From 0cc6bf989a928a751e4228f4dee86eac1f85c152 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 10:12:51 -0700 Subject: [PATCH 01/46] feat(cli): add release MVP sample --- cmd/release-mvp/main.go | 41 +++++++++++++++ go.mod | 16 ++++++ go.sum | 19 +++++++ internal/cli/root.go | 103 ++++++++++++++++++++++++++++++++++++++ internal/cli/root_test.go | 62 +++++++++++++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 cmd/release-mvp/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go diff --git a/cmd/release-mvp/main.go b/cmd/release-mvp/main.go new file mode 100644 index 0000000..3544dd5 --- /dev/null +++ b/cmd/release-mvp/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/meigma/release/internal/cli" +) + +var ( + version = "dev" + commit = "none" +) + +func main() { + os.Exit(run()) +} + +func run() int { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + root := cli.NewRootCommand(cli.Options{ + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + Build: cli.BuildInfo{ + Version: version, + Commit: commit, + }, + }) + if err := root.ExecuteContext(ctx); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + return 1 + } + + return 0 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d32b8e6 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/meigma/release + +go 1.26.4 + +require ( + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5352f4f --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..ee70b62 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,103 @@ +package cli + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" +) + +// BuildInfo describes linker-injected build metadata printed by --version. +type BuildInfo struct { + // Version is the release version. + Version string + // Commit is the source commit used to build the binary. + Commit string +} + +// Options customizes root command construction. +type Options struct { + // In receives command input. + In io.Reader + // Out receives command output. + Out io.Writer + // Err receives diagnostics. + Err io.Writer + // Build controls the root command version output. + Build BuildInfo +} + +// NewRootCommand creates the release-mvp Cobra command tree. +func NewRootCommand(options Options) *cobra.Command { + options = options.withDefaults() + + root := &cobra.Command{ + Use: "release-mvp", + Short: "Exercise the Meigma release pipeline", + Version: options.Build.Version, + SilenceUsage: true, + SilenceErrors: true, + } + root.SetVersionTemplate(fmt.Sprintf( + "release-mvp %s (%s)\n", + options.Build.Version, + options.Build.Commit, + )) + root.SetIn(options.In) + root.SetOut(options.Out) + root.SetErr(options.Err) + root.AddCommand(newGreetCommand()) + + return root +} + +func (options Options) withDefaults() Options { + if options.In == nil { + options.In = strings.NewReader("") + } + if options.Out == nil { + options.Out = io.Discard + } + if options.Err == nil { + options.Err = io.Discard + } + if strings.TrimSpace(options.Build.Version) == "" { + options.Build.Version = "dev" + } + if strings.TrimSpace(options.Build.Commit) == "" { + options.Build.Commit = "none" + } + + return options +} + +func newGreetCommand() *cobra.Command { + var uppercase bool + + cmd := &cobra.Command{ + Use: "greet [name]", + Short: "Print a greeting", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := "world" + if len(args) == 1 { + name = args[0] + } + + greeting := fmt.Sprintf("Hello, %s!", name) + if uppercase { + greeting = strings.ToUpper(greeting) + } + + if _, err := fmt.Fprintln(cmd.OutOrStdout(), greeting); err != nil { + return fmt.Errorf("write greeting: %w", err) + } + + return nil + }, + } + cmd.Flags().BoolVar(&uppercase, "uppercase", false, "print the greeting in uppercase") + + return cmd +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..b4c64a7 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,62 @@ +package cli_test + +import ( + "bytes" + "testing" + + "github.com/meigma/release/internal/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGreet(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + args []string + want string + }{ + "default name": { + args: []string{"greet"}, + want: "Hello, world!\n", + }, + "provided name": { + args: []string{"greet", "Meigma"}, + want: "Hello, Meigma!\n", + }, + "uppercase": { + args: []string{"greet", "Meigma", "--uppercase"}, + want: "HELLO, MEIGMA!\n", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + output := &bytes.Buffer{} + command := cli.NewRootCommand(cli.Options{Out: output}) + command.SetArgs(test.args) + + require.NoError(t, command.Execute()) + assert.Equal(t, test.want, output.String()) + }) + } +} + +func TestVersion(t *testing.T) { + t.Parallel() + + output := &bytes.Buffer{} + command := cli.NewRootCommand(cli.Options{ + Out: output, + Build: cli.BuildInfo{ + Version: "1.2.3", + Commit: "abc1234", + }, + }) + command.SetArgs([]string{"--version"}) + + require.NoError(t, command.Execute()) + assert.Equal(t, "release-mvp 1.2.3 (abc1234)\n", output.String()) +} From 9d23068ff3ffd609d1ca6440d0cad43d049b8312 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 10:29:31 -0700 Subject: [PATCH 02/46] build(mise): add pinned toolchain --- go.mod | 2 +- mise.lock | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ mise.toml | 16 +++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 mise.lock create mode 100644 mise.toml diff --git a/go.mod b/go.mod index d32b8e6..677bed2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/meigma/release -go 1.26.4 +go 1.26.6 require ( github.com/spf13/cobra v1.10.2 diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..bb0ca35 --- /dev/null +++ b/mise.lock @@ -0,0 +1,173 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html + +[[tools."aqua:astral-sh/uv"]] +version = "0.12.5" +backend = "aqua:astral-sh/uv" + +[tools."aqua:astral-sh/uv"."platforms.linux-arm64"] +checksum = "sha256:8767a0e77f2cd45436401b1b42bf7e9ed5a4a91a74a5305d6fe93249d0f6dbc5" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-unknown-linux-musl.tar.gz" +provenance = "github-attestations" + +[tools."aqua:astral-sh/uv"."platforms.linux-x64"] +checksum = "sha256:a4742988791c9aeae68c78150d6cba762062ad2a47e53738c2779d2b596bfcdb" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-unknown-linux-musl.tar.gz" +provenance = "github-attestations" + +[tools."aqua:astral-sh/uv"."platforms.macos-arm64"] +checksum = "sha256:5bb0e5fe008a773c3dbcb97ff79cd89e1241464fe9d2f986d52ad8f1b037bd62" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-apple-darwin.tar.gz" +provenance = "github-attestations" + +[tools."aqua:astral-sh/uv"."platforms.macos-x64"] +checksum = "sha256:b3b2137477cf96c9686ebfb71524614cec780c673fd73e59bce099aef02e70e8" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-apple-darwin.tar.gz" +provenance = "github-attestations" + +[[tools."aqua:chainguard-dev/apko"]] +version = "1.2.37" +backend = "aqua:chainguard-dev/apko" + +[tools."aqua:chainguard-dev/apko"."platforms.linux-arm64"] +checksum = "sha256:43e94fcda75df76e0b9fe78a98b2277ce4cc7eabe246e02df7c6f52c75b50f5d" +url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.37/apko_1.2.37_linux_arm64.tar.gz" + +[tools."aqua:chainguard-dev/apko"."platforms.linux-x64"] +checksum = "sha256:9fa4ed893d0d87483a5f709fd5e2f1a1e6f93e40b35195949df2d9cf4f7093cc" +url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.37/apko_1.2.37_linux_amd64.tar.gz" + +[tools."aqua:chainguard-dev/apko"."platforms.macos-arm64"] +checksum = "sha256:097ebf8e1f19278bca2e5fd788b7df002b40f25b2cbcda90165e5b78982b8c5b" +url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.37/apko_1.2.37_darwin_arm64.tar.gz" + +[tools."aqua:chainguard-dev/apko"."platforms.macos-x64"] +checksum = "sha256:7edc3c7c5839b7c8352c78e11f510537ff261f0bdcaf7b70c8fe985a1e3d5119" +url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.37/apko_1.2.37_darwin_amd64.tar.gz" + +[[tools."aqua:chainguard-dev/melange"]] +version = "0.59.1" +backend = "aqua:chainguard-dev/melange" + +[tools."aqua:chainguard-dev/melange"."platforms.linux-arm64"] +checksum = "sha256:aa5d221f92a248ba9aa490d1441c733417ad4c02f32fa9a13b8800acb42946f9" +url = "https://github.com/chainguard-dev/melange/releases/download/v0.59.1/melange_0.59.1_linux_arm64.tar.gz" + +[tools."aqua:chainguard-dev/melange"."platforms.linux-x64"] +checksum = "sha256:90f76f3e5fcb90ddc4c932ad352982bda98d58001d2bcccb982dd7fb2e608a1f" +url = "https://github.com/chainguard-dev/melange/releases/download/v0.59.1/melange_0.59.1_linux_amd64.tar.gz" + +[tools."aqua:chainguard-dev/melange"."platforms.macos-arm64"] +checksum = "sha256:6485fcff49ca60fc3f75a773eb1f7f81562cf394b86cc9fe8371f7d55e8f9b98" +url = "https://github.com/chainguard-dev/melange/releases/download/v0.59.1/melange_0.59.1_darwin_arm64.tar.gz" + +[tools."aqua:chainguard-dev/melange"."platforms.macos-x64"] +checksum = "sha256:0a1080caa973c9a10b9e331542e927531710810647c4f3b9ec9e53bf22f2e960" +url = "https://github.com/chainguard-dev/melange/releases/download/v0.59.1/melange_0.59.1_darwin_amd64.tar.gz" + +[[tools."aqua:golangci/golangci-lint"]] +version = "2.12.2" +backend = "aqua:golangci/golangci-lint" + +[tools."aqua:golangci/golangci-lint"."platforms.linux-arm64"] +checksum = "sha256:44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-linux-arm64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:golangci/golangci-lint"."platforms.linux-x64"] +checksum = "sha256:8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-linux-amd64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:golangci/golangci-lint"."platforms.macos-arm64"] +checksum = "sha256:a9c54498731b3128f79e090be6110f3e5fffccc617b08142ed244d4126c73f29" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-darwin-arm64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:golangci/golangci-lint"."platforms.macos-x64"] +checksum = "sha256:f6f06d94b6241521c53d15450c5209b028270bf966f842afb11c030c79f5bc16" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-darwin-amd64.tar.gz" +provenance = "github-attestations" + +[[tools."aqua:moonrepo/moon"]] +version = "2.5.1" +backend = "aqua:moonrepo/moon" + +[tools."aqua:moonrepo/moon"."platforms.linux-arm64"] +checksum = "sha256:9715272daa9f36026278981201b406680918934fba221108bc8b43a6915436d2" +url = "https://github.com/moonrepo/moon/releases/download/v2.5.1/moon_cli-aarch64-unknown-linux-musl.tar.xz" + +[tools."aqua:moonrepo/moon"."platforms.linux-x64"] +checksum = "sha256:e2fc74c22ad082702d36742e37f4088428b88b6862bb029d63ff22a31e28cfa9" +url = "https://github.com/moonrepo/moon/releases/download/v2.5.1/moon_cli-x86_64-unknown-linux-musl.tar.xz" + +[tools."aqua:moonrepo/moon"."platforms.macos-arm64"] +checksum = "sha256:980b30a3bae78a9d5cf3bb6e7bc203dde456c31011b6eb87ff2a26d2efa7d2fc" +url = "https://github.com/moonrepo/moon/releases/download/v2.5.1/moon_cli-aarch64-apple-darwin.tar.xz" + +[[tools."aqua:sigstore/cosign"]] +version = "3.1.3" +backend = "aqua:sigstore/cosign" + +[tools."aqua:sigstore/cosign"."platforms.linux-arm64"] +checksum = "sha256:c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a" +url = "https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-arm64" +provenance = "cosign" + +[tools."aqua:sigstore/cosign"."platforms.linux-x64"] +checksum = "sha256:4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71" +url = "https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64" +provenance = "cosign" + +[tools."aqua:sigstore/cosign"."platforms.macos-arm64"] +checksum = "sha256:5cf948c2f4dfe59687bdd0b8523709067383e03982cc543475c8a7dc70e92a76" +url = "https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-darwin-arm64" +provenance = "cosign" + +[tools."aqua:sigstore/cosign"."platforms.macos-x64"] +checksum = "sha256:2347488e5d5b25336644024dfeca5601b190e91197a71a917bda44744aff106c" +url = "https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-darwin-amd64" +provenance = "cosign" + +[[tools.go]] +version = "1.26.6" +backend = "core:go" + +[tools.go."platforms.linux-arm64"] +checksum = "sha256:d0507e9e9d7fe012aae570108cbd76c15de879e17130ab8cb90d4d7445cb1f2e" +url = "https://dl.google.com/go/go1.26.6.linux-arm64.tar.gz" + +[tools.go."platforms.linux-x64"] +checksum = "sha256:708effb774be8237570d0add163225abbdfaf4fca28b2611df167beba4feef89" +url = "https://dl.google.com/go/go1.26.6.linux-amd64.tar.gz" + +[tools.go."platforms.macos-arm64"] +checksum = "sha256:2dc95ce4675829f2df0e86b28bcef3283635902062a5f0580ca659bf570f3204" +url = "https://dl.google.com/go/go1.26.6.darwin-arm64.tar.gz" + +[tools.go."platforms.macos-x64"] +checksum = "sha256:08b65a63f244115121ced6c3b55ad38d801a7442acad5c949a17aad84ae6d684" +url = "https://dl.google.com/go/go1.26.6.darwin-amd64.tar.gz" + +[[tools.python]] +version = "3.14.7" +backend = "core:python" + +[tools.python."platforms.linux-arm64"] +checksum = "sha256:86dfb106b303fa6a1991f7da42ced3e11576f777be299b3162e06617b6d22542" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.14.7+20260814-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.linux-x64"] +checksum = "sha256:cefba034445d2875408d1fd4d5700ae6731563aeb54dcb39fd8164ab5c457533" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.14.7+20260814-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.macos-arm64"] +checksum = "sha256:423717c485b9ee7822590b9d973c1b5fb2cda0fe43448ab82a3d44f823bd329c" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.14.7+20260814-aarch64-apple-darwin-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.macos-x64"] +checksum = "sha256:e085b44e21d9c60acd866680747e1f82800cf9a1630a369da1723976d87bead1" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.14.7+20260814-x86_64-apple-darwin-install_only_stripped.tar.gz" +provenance = "github-attestations" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..b02e34e --- /dev/null +++ b/mise.toml @@ -0,0 +1,16 @@ +[tools] +go = "1.26.6" +python = "3.14.7" +"aqua:golangci/golangci-lint" = "2.12.2" +"aqua:astral-sh/uv" = "0.12.5" +"aqua:moonrepo/moon" = "2.5.1" +"aqua:chainguard-dev/melange" = "0.59.1" +"aqua:chainguard-dev/apko" = "1.2.37" +"aqua:sigstore/cosign" = "3.1.3" + +[env] +GOTOOLCHAIN = "local" + +[settings] +lockfile = true +locked = true From cbee1a554241e4705734d71d34e3716293022c3e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 10:57:02 -0700 Subject: [PATCH 03/46] build: add Moon CI and linting --- .github/workflows/ci.yml | 66 ++++++ .gitignore | 2 + .golangci.yml | 472 ++++++++++++++++++++++++++++++++++++++ .moon/workspace.yml | 12 + cmd/release-mvp/main.go | 1 + internal/cli/root_test.go | 3 +- moon.yml | 74 ++++++ 7 files changed, 629 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .golangci.yml create mode 100644 .moon/workspace.yml create mode 100644 moon.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1d3616b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + permissions: + contents: read + env: + GOTOOLCHAIN: local + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + cache: true + add_shims_to_path: false + export_path: false + + - name: Cache Go modules + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-mod-${{ hashFiles('go.sum') }} + restore-keys: | + ${{ runner.os }}-go-mod- + + - name: Cache Go build artifacts + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.cache/go-build + key: ${{ runner.os }}-go-build-${{ hashFiles('mise.lock', 'go.sum') }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-go-build-${{ hashFiles('mise.lock', 'go.sum') }}- + ${{ runner.os }}-go-build- + + - name: Cache golangci-lint + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.cache/golangci-lint + key: ${{ runner.os }}-golangci-lint-${{ hashFiles('mise.lock', '.golangci.yml', 'go.sum') }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-golangci-lint-${{ hashFiles('mise.lock', '.golangci.yml', 'go.sum') }}- + ${{ runner.os }}-golangci-lint- + + - name: Run Moon CI + run: mise exec -- moon ci --summary minimal diff --git a/.gitignore b/.gitignore index 5bdb90b..0d86317 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .claude/ .journal/ .wt/ +.moon/cache/ +bin/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..32f66aa --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,472 @@ +# This file is licensed under the terms of the MIT license https://opensource.org/license/mit +# Copyright (c) 2021-2025 Marat Reimers + +## Golden config for golangci-lint v2.12.2 +# +# This is the best config for golangci-lint based on my experience and opinion. +# It is very strict, but not extremely strict. +# Feel free to adapt it to suit your needs. +# If this config helps you, please consider keeping a link to this repo (see the next comment). + +# Based on https://github.com/maratori/golangci-lint-config + +version: "2" + +issues: + # Maximum count of issues with the same text. + # Set to 0 to disable. + # Default: 3 + max-same-issues: 50 + +formatters: + enable: + - goimports # checks if the code and import statements are formatted according to the 'goimports' command + - golines # checks if code is formatted, and fixes long lines + + ## you may want to enable + #- gci # checks if code and import statements are formatted, with additional rules + #- gofmt # checks if the code is formatted according to 'gofmt' command + #- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible + #- swaggo # formats swaggo comments + + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + settings: + goimports: + # A list of prefixes, which, if set, checks import paths + # with the given prefixes are grouped after 3rd-party packages. + # Default: [] + local-prefixes: + - github.com/meigma/release + + golines: + # Target maximum line length. + # Default: 100 + max-len: 120 + +linters: + enable: + - asasalint # checks for pass []any as any in variadic func(...any) + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - canonicalheader # checks whether net/http.Header uses canonical header + - copyloopvar # detects places where loop variables are copied (Go 1.22+) + - cyclop # checks function and package cyclomatic complexity + - depguard # checks if package imports are in a list of acceptable packages + - dupl # tool for code clone detection + - durationcheck # checks for two durations multiplied together + - embeddedstructfieldcheck # checks embedded types in structs + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - exhaustive # checks exhaustiveness of enum switch statements + - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions + - fatcontext # detects nested contexts in loops + - forbidigo # forbids identifiers + - funcorder # checks the order of functions, methods, and constructors + - funlen # tool for detection of long functions + - gocheckcompilerdirectives # validates go compiler directive comments (//go:) + - gochecknoglobals # checks that no global variables exist + - gochecknoinits # checks that no init functions are present in Go code + - gochecksumtype # checks exhaustiveness on Go "sum types" + - gocognit # computes and checks the cognitive complexity of functions + - goconst # finds repeated strings that could be replaced by a constant + - gocritic # provides diagnostics that check for bugs, performance and style issues + - gocyclo # computes and checks the cyclomatic complexity of functions + - godoclint # checks Golang's documentation practice + - godot # checks if comments end in a period + - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod + - goprintffuncname # checks that printf-like functions are named with f at the end + - gosec # inspects source code for security problems + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution + - ineffassign # detects when assignments to existing variables are not used + - intrange # finds places where for loops could make use of an integer range + - iotamixing # checks if iotas are being used in const blocks with other non-iota declarations + - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) + - makezero # finds slice declarations with non-zero initial length + - mirror # reports wrong mirror patterns of bytes/strings usage + - mnd # detects magic numbers + - modernize # suggests simplifications to Go code, using modern language and library features + - musttag # enforces field tags in (un)marshaled structs + - nakedret # finds naked returns in functions greater than a specified function length + - nestif # reports deeply nested if statements + - nilerr # finds the code that returns nil even if it checks that the error is not nil + - nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr) + - nilnil # checks that there is no simultaneous return of nil error and an invalid value + - noctx # finds sending http request without context.Context + - nolintlint # reports ill-formed or insufficient nolint directives + - nonamedreturns # reports all named returns + - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL + - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative + - predeclared # finds code that shadows one of Go's predeclared identifiers + - promlinter # checks Prometheus metrics naming via promlint + - protogetter # reports direct reads from proto message fields when getters should be used + - reassign # checks that package variables are not reassigned + - recvcheck # checks for receiver type consistency + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - rowserrcheck # checks whether Err of rows is checked successfully + - sloglint # ensure consistent code style when using log/slog + - spancheck # checks for mistakes with OpenTelemetry/Census spans + - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - testableexamples # checks if examples are testable (have an expected output) + - testifylint # checks usage of github.com/stretchr/testify + # testpackage disabled: tests intentionally use whitebox style to exercise + # unexported helpers and shared internal fixtures across multiple test files. + # Re-enable if the test layout grows large enough that + # blackbox-only is worth the export pollution. + #- testpackage + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - unqueryvet # detects SELECT * in SQL queries and SQL builders, encouraging explicit column selection + - unused # checks for unused constants, variables, functions and types + - usestdlibvars # detects the possibility to use variables/constants from the Go standard library + - usetesting # reports uses of functions with replacement inside the testing package + - wastedassign # finds wasted assignment statements + - whitespace # detects leading and trailing whitespace + + ## you may want to enable + #- arangolint # opinionated best practices for arangodb client + #- decorder # checks declaration order and count of types, constants, variables and functions + #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized + #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega + #- godox # detects usage of FIXME, TODO and other keywords inside comments + #- goheader # checks is file header matches to pattern + #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters + #- interfacebloat # checks the number of methods inside an interface + #- ireturn # accept interfaces, return concrete types + #- noinlineerr # disallows inline error handling `if err := ...; err != nil {` + #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated + #- tagalign # checks that struct tags are well aligned + #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope + #- wrapcheck # checks that errors returned from external packages are wrapped + #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event + + ## disabled + #- containedctx # detects struct contained context.Context field + #- contextcheck # [too many false positives] checks the function whether use a non-inherited context + #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + #- dupword # [useless without config] checks for duplicate words in the source code + #- err113 # [too strict] checks the errors handling expressions + #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- forcetypeassert # [replaced by errcheck] finds forced type assertions + #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies + #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase + #- grouper # analyzes expression groups + #- importas # enforces consistent import aliases + #- lll # [replaced by golines] reports long lines + #- maintidx # measures the maintainability index of each function + #- misspell # [useless] finds commonly misspelled English words in comments + #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity + #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test + #- tagliatelle # checks the struct tags + #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines + #- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines + + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + settings: + cyclop: + # The maximal code complexity to report. + # Default: 10 + max-complexity: 30 + # The maximal average package complexity. + # If it's higher than 0.0 (float) the check is enabled. + # Default: 0.0 + package-average: 10.0 + + depguard: + # Rules to apply. + # + # Variables: + # - File Variables + # Use an exclamation mark `!` to negate a variable. + # Example: `!$test` matches any file that is not a go test file. + # + # `$all` - matches all go files + # `$test` - matches all go test files + # + # - Package Variables + # + # `$gostd` - matches all of go's standard library (Pulled from `GOROOT`) + # + # Default (applies if no custom rules are defined): Only allow $gostd in all files. + rules: + "deprecated": + # List of file globs that will match this list of settings to compare against. + # By default, if a path is relative, it is relative to the directory where the golangci-lint command is executed. + # The placeholder '${base-path}' is substituted with a path relative to the mode defined with `run.relative-path-mode`. + # The placeholder '${config-path}' is substituted with a path relative to the configuration file. + # Default: $all + files: + - "$all" + # List of packages that are not allowed. + # Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $). + # Default: [] + deny: + - pkg: github.com/golang/protobuf + desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + - pkg: github.com/satori/go.uuid + desc: Use github.com/google/uuid instead, satori's package is not maintained + - pkg: github.com/gofrs/uuid$ + desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5 + "non-test files": + files: + - "!$test" + deny: + - pkg: math/rand$ + desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2 + "non-main files": + files: + - "!**/main.go" + deny: + - pkg: log$ + desc: Use log/slog instead, see https://go.dev/blog/slog + + embeddedstructfieldcheck: + # Checks that sync.Mutex and sync.RWMutex are not used as embedded fields. + # Default: false + forbid-mutex: true + + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Such cases aren't reported by default. + # Default: false + check-type-assertions: true + + exhaustive: + # Program elements to check for exhaustiveness. + # Default: [ switch ] + check: + - switch + - map + + exhaustruct: + # List of regular expressions to match type names that should be excluded from processing. + # Anonymous structs can be matched by '' alias. + # Has precedence over `include`. + # Each regular expression must match the full type name, including package path. + # For example, to match type `net/http.Cookie` regular expression should be `.*/http\.Cookie`, + # but not `http\.Cookie`. + # Default: [] + exclude: + # std libs + - ^net/http.Client$ + - ^net/http.Cookie$ + - ^net/http.Request$ + - ^net/http.Response$ + - ^net/http.Server$ + - ^net/http.Transport$ + - ^net/url.URL$ + - ^os/exec.Cmd$ + - ^reflect.StructField$ + # public libs + - ^github.com/Shopify/sarama.Config$ + - ^github.com/Shopify/sarama.ProducerMessage$ + - ^github.com/mitchellh/mapstructure.DecoderConfig$ + - ^github.com/prometheus/client_golang/.+Opts$ + - ^github.com/spf13/cobra.Command$ + - ^github.com/spf13/cobra.CompletionOptions$ + - ^github.com/stretchr/testify/mock.Mock$ + - ^github.com/testcontainers/testcontainers-go.+Request$ + - ^github.com/testcontainers/testcontainers-go.FromDockerfile$ + - ^golang.org/x/tools/go/analysis.Analyzer$ + - ^google.golang.org/protobuf/.+Options$ + - ^gopkg.in/yaml.v3.Node$ + # Allows empty structures in return statements. + # Default: false + allow-empty-returns: true + + funcorder: + # Checks if the exported methods of a structure are placed before the non-exported ones. + # Default: true + struct-method: false + + funlen: + # Checks the number of lines in a function. + # If lower than 0, disable the check. + # Default: 60 + lines: 100 + # Checks the number of statements in a function. + # If lower than 0, disable the check. + # Default: 40 + statements: 50 + + gochecksumtype: + # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed. + # Default: true + default-signifies-exhaustive: false + + gocognit: + # Minimal code complexity to report. + # Default: 30 (but we recommend 10-20) + min-complexity: 20 + + gocritic: + # Settings passed to gocritic. + # The settings key is the name of a supported gocritic checker. + # The list of supported checkers can be found at https://go-critic.com/overview. + settings: + captLocal: + # Whether to restrict checker to params only. + # Default: true + paramsOnly: false + underef: + # Whether to skip (*x).method() calls where x is a pointer receiver. + # Default: true + skipRecvDeref: false + + godoclint: + # List of rules to enable in addition to the default set. + # Default: empty + enable: + # Assert no unused link in godocs. + # https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#no-unused-link + - no-unused-link + # Require proper doc links to standard library declarations where applicable. + # https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#require-stdlib-doclink + - require-stdlib-doclink + + govet: + # Enable all analyzers. + # Default: false + enable-all: true + # Disable analyzers by name. + # Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers. + # Default: [] + disable: + - fieldalignment # too strict + + inamedparam: + # Skips check for interface methods with only a single parameter. + # Default: false + skip-single-param: true + + mnd: + # List of function patterns to exclude from analysis. + # Values always ignored: `time.Date`, + # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, + # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. + # Default: [] + ignored-functions: + - args.Error + - flag.Arg + - flag.Duration.* + - flag.Float.* + - flag.Int.* + - flag.Uint.* + - os.Chmod + - os.Mkdir.* + - os.OpenFile + - os.WriteFile + - prometheus.ExponentialBuckets.* + - prometheus.LinearBuckets + + nakedret: + # Make an issue if func has more lines of code than this setting, and it has naked returns. + # Default: 30 + max-func-lines: 0 + + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [ funlen, gocognit, golines ] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + + perfsprint: + # Optimizes into strings concatenation. + # Default: true + strconcat: false + + reassign: + # Patterns for global variable names that are checked for reassignment. + # See https://github.com/curioswitch/go-reassign#usage + # Default: ["EOF", "Err.*"] + patterns: + - ".*" + + rowserrcheck: + # database/sql is always checked. + # Default: [] + packages: + - github.com/jmoiron/sqlx + + sloglint: + # Enforce not using global loggers. + # Values: + # - "": disabled + # - "all": report all global loggers + # - "default": report only the default slog logger + # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global + # Default: "" + no-global: all + # Enforce using methods that accept a context. + # Values: + # - "": disabled + # - "all": report all contextless calls + # - "scope": report only if a context exists in the scope of the outermost function + # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only + # Default: "" + context: scope + + staticcheck: + # SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks + # Example (to disable some checks): [ "all", "-SA1000", "-SA1001"] + # Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"] + checks: + - all + # Incorrect or missing package comment. + # https://staticcheck.dev/docs/checks/#ST1000 + - -ST1000 + # Use consistent method receiver names. + # https://staticcheck.dev/docs/checks/#ST1016 + - -ST1016 + # Omit embedded fields from selector expression. + # https://staticcheck.dev/docs/checks/#QF1008 + - -QF1008 + + usetesting: + # Enable/disable `os.TempDir()` detections. + # Default: false + os-temp-dir: true + + exclusions: + # Log a warning if an exclusion rule is unused. + # Default: false + warn-unused: false + # Predefined exclusion rules. + # Default: [] + presets: + - std-error-handling + - common-false-positives + # Excluding configuration per-path, per-linter, per-text and per-source. + rules: + - source: 'TODO' + linters: [ godot ] + - text: 'should have a package comment' + linters: [ revive ] + - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' + linters: [ revive ] + - text: 'package comment should be of the form ".+"' + source: '// ?(nolint|TODO)' + linters: [ revive ] + - text: 'comment on exported \S+ \S+ should be of the form ".+"' + source: '// ?(nolint|TODO)' + linters: [ revive, staticcheck ] + - path: '_test\.go' + linters: + - bodyclose + - dupl + - errcheck + - funlen + - goconst + - gosec + - noctx + - wrapcheck diff --git a/.moon/workspace.yml b/.moon/workspace.yml new file mode 100644 index 0000000..4184322 --- /dev/null +++ b/.moon/workspace.yml @@ -0,0 +1,12 @@ +projects: + sources: + root: '.' + +defaultProject: 'root' + +vcs: + defaultBranch: 'main' + provider: 'github' + +pipeline: + installDependencies: false diff --git a/cmd/release-mvp/main.go b/cmd/release-mvp/main.go index 3544dd5..b128a0c 100644 --- a/cmd/release-mvp/main.go +++ b/cmd/release-mvp/main.go @@ -10,6 +10,7 @@ import ( "github.com/meigma/release/internal/cli" ) +//nolint:gochecknoglobals // Linker-injected build metadata. var ( version = "dev" commit = "none" diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index b4c64a7..70bc9dd 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -4,9 +4,10 @@ import ( "bytes" "testing" - "github.com/meigma/release/internal/cli" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/cli" ) func TestGreet(t *testing.T) { diff --git a/moon.yml b/moon.yml new file mode 100644 index 0000000..c413289 --- /dev/null +++ b/moon.yml @@ -0,0 +1,74 @@ +language: 'go' +layer: 'application' +stack: 'backend' + +project: + title: 'release-mvp' + description: 'Exercise the Meigma release pipeline.' + owner: 'meigma' + maintainers: + - 'meigma' + +toolchains: + default: 'system' + +fileGroups: + goSources: + - 'cmd/**/*.go' + - 'internal/**/*.go' + - 'go.mod' + - 'go.sum' + - 'mise.toml' + - 'mise.lock' + lintConfig: + - '.golangci.yml' + - 'mise.toml' + - 'mise.lock' + +workspace: + inheritedTasks: + include: [] + +tasks: + format: + command: 'mise exec -- golangci-lint fmt --config .golangci.yml --diff' + inputs: + - '@group(goSources)' + - '@group(lintConfig)' + options: + cache: false + + lint: + command: 'mise exec -- golangci-lint run --config .golangci.yml ./... --show-stats=false' + inputs: + - '@group(goSources)' + - '@group(lintConfig)' + options: + cache: false + + build: + command: 'mise exec -- go build -o bin/release-mvp ./cmd/release-mvp' + inputs: + - '@group(goSources)' + outputs: + - 'bin/release-mvp' + + test: + command: 'mise exec -- go test ./...' + inputs: + - '@group(goSources)' + options: + cache: false + + check: + deps: + - 'root:format' + - 'root:lint' + - 'root:build' + - 'root:test' + inputs: + - '@group(goSources)' + - '@group(lintConfig)' + options: + cache: false + runInCI: true From d16efa4fc310bb166de639040132b1e543f12850 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 12:18:13 -0700 Subject: [PATCH 04/46] test(ci): exercise reusable workflow --- .github/workflows/ci.yml | 48 ++----------------------------- .github/workflows/go-ci.yml | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/go-ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d3616b..8b33d31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: push: branches: - main + - mvp permissions: {} @@ -16,51 +17,6 @@ concurrency: jobs: ci: - runs-on: ubuntu-latest permissions: contents: read - env: - GOTOOLCHAIN: local - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Setup mise - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 - with: - version: 2026.8.8 - cache: true - add_shims_to_path: false - export_path: false - - - name: Cache Go modules - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-mod-${{ hashFiles('go.sum') }} - restore-keys: | - ${{ runner.os }}-go-mod- - - - name: Cache Go build artifacts - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: ~/.cache/go-build - key: ${{ runner.os }}-go-build-${{ hashFiles('mise.lock', 'go.sum') }}-${{ github.run_id }} - restore-keys: | - ${{ runner.os }}-go-build-${{ hashFiles('mise.lock', 'go.sum') }}- - ${{ runner.os }}-go-build- - - - name: Cache golangci-lint - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: ~/.cache/golangci-lint - key: ${{ runner.os }}-golangci-lint-${{ hashFiles('mise.lock', '.golangci.yml', 'go.sum') }}-${{ github.run_id }} - restore-keys: | - ${{ runner.os }}-golangci-lint-${{ hashFiles('mise.lock', '.golangci.yml', 'go.sum') }}- - ${{ runner.os }}-golangci-lint- - - - name: Run Moon CI - run: mise exec -- moon ci --summary minimal + uses: ./.github/workflows/go-ci.yml diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000..3a07273 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,57 @@ +name: Reusable Go CI + +on: + workflow_call: + +permissions: {} + +jobs: + ci: + runs-on: ubuntu-latest + permissions: + contents: read + env: + GOTOOLCHAIN: local + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + cache: true + add_shims_to_path: false + export_path: false + + - name: Cache Go modules + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-mod-${{ hashFiles('go.sum') }} + restore-keys: | + ${{ runner.os }}-go-mod- + + - name: Cache Go build artifacts + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.cache/go-build + key: ${{ runner.os }}-go-build-${{ hashFiles('mise.lock', 'go.sum') }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-go-build-${{ hashFiles('mise.lock', 'go.sum') }}- + ${{ runner.os }}-go-build- + + - name: Cache golangci-lint + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.cache/golangci-lint + key: ${{ runner.os }}-golangci-lint-${{ hashFiles('mise.lock', '.golangci.yml', 'go.sum') }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-golangci-lint-${{ hashFiles('mise.lock', '.golangci.yml', 'go.sum') }}- + ${{ runner.os }}-golangci-lint- + + - name: Run Moon CI + run: mise exec -- moon ci --summary minimal From f751527b0bd5c726e39b70d894dfc827b31deceb Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 12:19:35 -0700 Subject: [PATCH 05/46] chore(ci): remove mvp test trigger --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b33d31..f5fa459 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,6 @@ on: push: branches: - main - - mvp permissions: {} From 3389107efb716f6474cb74668f29cd4fba245b7f Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 12:56:06 -0700 Subject: [PATCH 06/46] fix(ci): harden reusable workflow --- .github/workflows/ci.yml | 3 ++- .github/workflows/go-ci.yml | 4 +++- mise.lock | 36 ++++++++++++++++++++---------------- mise.toml | 2 +- moon.yml | 8 ++++++++ 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5fa459..9685df1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,13 @@ on: push: branches: - main + - mvp permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci: diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index 3a07273..4b95ee4 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -7,7 +7,8 @@ permissions: {} jobs: ci: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 20 permissions: contents: read env: @@ -17,6 +18,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + filter: 'blob:none' persist-credentials: false - name: Setup mise diff --git a/mise.lock b/mise.lock index bb0ca35..9ed9aa1 100644 --- a/mise.lock +++ b/mise.lock @@ -88,22 +88,6 @@ checksum = "sha256:f6f06d94b6241521c53d15450c5209b028270bf966f842afb11c030c79f5b url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-darwin-amd64.tar.gz" provenance = "github-attestations" -[[tools."aqua:moonrepo/moon"]] -version = "2.5.1" -backend = "aqua:moonrepo/moon" - -[tools."aqua:moonrepo/moon"."platforms.linux-arm64"] -checksum = "sha256:9715272daa9f36026278981201b406680918934fba221108bc8b43a6915436d2" -url = "https://github.com/moonrepo/moon/releases/download/v2.5.1/moon_cli-aarch64-unknown-linux-musl.tar.xz" - -[tools."aqua:moonrepo/moon"."platforms.linux-x64"] -checksum = "sha256:e2fc74c22ad082702d36742e37f4088428b88b6862bb029d63ff22a31e28cfa9" -url = "https://github.com/moonrepo/moon/releases/download/v2.5.1/moon_cli-x86_64-unknown-linux-musl.tar.xz" - -[tools."aqua:moonrepo/moon"."platforms.macos-arm64"] -checksum = "sha256:980b30a3bae78a9d5cf3bb6e7bc203dde456c31011b6eb87ff2a26d2efa7d2fc" -url = "https://github.com/moonrepo/moon/releases/download/v2.5.1/moon_cli-aarch64-apple-darwin.tar.xz" - [[tools."aqua:sigstore/cosign"]] version = "3.1.3" backend = "aqua:sigstore/cosign" @@ -148,6 +132,26 @@ url = "https://dl.google.com/go/go1.26.6.darwin-arm64.tar.gz" checksum = "sha256:08b65a63f244115121ced6c3b55ad38d801a7442acad5c949a17aad84ae6d684" url = "https://dl.google.com/go/go1.26.6.darwin-amd64.tar.gz" +[[tools."http:moon"]] +version = "2.5.1" +backend = "http:moon" + +[tools."http:moon"."platforms.linux-arm64"] +checksum = "sha256:9715272daa9f36026278981201b406680918934fba221108bc8b43a6915436d2" +url = "https://github.com/moonrepo/moon/releases/download/v2.5.1/moon_cli-aarch64-unknown-linux-musl.tar.xz" + +[tools."http:moon"."platforms.linux-x64"] +checksum = "sha256:e2fc74c22ad082702d36742e37f4088428b88b6862bb029d63ff22a31e28cfa9" +url = "https://github.com/moonrepo/moon/releases/download/v2.5.1/moon_cli-x86_64-unknown-linux-musl.tar.xz" + +[tools."http:moon"."platforms.macos-arm64"] +checksum = "sha256:980b30a3bae78a9d5cf3bb6e7bc203dde456c31011b6eb87ff2a26d2efa7d2fc" +url = "https://github.com/moonrepo/moon/releases/download/v2.5.1/moon_cli-aarch64-apple-darwin.tar.xz" + +[tools."http:moon"."platforms.macos-x64"] +checksum = "sha256:0d674b5fb1a2f6e188affa233033add47fd793185dde406045fc26fc4fba1820" +url = "https://github.com/moonrepo/moon/releases/download/v2.5.1/moon_cli-x86_64-apple-darwin.tar.xz" + [[tools.python]] version = "3.14.7" backend = "core:python" diff --git a/mise.toml b/mise.toml index b02e34e..23bfd0f 100644 --- a/mise.toml +++ b/mise.toml @@ -3,7 +3,7 @@ go = "1.26.6" python = "3.14.7" "aqua:golangci/golangci-lint" = "2.12.2" "aqua:astral-sh/uv" = "0.12.5" -"aqua:moonrepo/moon" = "2.5.1" +"http:moon" = { version = "2.5.1", url = 'https://github.com/moonrepo/moon/releases/download/v{{ version }}/moon_cli-{{ arch(x64="x86_64", arm64="aarch64") }}-{{ os(linux="unknown-linux-musl", macos="apple-darwin") }}.tar.xz', checksum_url = 'https://github.com/moonrepo/moon/releases/download/v{{ version }}/moon_cli-{{ arch(x64="x86_64", arm64="aarch64") }}-{{ os(linux="unknown-linux-musl", macos="apple-darwin") }}.tar.xz.sha256' } "aqua:chainguard-dev/melange" = "0.59.1" "aqua:chainguard-dev/apko" = "1.2.37" "aqua:sigstore/cosign" = "3.1.3" diff --git a/moon.yml b/moon.yml index c413289..885675a 100644 --- a/moon.yml +++ b/moon.yml @@ -24,6 +24,9 @@ fileGroups: - '.golangci.yml' - 'mise.toml' - 'mise.lock' + ciConfig: + - 'moon.yml' + - '.github/workflows/**/*.yml' workspace: inheritedTasks: @@ -35,6 +38,7 @@ tasks: inputs: - '@group(goSources)' - '@group(lintConfig)' + - '@group(ciConfig)' options: cache: false @@ -43,6 +47,7 @@ tasks: inputs: - '@group(goSources)' - '@group(lintConfig)' + - '@group(ciConfig)' options: cache: false @@ -50,6 +55,7 @@ tasks: command: 'mise exec -- go build -o bin/release-mvp ./cmd/release-mvp' inputs: - '@group(goSources)' + - '@group(ciConfig)' outputs: - 'bin/release-mvp' @@ -57,6 +63,7 @@ tasks: command: 'mise exec -- go test ./...' inputs: - '@group(goSources)' + - '@group(ciConfig)' options: cache: false @@ -69,6 +76,7 @@ tasks: inputs: - '@group(goSources)' - '@group(lintConfig)' + - '@group(ciConfig)' options: cache: false runInCI: true From e354b31fd30d85628270225a6fa9fedcecbfacd3 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 12:56:49 -0700 Subject: [PATCH 07/46] chore(ci): remove review test trigger --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9685df1..0594d33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,6 @@ on: push: branches: - main - - mvp permissions: {} From 159140a063c99d32a820d0d04f80cfab7cea562d Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 13:08:23 -0700 Subject: [PATCH 08/46] build(release): add release-please config --- .release-please-manifest.json | 3 +++ release-please-config.json | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..bbf285e --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "go", + "include-v-in-tag": true, + "include-component-in-tag": false, + "force-tag-creation": true, + "draft": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "packages": { + ".": { + "package-name": "release-mvp", + "changelog-path": "CHANGELOG.md" + } + }, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "deps", "section": "Dependencies" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "chore", "section": "Chores", "hidden": true } + ] +} From 1afe4a721dc98aee309b48836a3cd13eedd920bf Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 13:09:07 -0700 Subject: [PATCH 09/46] fix(release): start releases at v0.1.0 --- release-please-config.json | 1 + 1 file changed, 1 insertion(+) diff --git a/release-please-config.json b/release-please-config.json index bbf285e..942b03b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,6 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "release-type": "go", + "initial-version": "0.1.0", "include-v-in-tag": true, "include-component-in-tag": false, "force-tag-creation": true, From 12ff06ffe85e220ee29dff0d6472b7eee407b1e8 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 14:57:58 -0700 Subject: [PATCH 10/46] ci(release): add release-please workflow --- .github/workflows/release-please.yml | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/release-please.yml diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..9e6a01b --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,39 @@ +# Required release app settings: +# - vars.MEIGMA_RELEASE_APP_ID +# - secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY +# - protected-tag ruleset bypass for the release app, if protected v* tags are enabled. + +name: Release Please + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: {} + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + issues: write + steps: + - name: Create release app token + id: release-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.MEIGMA_RELEASE_APP_ID }} + private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} + + - name: Run Release Please + id: release + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.release-app.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json From 58c4b0f98b700e8f7651a65d6dc0d5872bd70e6c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 14:58:17 -0700 Subject: [PATCH 11/46] test(release): exercise app token workflow --- .github/workflows/release-please.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 9e6a01b..43802b0 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -9,6 +9,7 @@ on: push: branches: - main + - mvp workflow_dispatch: permissions: {} @@ -37,3 +38,4 @@ jobs: token: ${{ steps.release-app.outputs.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json + target-branch: mvp From 7c2cd1d70d5376f730a07327278f723f6a7889ca Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 14:59:35 -0700 Subject: [PATCH 12/46] test(release): verify client ID and CI fan-out --- .github/workflows/ci.yml | 1 + .github/workflows/release-please.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0594d33..7996322 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - mvp push: branches: - main diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 43802b0..b8d05a3 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,5 +1,5 @@ # Required release app settings: -# - vars.MEIGMA_RELEASE_APP_ID +# - vars.MEIGMA_RELEASE_APP_CLIENT_ID # - secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY # - protected-tag ruleset bypass for the release app, if protected v* tags are enabled. @@ -28,7 +28,7 @@ jobs: id: release-app uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: - app-id: ${{ vars.MEIGMA_RELEASE_APP_ID }} + client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} - name: Run Release Please From d7fc4063e38f2bd10abf300971844b556f54a575 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 15:00:10 -0700 Subject: [PATCH 13/46] fix(mvp): exercise release event fan-out From aeefd90729d2e0f8ecf1bde148f4fff0aeffe01a Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 15:01:25 -0700 Subject: [PATCH 14/46] fix(release): use GitHub App client ID --- .github/workflows/ci.yml | 1 - .github/workflows/release-please.yml | 2 -- 2 files changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7996322..0594d33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: pull_request: branches: - main - - mvp push: branches: - main diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b8d05a3..11f1be0 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -9,7 +9,6 @@ on: push: branches: - main - - mvp workflow_dispatch: permissions: {} @@ -38,4 +37,3 @@ jobs: token: ${{ steps.release-app.outputs.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json - target-branch: mvp From 5ac6b9fc0b62424b76bdb45004027fcdc2658871 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 15:08:02 -0700 Subject: [PATCH 15/46] build(release): add GoReleaser config --- .gitignore | 1 + .goreleaser.yaml | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 .goreleaser.yaml diff --git a/.gitignore b/.gitignore index 0d86317..37af592 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .wt/ .moon/cache/ bin/ +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..9f08e4c --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,76 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +project_name: release-mvp + +before: + hooks: + - go test ./... + +gomod: + proxy: true + env: + - GOTOOLCHAIN=local + - GOPROXY=https://proxy.golang.org,direct + - GOSUMDB=sum.golang.org + +builds: + - id: release-mvp + main: ./cmd/release-mvp + binary: release-mvp + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + flags: + - -trimpath + ldflags: + - -s -w -buildid= + - -X main.version={{ .Version }} + - -X main.commit={{ .FullCommit }} + mod_timestamp: "{{ .CommitTimestamp }}" + +archives: + - id: release-mvp + ids: + - release-mvp + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + +checksum: + name_template: checksums.txt + +sboms: + - id: archives + artifacts: archive + +signs: + - id: checksums + cmd: cosign + artifacts: checksum + signature: "${artifact}.sigstore.json" + args: + - sign-blob + - "--bundle=${signature}" + - "${artifact}" + - --yes + +changelog: + disable: true + +release: + draft: true + prerelease: auto + mode: keep-existing + use_existing_draft: true From ecec3f989a090061ce53d0a4cc18fc520990b045 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 15:37:19 -0700 Subject: [PATCH 16/46] ci(release): build authoritative assets --- .github/workflows/go-pre-publish.yml | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 .github/workflows/go-pre-publish.yml diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml new file mode 100644 index 0000000..0a25fb0 --- /dev/null +++ b/.github/workflows/go-pre-publish.yml @@ -0,0 +1,97 @@ +name: Reusable Go Pre-publish + +on: + push: + tags: + - 'v*' + workflow_call: + outputs: + artifact-id: + description: ID of the authoritative release-assets artifact. + value: ${{ jobs.release-assets.outputs.artifact-id }} + artifact-url: + description: URL of the authoritative release-assets artifact. + value: ${{ jobs.release-assets.outputs.artifact-url }} + artifact-digest: + description: SHA-256 digest of the authoritative release-assets artifact. + value: ${{ jobs.release-assets.outputs.artifact-digest }} + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-assets: + name: Build authoritative release assets + if: github.event_name != 'push' || github.event.deleted == false + runs-on: ubuntu-24.04 + timeout-minutes: 20 + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + artifact-url: ${{ steps.upload.outputs.artifact-url }} + artifact-digest: ${{ steps.upload.outputs.artifact-digest }} + permissions: + contents: read + id-token: write + steps: + - name: Require a tag ref + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" != 'tag' ]]; then + echo '::error::Go pre-publish must run against a tag ref.' + exit 1 + fi + + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + filter: 'blob:none' + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + + - name: Install Syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + syft-version: v1.51.0 + + - name: Install Cosign + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + with: + cosign-release: v3.1.3 + + - name: Build and sign release assets + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + with: + distribution: goreleaser + version: v2.17.1 + args: release --clean --skip=publish + + - name: Verify release asset checksums + working-directory: dist + shell: bash + run: | + sha256sum --check checksums.txt + test -s checksums.txt.sigstore.json + + - name: Upload authoritative release assets + id: upload + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-assets + path: | + dist/*.tar.gz + dist/*.zip + dist/*.sbom.json + dist/checksums.txt + dist/checksums.txt.sigstore.json + if-no-files-found: error + retention-days: 7 + compression-level: 0 From d39ea538956e63fd1f39a9d5ef8c5b7871716ff5 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 15:39:58 -0700 Subject: [PATCH 17/46] fix(release): use Node 24 artifact upload --- .github/workflows/go-pre-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml index 0a25fb0..72ae3e7 100644 --- a/.github/workflows/go-pre-publish.yml +++ b/.github/workflows/go-pre-publish.yml @@ -83,7 +83,7 @@ jobs: - name: Upload authoritative release assets id: upload - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-assets path: | From ab509b6cb972b578f3a60115b2732561331b3306 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 15:44:21 -0700 Subject: [PATCH 18/46] refactor(release): separate reusable asset build --- .github/workflows/go-pre-publish.yml | 8 -------- .github/workflows/pre-publish.yml | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/pre-publish.yml diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml index 72ae3e7..cc1c2f1 100644 --- a/.github/workflows/go-pre-publish.yml +++ b/.github/workflows/go-pre-publish.yml @@ -1,9 +1,6 @@ name: Reusable Go Pre-publish on: - push: - tags: - - 'v*' workflow_call: outputs: artifact-id: @@ -18,14 +15,9 @@ on: permissions: {} -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - jobs: release-assets: name: Build authoritative release assets - if: github.event_name != 'push' || github.event.deleted == false runs-on: ubuntu-24.04 timeout-minutes: 20 outputs: diff --git a/.github/workflows/pre-publish.yml b/.github/workflows/pre-publish.yml new file mode 100644 index 0000000..65b94b3 --- /dev/null +++ b/.github/workflows/pre-publish.yml @@ -0,0 +1,21 @@ +name: Pre-publish + +on: + push: + tags: + - 'v*' + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-assets: + name: Build release assets + if: github.event.deleted == false + permissions: + contents: read + id-token: write + uses: ./.github/workflows/go-pre-publish.yml From 0972d26d8a957355da20143882b9637d3c83a1ed Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 16:21:06 -0700 Subject: [PATCH 19/46] refactor(release): run GoReleaser through mise --- .github/workflows/go-pre-publish.yml | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml index cc1c2f1..10cddc7 100644 --- a/.github/workflows/go-pre-publish.yml +++ b/.github/workflows/go-pre-publish.yml @@ -27,6 +27,8 @@ jobs: permissions: contents: read id-token: write + env: + GOTOOLCHAIN: local steps: - name: Require a tag ref shell: bash @@ -43,11 +45,14 @@ jobs: filter: 'blob:none' persist-credentials: false - - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 with: - go-version-file: go.mod + version: 2026.8.8 + install_args: go cache: true + add_shims_to_path: false + export_path: false - name: Install Syft uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 @@ -60,11 +65,16 @@ jobs: cosign-release: v3.1.3 - name: Build and sign release assets - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 - with: - distribution: goreleaser - version: v2.17.1 - args: release --clean --skip=publish + shell: bash + run: | + mise x aqua:goreleaser/goreleaser@2.17.1 -- bash -euo pipefail -c ' + mise_go=$(mise which go) + active_go=$(command -v go) + test "$(realpath "${active_go}")" = "$(realpath "${mise_go}")" + printf "Using %s\n" "${active_go}" + go version + goreleaser release --clean --skip=publish + ' - name: Verify release asset checksums working-directory: dist From 9e15876f126f38bda118236048ca6fd4b0e54b5c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 16:23:01 -0700 Subject: [PATCH 20/46] fix(release): lock mise GoReleaser --- .github/workflows/go-pre-publish.yml | 4 ++-- mise.lock | 24 ++++++++++++++++++++++++ mise.toml | 1 + 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml index 10cddc7..8c2892d 100644 --- a/.github/workflows/go-pre-publish.yml +++ b/.github/workflows/go-pre-publish.yml @@ -49,7 +49,7 @@ jobs: uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 with: version: 2026.8.8 - install_args: go + install_args: 'go aqua:goreleaser/goreleaser' cache: true add_shims_to_path: false export_path: false @@ -67,7 +67,7 @@ jobs: - name: Build and sign release assets shell: bash run: | - mise x aqua:goreleaser/goreleaser@2.17.1 -- bash -euo pipefail -c ' + mise exec -- bash -euo pipefail -c ' mise_go=$(mise which go) active_go=$(command -v go) test "$(realpath "${active_go}")" = "$(realpath "${mise_go}")" diff --git a/mise.lock b/mise.lock index 9ed9aa1..c22d04d 100644 --- a/mise.lock +++ b/mise.lock @@ -88,6 +88,30 @@ checksum = "sha256:f6f06d94b6241521c53d15450c5209b028270bf966f842afb11c030c79f5b url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-darwin-amd64.tar.gz" provenance = "github-attestations" +[[tools."aqua:goreleaser/goreleaser"]] +version = "2.17.1" +backend = "aqua:goreleaser/goreleaser" + +[tools."aqua:goreleaser/goreleaser"."platforms.linux-arm64"] +checksum = "sha256:702f03769ac8bcb0e47839c82243cc614ae995633599a98c63062e13ea85f829" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.17.1/goreleaser_Linux_arm64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:goreleaser/goreleaser"."platforms.linux-x64"] +checksum = "sha256:a99bbc7ae0d8d897b07c4c497a9b62f222558804715ef219d1af05a7e417bc80" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.17.1/goreleaser_Linux_x86_64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:goreleaser/goreleaser"."platforms.macos-arm64"] +checksum = "sha256:f49d4fe67d283b5b5130c380c983087dca0a4d4ec15b6637b18fe1ab096780d8" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.17.1/goreleaser_Darwin_all.tar.gz" +provenance = "github-attestations" + +[tools."aqua:goreleaser/goreleaser"."platforms.macos-x64"] +checksum = "sha256:f49d4fe67d283b5b5130c380c983087dca0a4d4ec15b6637b18fe1ab096780d8" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.17.1/goreleaser_Darwin_all.tar.gz" +provenance = "github-attestations" + [[tools."aqua:sigstore/cosign"]] version = "3.1.3" backend = "aqua:sigstore/cosign" diff --git a/mise.toml b/mise.toml index 23bfd0f..463f0c1 100644 --- a/mise.toml +++ b/mise.toml @@ -2,6 +2,7 @@ go = "1.26.6" python = "3.14.7" "aqua:golangci/golangci-lint" = "2.12.2" +"aqua:goreleaser/goreleaser" = "2.17.1" "aqua:astral-sh/uv" = "0.12.5" "http:moon" = { version = "2.5.1", url = 'https://github.com/moonrepo/moon/releases/download/v{{ version }}/moon_cli-{{ arch(x64="x86_64", arm64="aarch64") }}-{{ os(linux="unknown-linux-musl", macos="apple-darwin") }}.tar.xz', checksum_url = 'https://github.com/moonrepo/moon/releases/download/v{{ version }}/moon_cli-{{ arch(x64="x86_64", arm64="aarch64") }}-{{ os(linux="unknown-linux-musl", macos="apple-darwin") }}.tar.xz.sha256' } "aqua:chainguard-dev/melange" = "0.59.1" From 787fc76a3e0fdd1c41a1096166e0d15dcb040996 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 16:26:23 -0700 Subject: [PATCH 21/46] fix(release): limit mise release tools --- .github/workflows/go-pre-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml index 8c2892d..7c1de62 100644 --- a/.github/workflows/go-pre-publish.yml +++ b/.github/workflows/go-pre-publish.yml @@ -29,6 +29,7 @@ jobs: id-token: write env: GOTOOLCHAIN: local + MISE_EXEC_AUTO_INSTALL: 'false' steps: - name: Require a tag ref shell: bash From 07a4c63ec2b0569378111584080f7875ab84d62f Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 16:35:55 -0700 Subject: [PATCH 22/46] refactor(release): unify mise toolchain --- .github/workflows/go-pre-publish.yml | 26 +++++++++++--------------- mise.lock | 20 ++++++++++++++++++++ mise.toml | 1 + 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml index 7c1de62..968f231 100644 --- a/.github/workflows/go-pre-publish.yml +++ b/.github/workflows/go-pre-publish.yml @@ -50,29 +50,25 @@ jobs: uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 with: version: 2026.8.8 - install_args: 'go aqua:goreleaser/goreleaser' + install_args: >- + go + aqua:goreleaser/goreleaser + aqua:anchore/syft + aqua:sigstore/cosign cache: true add_shims_to_path: false export_path: false - - name: Install Syft - uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - with: - syft-version: v1.51.0 - - - name: Install Cosign - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - with: - cosign-release: v3.1.3 - - name: Build and sign release assets shell: bash run: | mise exec -- bash -euo pipefail -c ' - mise_go=$(mise which go) - active_go=$(command -v go) - test "$(realpath "${active_go}")" = "$(realpath "${mise_go}")" - printf "Using %s\n" "${active_go}" + for tool in go goreleaser syft cosign; do + active=$(command -v "${tool}") + managed=$(mise which "${tool}") + test "$(realpath "${active}")" = "$(realpath "${managed}")" + printf "Using %s: %s\n" "${tool}" "${active}" + done go version goreleaser release --clean --skip=publish ' diff --git a/mise.lock b/mise.lock index c22d04d..9cbe452 100644 --- a/mise.lock +++ b/mise.lock @@ -1,5 +1,25 @@ # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html +[[tools."aqua:anchore/syft"]] +version = "1.51.0" +backend = "aqua:anchore/syft" + +[tools."aqua:anchore/syft"."platforms.linux-arm64"] +checksum = "sha256:6c0466811541ea03add5213a60a1562f0851e4c0b0ecfdee1a694a9455285900" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_linux_arm64.tar.gz" + +[tools."aqua:anchore/syft"."platforms.linux-x64"] +checksum = "sha256:2a2e837a2c8d59ec9af5472ee22d3b04ee463c4e44476ecf993fd1e5ab6ebc7f" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_linux_amd64.tar.gz" + +[tools."aqua:anchore/syft"."platforms.macos-arm64"] +checksum = "sha256:4f37f4c7fefce0a68e4cf71ba3f5f9829a99e65d89b29f7ee41b8c2c10ea8c59" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_darwin_arm64.tar.gz" + +[tools."aqua:anchore/syft"."platforms.macos-x64"] +checksum = "sha256:cddf9a044145caf0a1a3194d00d1dd51a1666f4814f2919cdb4768a0c062ad95" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_darwin_amd64.tar.gz" + [[tools."aqua:astral-sh/uv"]] version = "0.12.5" backend = "aqua:astral-sh/uv" diff --git a/mise.toml b/mise.toml index 463f0c1..9c82437 100644 --- a/mise.toml +++ b/mise.toml @@ -3,6 +3,7 @@ go = "1.26.6" python = "3.14.7" "aqua:golangci/golangci-lint" = "2.12.2" "aqua:goreleaser/goreleaser" = "2.17.1" +"aqua:anchore/syft" = "1.51.0" "aqua:astral-sh/uv" = "0.12.5" "http:moon" = { version = "2.5.1", url = 'https://github.com/moonrepo/moon/releases/download/v{{ version }}/moon_cli-{{ arch(x64="x86_64", arm64="aarch64") }}-{{ os(linux="unknown-linux-musl", macos="apple-darwin") }}.tar.xz', checksum_url = 'https://github.com/moonrepo/moon/releases/download/v{{ version }}/moon_cli-{{ arch(x64="x86_64", arm64="aarch64") }}-{{ os(linux="unknown-linux-musl", macos="apple-darwin") }}.tar.xz.sha256' } "aqua:chainguard-dev/melange" = "0.59.1" From 84af48a97da33782461b9f2650f4343f8bf4f611 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 16:49:07 -0700 Subject: [PATCH 23/46] feat(release): publish GitHub release assets --- .../workflows/go-publish-github-release.yml | 266 ++++++++++++++++++ .github/workflows/pre-publish.yml | 21 -- .github/workflows/release.yml | 39 +++ mise.lock | 24 ++ mise.toml | 1 + 5 files changed, 330 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/go-publish-github-release.yml delete mode 100644 .github/workflows/pre-publish.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/go-publish-github-release.yml b/.github/workflows/go-publish-github-release.yml new file mode 100644 index 0000000..b322f40 --- /dev/null +++ b/.github/workflows/go-publish-github-release.yml @@ -0,0 +1,266 @@ +name: Reusable Go GitHub Release Publisher + +on: + workflow_call: + inputs: + artifact-id: + description: ID of the authoritative release-assets artifact. + required: true + type: string + artifact-digest: + description: Expected SHA-256 digest of the release-assets artifact. + required: true + type: string + release-app-client-id: + description: Client ID of the GitHub App that publishes releases. + required: true + type: string + publish-release: + description: Publish the draft after uploading and attesting its assets. + required: false + default: true + type: boolean + secrets: + release-app-private-key: + description: Private key of the GitHub App that publishes releases. + required: true + outputs: + attestation-url: + description: URL of the GitHub build-provenance attestation. + value: ${{ jobs.publish.outputs.attestation-url }} + release-url: + description: URL of the populated GitHub Release. + value: ${{ jobs.publish.outputs.release-url }} + +permissions: {} + +jobs: + publish: + name: Publish GitHub Release + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + attestation-url: ${{ steps.attest.outputs.attestation-url }} + release-url: ${{ steps.release.outputs.url }} + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + env: + MISE_EXEC_AUTO_INSTALL: 'false' + steps: + - name: Require a tag ref + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" != 'tag' ]]; then + echo '::error::GitHub Release publication must run against a tag ref.' + exit 1 + fi + + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + filter: 'blob:none' + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + install_args: 'aqua:cli/cli aqua:sigstore/cosign' + cache: true + add_shims_to_path: false + export_path: false + + - name: Verify publication tools + shell: bash + run: | + mise exec -- bash -euo pipefail -c ' + for tool in gh cosign; do + active=$(command -v "${tool}") + managed=$(mise which "${tool}") + test "$(realpath "${active}")" = "$(realpath "${managed}")" + printf "Using %s: %s\n" "${tool}" "${active}" + done + gh --version + cosign version + ' + + - name: Create release app token + id: release-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ inputs.release-app-client-id }} + private-key: ${{ secrets.release-app-private-key }} + permission-contents: write + + - name: Verify artifact handoff + env: + ARTIFACT_ID: ${{ inputs.artifact-id }} + EXPECTED_DIGEST: ${{ inputs.artifact-digest }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + actual_digest=$(mise exec -- gh api \ + "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}" \ + --jq '.digest') + if [[ "${actual_digest}" != "${EXPECTED_DIGEST}" ]]; then + echo "::error::Artifact digest mismatch: expected ${EXPECTED_DIGEST}, got ${actual_digest}." + exit 1 + fi + + - name: Wait for matching draft release + id: draft + env: + GH_TOKEN: ${{ steps.release-app.outputs.token }} + shell: bash + run: | + release='' + for attempt in {1..24}; do + if release=$(mise exec -- gh api \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" 2>/dev/null); then + break + fi + printf 'Draft release not available; retrying (%s/24).\n' "${attempt}" + sleep 5 + done + + if [[ -z "${release}" ]]; then + echo "::error::No GitHub Release found for ${GITHUB_REF_NAME}." + exit 1 + fi + + if [[ "$(jq -r '.draft' <<<"${release}")" != 'true' ]]; then + echo "::error::Release ${GITHUB_REF_NAME} is not a draft." + exit 1 + fi + if [[ "$(jq -r '.tag_name' <<<"${release}")" != "${GITHUB_REF_NAME}" ]]; then + echo "::error::Draft release tag does not match ${GITHUB_REF_NAME}." + exit 1 + fi + if [[ "$(jq '.assets | length' <<<"${release}")" != '0' ]]; then + echo "::error::Draft release already contains assets; refusing to replace them." + exit 1 + fi + + tag_commit=$(git rev-list -n 1 "${GITHUB_REF_NAME}") + if [[ "${tag_commit}" != "${GITHUB_SHA}" ]]; then + echo "::error::Tag resolves to ${tag_commit}, expected ${GITHUB_SHA}." + exit 1 + fi + + printf 'id=%s\n' "$(jq -r '.id' <<<"${release}")" >>"${GITHUB_OUTPUT}" + + - name: Download authoritative release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: dist + digest-mismatch: error + + - name: Verify authoritative release assets + env: + CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/go-pre-publish.yml@${{ github.ref }} + shell: bash + run: | + mise exec -- bash -euo pipefail -c ' + cd dist + sha256sum --check checksums.txt + cosign verify-blob \ + --bundle checksums.txt.sigstore.json \ + --certificate-identity "${CERTIFICATE_IDENTITY}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums.txt + ' + + - name: Attest release assets + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-checksums: dist/checksums.txt + + - name: Upload release assets + env: + GH_TOKEN: ${{ steps.release-app.outputs.token }} + shell: bash + run: | + shopt -s nullglob + assets=( + dist/*.tar.gz + dist/*.zip + dist/*.sbom.json + dist/checksums.txt + dist/checksums.txt.sigstore.json + ) + if (( ${#assets[@]} == 0 )); then + echo '::error::No release assets found.' + exit 1 + fi + mise exec -- gh release upload "${GITHUB_REF_NAME}" "${assets[@]}" \ + --repo "${GITHUB_REPOSITORY}" + + - name: Verify uploaded release assets + env: + GH_TOKEN: ${{ steps.release-app.outputs.token }} + RELEASE_ID: ${{ steps.draft.outputs.id }} + shell: bash + run: | + release_assets=$(mise exec -- gh api \ + "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100") + + expected_count=2 + while read -r checksum name; do + expected_count=$((expected_count + 1)) + actual=$(jq -r --arg name "${name}" \ + '.[] | select(.name == $name) | .digest' <<<"${release_assets}") + if [[ "${actual}" != "sha256:${checksum}" ]]; then + echo "::error::Release asset ${name} has digest ${actual}, expected sha256:${checksum}." + exit 1 + fi + done >"${GITHUB_OUTPUT}" diff --git a/.github/workflows/pre-publish.yml b/.github/workflows/pre-publish.yml deleted file mode 100644 index 65b94b3..0000000 --- a/.github/workflows/pre-publish.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Pre-publish - -on: - push: - tags: - - 'v*' - -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - release-assets: - name: Build release assets - if: github.event.deleted == false - permissions: - contents: read - id-token: write - uses: ./.github/workflows/go-pre-publish.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ebb1322 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-assets: + name: Build release assets + if: github.event.deleted == false + permissions: + contents: read + id-token: write + uses: ./.github/workflows/go-pre-publish.yml + + github-release: + name: Publish GitHub Release + needs: release-assets + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + uses: ./.github/workflows/go-publish-github-release.yml + with: + artifact-id: ${{ needs.release-assets.outputs.artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-release: false + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} diff --git a/mise.lock b/mise.lock index 9cbe452..f8c0a67 100644 --- a/mise.lock +++ b/mise.lock @@ -84,6 +84,30 @@ url = "https://github.com/chainguard-dev/melange/releases/download/v0.59.1/melan checksum = "sha256:0a1080caa973c9a10b9e331542e927531710810647c4f3b9ec9e53bf22f2e960" url = "https://github.com/chainguard-dev/melange/releases/download/v0.59.1/melange_0.59.1_darwin_amd64.tar.gz" +[[tools."aqua:cli/cli"]] +version = "2.97.0" +backend = "aqua:cli/cli" + +[tools."aqua:cli/cli"."platforms.linux-arm64"] +checksum = "sha256:73ea440ecad9c9e284429997ee6f93577bc6f7bc6fba357ef62c53ad8fb641a5" +url = "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_linux_arm64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:cli/cli"."platforms.linux-x64"] +checksum = "sha256:a2c9b8497e1f85b1ad0dfcb78b5a622e098801b8e461e459e88e1ee12f018112" +url = "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_linux_amd64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:cli/cli"."platforms.macos-arm64"] +checksum = "sha256:a58b8fd77b417a38f47a0b54d1370c59b0fcdb324ccc9ca002b0998f7c4c999e" +url = "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_macOS_arm64.zip" +provenance = "github-attestations" + +[tools."aqua:cli/cli"."platforms.macos-x64"] +checksum = "sha256:63298c998cc2a924c9e254c6af6a1caad6ece281122687a91f079bc0a462700e" +url = "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_macOS_amd64.zip" +provenance = "github-attestations" + [[tools."aqua:golangci/golangci-lint"]] version = "2.12.2" backend = "aqua:golangci/golangci-lint" diff --git a/mise.toml b/mise.toml index 9c82437..677b4be 100644 --- a/mise.toml +++ b/mise.toml @@ -3,6 +3,7 @@ go = "1.26.6" python = "3.14.7" "aqua:golangci/golangci-lint" = "2.12.2" "aqua:goreleaser/goreleaser" = "2.17.1" +"aqua:cli/cli" = "2.97.0" "aqua:anchore/syft" = "1.51.0" "aqua:astral-sh/uv" = "0.12.5" "http:moon" = { version = "2.5.1", url = 'https://github.com/moonrepo/moon/releases/download/v{{ version }}/moon_cli-{{ arch(x64="x86_64", arm64="aarch64") }}-{{ os(linux="unknown-linux-musl", macos="apple-darwin") }}.tar.xz', checksum_url = 'https://github.com/moonrepo/moon/releases/download/v{{ version }}/moon_cli-{{ arch(x64="x86_64", arm64="aarch64") }}-{{ os(linux="unknown-linux-musl", macos="apple-darwin") }}.tar.xz.sha256' } From 3f3d17d79cdae4357069631c1cf6039e7ebca3d0 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 16:52:12 -0700 Subject: [PATCH 24/46] fix(release): normalize artifact digest --- .github/workflows/go-publish-github-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go-publish-github-release.yml b/.github/workflows/go-publish-github-release.yml index b322f40..a0396e5 100644 --- a/.github/workflows/go-publish-github-release.yml +++ b/.github/workflows/go-publish-github-release.yml @@ -107,7 +107,7 @@ jobs: actual_digest=$(mise exec -- gh api \ "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}" \ --jq '.digest') - if [[ "${actual_digest}" != "${EXPECTED_DIGEST}" ]]; then + if [[ "${actual_digest#sha256:}" != "${EXPECTED_DIGEST#sha256:}" ]]; then echo "::error::Artifact digest mismatch: expected ${EXPECTED_DIGEST}, got ${actual_digest}." exit 1 fi From bec93e2e2bdc61fa3898c0042a7d60a5bf713838 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 16:58:14 -0700 Subject: [PATCH 25/46] fix(release): discover draft by release list --- .github/workflows/go-publish-github-release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go-publish-github-release.yml b/.github/workflows/go-publish-github-release.yml index a0396e5..d1330bd 100644 --- a/.github/workflows/go-publish-github-release.yml +++ b/.github/workflows/go-publish-github-release.yml @@ -120,8 +120,12 @@ jobs: run: | release='' for attempt in {1..24}; do - if release=$(mise exec -- gh api \ - "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" 2>/dev/null); then + if release=$( + mise exec -- gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/releases?per_page=100" 2>/dev/null | + jq -ce --arg tag "${GITHUB_REF_NAME}" \ + '[.[][] | select(.tag_name == $tag)] | first // empty' + ); then break fi printf 'Draft release not available; retrying (%s/24).\n' "${attempt}" From 8911ad105e3a11860d9eabffa81e3e597784e6ec Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 17:02:13 -0700 Subject: [PATCH 26/46] feat(release): publish completed GitHub releases --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebb1322..77bbcc1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,6 @@ jobs: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} - publish-release: false + publish-release: true secrets: release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} From 799e83029b38e1f7efe87c6eb1114e0647eba5af Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 17:20:40 -0700 Subject: [PATCH 27/46] refactor(release): generalize GitHub publisher --- ...release.yml => publish-github-release.yml} | 78 +++++++++++++++---- .github/workflows/release.yml | 3 +- 2 files changed, 65 insertions(+), 16 deletions(-) rename .github/workflows/{go-publish-github-release.yml => publish-github-release.yml} (77%) diff --git a/.github/workflows/go-publish-github-release.yml b/.github/workflows/publish-github-release.yml similarity index 77% rename from .github/workflows/go-publish-github-release.yml rename to .github/workflows/publish-github-release.yml index d1330bd..9d170dd 100644 --- a/.github/workflows/go-publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -1,4 +1,4 @@ -name: Reusable Go GitHub Release Publisher +name: Reusable GitHub Release Publisher on: workflow_call: @@ -11,6 +11,10 @@ on: description: Expected SHA-256 digest of the release-assets artifact. required: true type: string + checksum-signing-workflow: + description: Repository-relative workflow path that signed checksums.txt. + required: true + type: string release-app-client-id: description: Client ID of the GitHub App that publishes releases. required: true @@ -165,14 +169,60 @@ jobs: path: dist digest-mismatch: error - - name: Verify authoritative release assets + - name: Verify authoritative release bundle env: - CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/go-pre-publish.yml@${{ github.ref }} + ASSET_LIST: ${{ runner.temp }}/release-assets.txt + CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/${{ inputs.checksum-signing-workflow }}@${{ github.ref }} shell: bash run: | mise exec -- bash -euo pipefail -c ' cd dist + : >"${ASSET_LIST}" + declare -A seen=() + while read -r checksum name; do + name="${name#\*}" + if [[ ! "${checksum}" =~ ^[[:xdigit:]]{64}$ || + ! "${name}" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then + echo "::error::Invalid checksums.txt entry: ${checksum} ${name}." + exit 1 + fi + if [[ "${name}" == checksums.txt || + "${name}" == checksums.txt.sigstore.json ]]; then + echo "::error::Control file ${name} must not be listed in checksums.txt." + exit 1 + fi + if [[ -n "${seen[${name}]:-}" ]]; then + echo "::error::Duplicate checksums.txt entry: ${name}." + exit 1 + fi + if [[ ! -f "${name}" || -L "${name}" ]]; then + echo "::error::Release payload ${name} is not a regular file." + exit 1 + fi + seen["${name}"]=1 + printf "dist/%s\n" "${name}" >>"${ASSET_LIST}" + done Date: Mon, 17 Aug 2026 17:39:44 -0700 Subject: [PATCH 28/46] refactor(release): replace publisher Bash with JavaScript --- .github/workflows/publish-github-release.yml | 477 +++++++++++-------- 1 file changed, 275 insertions(+), 202 deletions(-) diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 9d170dd..d290a6d 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -56,12 +56,12 @@ jobs: MISE_EXEC_AUTO_INSTALL: 'false' steps: - name: Require a tag ref - shell: bash - run: | - if [[ "${GITHUB_REF_TYPE}" != 'tag' ]]; then - echo '::error::GitHub Release publication must run against a tag ref.' - exit 1 - fi + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + if (process.env.GITHUB_REF_TYPE !== 'tag') { + throw new Error('GitHub Release publication must run against a tag ref.') + } - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -80,18 +80,11 @@ jobs: export_path: false - name: Verify publication tools - shell: bash - run: | - mise exec -- bash -euo pipefail -c ' - for tool in gh cosign; do - active=$(command -v "${tool}") - managed=$(mise which "${tool}") - test "$(realpath "${active}")" = "$(realpath "${managed}")" - printf "Using %s: %s\n" "${tool}" "${active}" - done - gh --version - cosign version - ' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + await exec.exec('mise', ['exec', '--', 'gh', '--version']) + await exec.exec('mise', ['exec', '--', 'cosign', 'version']) - name: Create release app token id: release-app @@ -102,65 +95,84 @@ jobs: permission-contents: write - name: Verify artifact handoff + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: ARTIFACT_ID: ${{ inputs.artifact-id }} EXPECTED_DIGEST: ${{ inputs.artifact-digest }} - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - actual_digest=$(mise exec -- gh api \ - "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}" \ - --jq '.digest') - if [[ "${actual_digest#sha256:}" != "${EXPECTED_DIGEST#sha256:}" ]]; then - echo "::error::Artifact digest mismatch: expected ${EXPECTED_DIGEST}, got ${actual_digest}." - exit 1 - fi + with: + retries: 3 + script: | + const artifactId = Number(process.env.ARTIFACT_ID) + if (!Number.isSafeInteger(artifactId) || artifactId <= 0) { + throw new Error(`Invalid artifact ID: ${process.env.ARTIFACT_ID}`) + } + + const {data: artifact} = await github.rest.actions.getArtifact({ + ...context.repo, + artifact_id: artifactId, + }) + const normalize = (digest) => String(digest ?? '') + .replace(/^sha256:/, '') + .toLowerCase() + const expected = normalize(process.env.EXPECTED_DIGEST) + const actual = normalize(artifact.digest) + if (!expected || actual !== expected) { + throw new Error( + `Artifact digest mismatch: expected ${process.env.EXPECTED_DIGEST}, got ${artifact.digest}.`, + ) + } - name: Wait for matching draft release id: draft + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_TOKEN: ${{ steps.release-app.outputs.token }} - shell: bash - run: | - release='' - for attempt in {1..24}; do - if release=$( - mise exec -- gh api --paginate --slurp \ - "repos/${GITHUB_REPOSITORY}/releases?per_page=100" 2>/dev/null | - jq -ce --arg tag "${GITHUB_REF_NAME}" \ - '[.[][] | select(.tag_name == $tag)] | first // empty' - ); then - break - fi - printf 'Draft release not available; retrying (%s/24).\n' "${attempt}" - sleep 5 - done - - if [[ -z "${release}" ]]; then - echo "::error::No GitHub Release found for ${GITHUB_REF_NAME}." - exit 1 - fi - - if [[ "$(jq -r '.draft' <<<"${release}")" != 'true' ]]; then - echo "::error::Release ${GITHUB_REF_NAME} is not a draft." - exit 1 - fi - if [[ "$(jq -r '.tag_name' <<<"${release}")" != "${GITHUB_REF_NAME}" ]]; then - echo "::error::Draft release tag does not match ${GITHUB_REF_NAME}." - exit 1 - fi - if [[ "$(jq '.assets | length' <<<"${release}")" != '0' ]]; then - echo "::error::Draft release already contains assets; refusing to replace them." - exit 1 - fi - - tag_commit=$(git rev-list -n 1 "${GITHUB_REF_NAME}") - if [[ "${tag_commit}" != "${GITHUB_SHA}" ]]; then - echo "::error::Tag resolves to ${tag_commit}, expected ${GITHUB_SHA}." - exit 1 - fi - - printf 'id=%s\n' "$(jq -r '.id' <<<"${release}")" >>"${GITHUB_OUTPUT}" + EXPECTED_SHA: ${{ github.sha }} + RELEASE_APP_TOKEN: ${{ steps.release-app.outputs.token }} + TAG: ${{ github.ref_name }} + with: + retries: 3 + script: | + const releaseGitHub = getOctokit(process.env.RELEASE_APP_TOKEN) + const sleep = (milliseconds) => new Promise((resolve) => { + setTimeout(resolve, milliseconds) + }) + + let release + for (let attempt = 1; attempt <= 24; attempt += 1) { + const releases = await releaseGitHub.paginate( + releaseGitHub.rest.repos.listReleases, + {...context.repo, per_page: 100}, + ) + release = releases.find(({tag_name: tag}) => tag === process.env.TAG) + if (release) break + + core.info(`Draft release not available; retrying (${attempt}/24).`) + await sleep(5000) + } + + if (!release) { + throw new Error(`No GitHub Release found for ${process.env.TAG}.`) + } + if (!release.draft) { + throw new Error(`Release ${process.env.TAG} is not a draft.`) + } + if (release.assets.length !== 0) { + throw new Error('Draft release already contains assets; refusing to replace them.') + } + + const tag = await exec.getExecOutput( + 'git', + ['rev-list', '-n', '1', process.env.TAG], + {silent: true}, + ) + const tagCommit = tag.stdout.trim() + if (tagCommit !== process.env.EXPECTED_SHA) { + throw new Error( + `Tag resolves to ${tagCommit}, expected ${process.env.EXPECTED_SHA}.`, + ) + } + + core.setOutput('id', String(release.id)) - name: Download authoritative release assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -170,65 +182,117 @@ jobs: digest-mismatch: error - name: Verify authoritative release bundle + id: bundle + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - ASSET_LIST: ${{ runner.temp }}/release-assets.txt CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/${{ inputs.checksum-signing-workflow }}@${{ github.ref }} - shell: bash - run: | - mise exec -- bash -euo pipefail -c ' - cd dist - : >"${ASSET_LIST}" - declare -A seen=() - while read -r checksum name; do - name="${name#\*}" - if [[ ! "${checksum}" =~ ^[[:xdigit:]]{64}$ || - ! "${name}" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then - echo "::error::Invalid checksums.txt entry: ${checksum} ${name}." - exit 1 - fi - if [[ "${name}" == checksums.txt || - "${name}" == checksums.txt.sigstore.json ]]; then - echo "::error::Control file ${name} must not be listed in checksums.txt." - exit 1 - fi - if [[ -n "${seen[${name}]:-}" ]]; then - echo "::error::Duplicate checksums.txt entry: ${name}." - exit 1 - fi - if [[ ! -f "${name}" || -L "${name}" ]]; then - echo "::error::Release payload ${name} is not a regular file." - exit 1 - fi - seen["${name}"]=1 - printf "dist/%s\n" "${name}" >>"${ASSET_LIST}" - done { + let stat + try { + stat = fs.lstatSync(file) + } catch { + throw new Error(`${label} does not exist.`) + } + if (!stat.isFile()) { + throw new Error(`${label} is not a regular file.`) + } + } + + const sha256 = async (file) => { + const hash = crypto.createHash('sha256') + for await (const chunk of fs.createReadStream(file)) { + hash.update(chunk) + } + return hash.digest('hex') + } + + requireRegularFile(checksumPath, checksumName) + requireRegularFile(bundlePath, bundleName) + + const manifest = fs.readFileSync(checksumPath, 'utf8') + const lines = manifest.split(/\r?\n/) + if (lines.at(-1) === '') lines.pop() + if (lines.length === 0) { + throw new Error('checksums.txt does not list any release payloads.') + } + + const seen = new Set() + const entries = [] + for (const [index, line] of lines.entries()) { + const match = line.match( + /^([0-9A-Fa-f]{64}) [ *]([A-Za-z0-9][A-Za-z0-9._+-]*)$/, + ) + if (!match) { + throw new Error(`Invalid checksums.txt entry on line ${index + 1}.`) + } + + const [, rawDigest, name] = match + if (controls.has(name)) { + throw new Error(`Control file ${name} must not be listed in checksums.txt.`) + } + if (seen.has(name)) { + throw new Error(`Duplicate checksums.txt entry: ${name}.`) + } + + const file = path.join(dist, name) + requireRegularFile(file, `Release payload ${name}`) + const digest = rawDigest.toLowerCase() + const actual = await sha256(file) + if (actual !== digest) { + throw new Error( + `Release payload ${name} has digest ${actual}, expected ${digest}.`, + ) + } + + seen.add(name) + entries.push({name, file, digest}) + } + + const allowed = new Set([...seen, ...controls]) + for (const entry of fs.readdirSync(dist, {withFileTypes: true})) { + if (!allowed.has(entry.name) || !entry.isFile()) { + throw new Error(`Unlisted or invalid release bundle entry: ${entry.name}.`) + } + } + + await exec.exec('mise', [ + 'exec', + '--', + 'cosign', + 'verify-blob', + '--bundle', + bundlePath, + '--certificate-identity', + process.env.CERTIFICATE_IDENTITY, + '--certificate-oidc-issuer', + 'https://token.actions.githubusercontent.com', + checksumPath, + ], {cwd: workspace}) + + const assets = entries.map(({file}) => file) + assets.push(checksumPath, bundlePath) + const digests = Object.fromEntries( + entries.map(({name, digest}) => [name, digest]), + ) + digests[checksumName] = await sha256(checksumPath) + digests[bundleName] = await sha256(bundlePath) + + core.setOutput('assets', JSON.stringify(assets)) + core.setOutput('digests', JSON.stringify(digests)) - name: Attest release assets id: attest @@ -237,82 +301,91 @@ jobs: subject-checksums: dist/checksums.txt - name: Upload release assets + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - ASSET_LIST: ${{ runner.temp }}/release-assets.txt - GH_TOKEN: ${{ steps.release-app.outputs.token }} - shell: bash - run: | - mapfile -t assets <"${ASSET_LIST}" - assets+=( - dist/checksums.txt - dist/checksums.txt.sigstore.json - ) - mise exec -- gh release upload "${GITHUB_REF_NAME}" "${assets[@]}" \ - --repo "${GITHUB_REPOSITORY}" - - - name: Verify uploaded release assets - env: - GH_TOKEN: ${{ steps.release-app.outputs.token }} - RELEASE_ID: ${{ steps.draft.outputs.id }} - shell: bash - run: | - release_assets=$( - mise exec -- gh api --paginate --slurp \ - "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" | - jq -c 'add' - ) - - expected_count=2 - while read -r checksum name; do - name="${name#\*}" - expected_count=$((expected_count + 1)) - actual=$(jq -r --arg name "${name}" \ - '.[] | select(.name == $name) | .digest' <<<"${release_assets}") - if [[ "${actual}" != "sha256:${checksum,,}" ]]; then - echo "::error::Release asset ${name} has digest ${actual}, expected sha256:${checksum}." - exit 1 - fi - done >"${GITHUB_OUTPUT}" + RELEASE_APP_TOKEN: ${{ steps.release-app.outputs.token }} + RELEASE_ID: ${{ steps.draft.outputs.id }} + with: + retries: 3 + script: | + const releaseGitHub = getOctokit(process.env.RELEASE_APP_TOKEN) + const releaseId = Number(process.env.RELEASE_ID) + if (!Number.isSafeInteger(releaseId) || releaseId <= 0) { + throw new Error(`Invalid release ID: ${process.env.RELEASE_ID}`) + } + + const expected = JSON.parse(process.env.EXPECTED_DIGESTS) + const assets = await releaseGitHub.paginate( + releaseGitHub.rest.repos.listReleaseAssets, + {...context.repo, release_id: releaseId, per_page: 100}, + ) + const actual = new Map(assets.map((asset) => [asset.name, asset.digest])) + if (actual.size !== assets.length) { + throw new Error('GitHub Release contains duplicate asset names.') + } + + const expectedNames = Object.keys(expected) + if (assets.length !== expectedNames.length) { + throw new Error( + `Release contains ${assets.length} assets; expected ${expectedNames.length}.`, + ) + } + for (const name of expectedNames) { + const expectedDigest = `sha256:${expected[name]}` + const actualDigest = actual.get(name) + if (actualDigest !== expectedDigest) { + throw new Error( + `Release asset ${name} has digest ${actualDigest}, expected ${expectedDigest}.`, + ) + } + } + + const publish = process.env.PUBLISH_RELEASE === 'true' + if (publish) { + await releaseGitHub.rest.repos.updateRelease({ + ...context.repo, + release_id: releaseId, + draft: false, + }) + } + + const {data: release} = await releaseGitHub.rest.repos.getRelease({ + ...context.repo, + release_id: releaseId, + }) + if (release.draft === publish) { + throw new Error('GitHub Release ended in the wrong draft state.') + } + core.setOutput('url', release.html_url) From e9fd9f876f4948eba0746cd0e44e1ec3e3f980a0 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 18:17:47 -0700 Subject: [PATCH 29/46] fix(release): make publication resumable --- .github/workflows/publish-github-release.yml | 75 +++++++++++++++++--- .github/workflows/release.yml | 2 +- .goreleaser.yaml | 9 +-- 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index d290a6d..403ae12 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -111,6 +111,15 @@ jobs: ...context.repo, artifact_id: artifactId, }) + if (artifact.expired) { + throw new Error(`Artifact ${artifactId} has expired.`) + } + if (artifact.workflow_run?.id !== Number(context.runId)) { + throw new Error( + `Artifact ${artifactId} belongs to workflow run ` + + `${artifact.workflow_run?.id}, expected ${context.runId}.`, + ) + } const normalize = (digest) => String(digest ?? '') .replace(/^sha256:/, '') .toLowerCase() @@ -156,9 +165,6 @@ jobs: if (!release.draft) { throw new Error(`Release ${process.env.TAG} is not a draft.`) } - if (release.assets.length !== 0) { - throw new Error('Draft release already contains assets; refusing to replace them.') - } const tag = await exec.getExecOutput( 'git', @@ -304,15 +310,51 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: ASSETS: ${{ steps.bundle.outputs.assets }} + EXPECTED_DIGESTS: ${{ steps.bundle.outputs.digests }} RELEASE_APP_TOKEN: ${{ steps.release-app.outputs.token }} + RELEASE_ID: ${{ steps.draft.outputs.id }} REPOSITORY: ${{ github.repository }} TAG: ${{ github.ref_name }} with: + retries: 3 script: | const assets = JSON.parse(process.env.ASSETS) + const expected = JSON.parse(process.env.EXPECTED_DIGESTS) + const releaseId = Number(process.env.RELEASE_ID) if (!Array.isArray(assets) || assets.length === 0) { throw new Error('No release assets found.') } + if (!Number.isSafeInteger(releaseId) || releaseId <= 0) { + throw new Error(`Invalid release ID: ${process.env.RELEASE_ID}`) + } + + const releaseGitHub = getOctokit(process.env.RELEASE_APP_TOKEN) + const releases = await releaseGitHub.paginate( + releaseGitHub.rest.repos.listReleases, + {...context.repo, per_page: 100}, + ) + const matches = releases.filter( + ({tag_name: tag}) => tag === process.env.TAG, + ) + if (matches.length !== 1 || matches[0].id !== releaseId) { + throw new Error( + `Tag ${process.env.TAG} does not uniquely resolve to release ${releaseId}.`, + ) + } + + const expectedNames = new Set(Object.keys(expected)) + const existing = await releaseGitHub.paginate( + releaseGitHub.rest.repos.listReleaseAssets, + {...context.repo, release_id: releaseId, per_page: 100}, + ) + const unexpected = existing + .filter(({name}) => !expectedNames.has(name)) + .map(({name}) => name) + if (unexpected.length !== 0) { + throw new Error( + `Draft release contains unexpected assets: ${unexpected.join(', ')}.`, + ) + } await exec.exec('mise', [ 'exec', @@ -324,6 +366,7 @@ jobs: ...assets, '--repo', process.env.REPOSITORY, + '--clobber', ], { cwd: process.env.GITHUB_WORKSPACE, env: {...process.env, GH_TOKEN: process.env.RELEASE_APP_TOKEN}, @@ -347,16 +390,32 @@ jobs: } const expected = JSON.parse(process.env.EXPECTED_DIGESTS) - const assets = await releaseGitHub.paginate( - releaseGitHub.rest.repos.listReleaseAssets, - {...context.repo, release_id: releaseId, per_page: 100}, - ) + const expectedNames = Object.keys(expected) + const sleep = (milliseconds) => new Promise((resolve) => { + setTimeout(resolve, milliseconds) + }) + let assets + for (let attempt = 1; attempt <= 12; attempt += 1) { + assets = await releaseGitHub.paginate( + releaseGitHub.rest.repos.listReleaseAssets, + {...context.repo, release_id: releaseId, per_page: 100}, + ) + const ready = assets.length === expectedNames.length && + assets.every(({digest, state}) => digest && state === 'uploaded') + if (ready) break + + core.info(`Release assets not ready; retrying (${attempt}/12).`) + await sleep(1000) + } + if (assets.some(({digest, state}) => !digest || state !== 'uploaded')) { + throw new Error('GitHub Release assets did not reach the uploaded state.') + } + const actual = new Map(assets.map((asset) => [asset.name, asset.digest])) if (actual.size !== assets.length) { throw new Error('GitHub Release contains duplicate asset names.') } - const expectedNames = Object.keys(expected) if (assets.length !== expectedNames.length) { throw new Error( `Release contains ${assets.length} assets; expected ${expectedNames.length}.`, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a9ce18..a2cec86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,6 @@ jobs: artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} checksum-signing-workflow: .github/workflows/go-pre-publish.yml release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} - publish-release: true + publish-release: false secrets: release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9f08e4c..2c9023e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,10 +3,6 @@ version: 2 project_name: release-mvp -before: - hooks: - - go test ./... - gomod: proxy: true env: @@ -70,7 +66,4 @@ changelog: disable: true release: - draft: true - prerelease: auto - mode: keep-existing - use_existing_draft: true + disable: true From cd9553a7a76820950bd4ded3cfb38b2022045fbb Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 18:20:18 -0700 Subject: [PATCH 30/46] fix(release): restore final publication --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2cec86..0a9ce18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,6 @@ jobs: artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} checksum-signing-workflow: .github/workflows/go-pre-publish.yml release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} - publish-release: false + publish-release: true secrets: release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} From 5be87cc60f2f11ac11fe401d8129c7644edc17ca Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 18:54:13 -0700 Subject: [PATCH 31/46] feat(release): accept external signer workflow refs --- .github/workflows/publish-github-release.yml | 6 +++--- .github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 403ae12..e4ef67d 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -11,8 +11,8 @@ on: description: Expected SHA-256 digest of the release-assets artifact. required: true type: string - checksum-signing-workflow: - description: Repository-relative workflow path that signed checksums.txt. + checksum-signing-workflow-ref: + description: Exact workflow ref expected in the checksum signing certificate identity. required: true type: string release-app-client-id: @@ -191,7 +191,7 @@ jobs: id: bundle uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/${{ inputs.checksum-signing-workflow }}@${{ github.ref }} + CERTIFICATE_IDENTITY: https://github.com/${{ inputs.checksum-signing-workflow-ref }} with: script: | const crypto = require('node:crypto') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a9ce18..1d63dd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow: .github/workflows/go-pre-publish.yml + checksum-signing-workflow-ref: ${{ github.repository }}/.github/workflows/go-pre-publish.yml@${{ github.ref }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} publish-release: true secrets: From 9c13e9bc29e9f15b15ef2504dc437fa80f072859 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 19:51:28 -0700 Subject: [PATCH 32/46] docs(release): document consumer adoption --- README.md | 15 + docs/how-to/configure-github-releases.md | 249 ++++++++++++++ .../rehearse-and-recover-github-releases.md | 325 ++++++++++++++++++ .../upgrade-github-release-workflows.md | 249 ++++++++++++++ docs/reference/github-release-contract.md | 314 +++++++++++++++++ .../.github/workflows/release-please.yml | 39 +++ .../go-release/.github/workflows/release.yml | 40 +++ examples/go-release/.goreleaser.yaml | 69 ++++ .../go-release/.release-please-manifest.json | 3 + examples/go-release/README.md | 47 +++ examples/go-release/cmd/example/main.go | 29 ++ examples/go-release/go.mod | 3 + examples/go-release/mise.lock | 113 ++++++ examples/go-release/mise.toml | 13 + .../go-release/release-please-config.json | 25 ++ 15 files changed, 1533 insertions(+) create mode 100644 README.md create mode 100644 docs/how-to/configure-github-releases.md create mode 100644 docs/how-to/rehearse-and-recover-github-releases.md create mode 100644 docs/how-to/upgrade-github-release-workflows.md create mode 100644 docs/reference/github-release-contract.md create mode 100644 examples/go-release/.github/workflows/release-please.yml create mode 100644 examples/go-release/.github/workflows/release.yml create mode 100644 examples/go-release/.goreleaser.yaml create mode 100644 examples/go-release/.release-please-manifest.json create mode 100644 examples/go-release/README.md create mode 100644 examples/go-release/cmd/example/main.go create mode 100644 examples/go-release/go.mod create mode 100644 examples/go-release/mise.lock create mode 100644 examples/go-release/mise.toml create mode 100644 examples/go-release/release-please-config.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..79acabd --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Meigma release workflows + +This repository defines the reusable workflows and repository contract that Meigma uses to build, sign, attest, and publish Go binaries through GitHub Releases. + +Current documented automation covers GitHub Releases only. The repository does not yet provide OCI publication, package-manager publication, native package repositories, or installer distribution. + +## Documentation + +- [Configure GitHub releases](docs/how-to/configure-github-releases.md) +- [Rehearse and recover GitHub releases](docs/how-to/rehearse-and-recover-github-releases.md) +- [Upgrade GitHub release workflows](docs/how-to/upgrade-github-release-workflows.md) +- [GitHub release contract reference](docs/reference/github-release-contract.md) +- [Copyable Go release example](examples/go-release/) + +Consumer repositories call the reusable workflows at a full commit SHA. The documented revision is `5be87cc60f2f11ac11fe401d8129c7644edc17ca`. diff --git a/docs/how-to/configure-github-releases.md b/docs/how-to/configure-github-releases.md new file mode 100644 index 0000000..f4ab7bd --- /dev/null +++ b/docs/how-to/configure-github-releases.md @@ -0,0 +1,249 @@ +# Configure GitHub Releases + +Use this guide to add the shared Meigma Go release workflows to a repository. The [GitHub Release contract](../reference/github-release-contract.md) defines the reusable workflow inputs, permissions, artifacts, and failure behavior. + +## Prerequisites + +Before you change the repository, confirm that: + +- the repository contains a Go command and uses `main` as its default branch, or you know which branch value to replace in the example; +- GitHub Actions is enabled and the repository's Actions policy permits calls to `meigma/release` and the pinned actions used by the shared workflows; +- you can create and merge pull requests in the consumer repository; +- an organization owner can manage the `meigma-release` GitHub App installation and organization Actions credentials; +- `mise`, Git, and GitHub CLI are installed locally; and +- GitHub CLI is authenticated for the consumer repository. + +From the consumer repository, record its name and check authentication: + +```bash +gh auth status +export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" +export DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" +printf 'Repository: %s\nDefault branch: %s\n' "$REPOSITORY" "$DEFAULT_BRANCH" +``` + +The example workflows target `main`. If the command prints another default branch, replace `main` in `.github/workflows/release-please.yml` before you merge the configuration. + +## 1. Grant the Release App access + +An organization owner must complete this step before either release workflow runs. + +1. Open the Meigma organization settings in GitHub. +2. Open **Third-party access** > **GitHub Apps** > **Installed GitHub Apps**. +3. Configure the Meigma Release App. +4. Under **Repository access**, keep **Only select repositories** selected and add the consumer repository. +5. Save the installation. + +The installation must show the consumer repository in its selected repository list. Do not change the installation to **All repositories**. + +If a repository or organization ruleset restricts creation of `v*` tags, add the Meigma Release App as a bypass actor for that restriction. Keep the rule enabled for other actors. + +These are organization administration operations. GitHub CLI has no purpose-built command for changing an App installation's selected repositories, and an API request requires installation-management authorization. The commands in this guide therefore do not attempt that change. An organization owner must use the GitHub settings UI or an independently authorized administrative process. + +## 2. Grant the organization credentials + +In the Meigma organization settings, open **Secrets and variables** > **Actions**. + +1. Create or update the organization variable `MEIGMA_RELEASE_APP_CLIENT_ID`. +2. Set its value to the Meigma Release App client ID. +3. Set its repository access to **Selected repositories** and add the consumer repository. +4. Create or update the organization secret `MEIGMA_RELEASE_APP_PRIVATE_KEY` with the App private key. +5. Set the secret's repository access to **Selected repositories** and add the same consumer repository. + +The variable and secret must each show the consumer repository in their selected repository list. GitHub never returns an Actions secret's stored value, so verification is limited to its name, visibility, selected repository access, and a workflow that successfully creates an App token. Do not print the private key or add it as a repository file. + +Organization owners can administer organization variables and secrets through GitHub CLI only when their token has the required organization scopes and role. This guide uses the UI because repository-level authorization alone cannot perform or verify these organization-level writes. + +## 3. Copy the release files + +From a checkout of `meigma/release`, copy the release infrastructure from `examples/go-release/` into the consumer repository. Preserve the relative paths. Do not copy `examples/go-release/README.md`. + +Set `CONSUMER` to the consumer checkout. Before copying, check whether any destination path already exists. If it does, stop and merge the example's release settings into that file; do not overwrite repository configuration. When the destination paths are absent, copy the files: + +```bash +export CONSUMER=/absolute/path/to/consumer +mkdir -p "$CONSUMER/.github/workflows" +cp examples/go-release/.github/workflows/release-please.yml "$CONSUMER/.github/workflows/" +cp examples/go-release/.github/workflows/release.yml "$CONSUMER/.github/workflows/" +cp examples/go-release/.goreleaser.yaml "$CONSUMER/" +cp examples/go-release/.release-please-manifest.json "$CONSUMER/" +cp examples/go-release/release-please-config.json "$CONSUMER/" +cp examples/go-release/mise.toml "$CONSUMER/" +cp examples/go-release/mise.lock "$CONSUMER/" +``` + +The example contains release infrastructure only. It does not define pull request checks, branch protection, code review, or the repository's complete CI policy. + +For a new empty repository that will use the complete minimal command, also copy: + +```bash +mkdir -p "$CONSUMER/cmd/example" +cp examples/go-release/go.mod "$CONSUMER/" +cp examples/go-release/cmd/example/main.go "$CONSUMER/cmd/example/" +``` + +Do not overwrite an existing repository's `go.mod` or command source. Adapt its real command to the copied release configuration instead. + +## 4. Replace project-specific values + +In the copied files, replace the example values with values from the consumer repository: + +- In `.goreleaser.yaml`, replace project name, build ID, archive ID, command path, and binary name `example` with the consumer's values. +- Keep the `main.version` and `main.commit` linker variable names only if the command's `main` package defines both variables and uses them for `--version`. Otherwise, change the ldflags to the consumer command's real linker variables. +- If you copied the sample source, replace module path `example.com/meigma/release-consumer` and the literal command name and output in `cmd/example/main.go`. +- In `release-please-config.json`, replace package name `example` and choose the intended first release in `initial-version`. +- In `.release-please-manifest.json`, keep `0.0.0` only for a repository that has never released. For an existing project, set `.` to its latest released version without the `v` prefix. +- In `.github/workflows/release-please.yml`, replace `main` if the consumer's default branch is different. + +Do not replace these shared contract values: + +- reusable workflow revision `5be87cc60f2f11ac11fe401d8129c7644edc17ca`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca`; +- variable name `MEIGMA_RELEASE_APP_CLIENT_ID`; or +- secret name `MEIGMA_RELEASE_APP_PRIVATE_KEY`. + +To change the immutable revision later, follow [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). Update both reusable workflow references and the checksum signing identity together; do not edit one reference in isolation. + +The copied GoReleaser configuration builds Darwin, Linux, and Windows archives for amd64 and arm64. Confirm that the consumer command supports those targets before releasing it. + +The copied release caller sets `publish-release: false`. Keep that value for a draft rehearsal. Before a public release, change it to `true` and merge the change before Release Please creates the tag. The [rehearsal and recovery guide](rehearse-and-recover-github-releases.md) gives the safer first-run sequence. + +## 5. Generate and validate the tool lock + +Run the following commands from the consumer repository: + +```bash +mise lock --platform linux-x64,linux-arm64,macos-x64,macos-arm64 +mise install --locked +mise exec -- goreleaser check +mise exec -- go list ./cmd/... +``` + +`mise lock` must leave `mise.lock` with entries for the pinned Go, GoReleaser, Syft, Cosign, and GitHub CLI tools. `mise install --locked` must complete without changing a requested version, and `goreleaser check` must accept `.goreleaser.yaml`. Confirm that `go list` includes the command path configured in `.goreleaser.yaml`. + +Commit both `mise.toml` and the generated `mise.lock` with the other release files. Submit the change through the repository's normal pull request review and squash-merge process. + +After the configuration reaches the default branch, confirm that GitHub recognizes both workflows: + +```bash +gh workflow view release-please.yml --repo "$REPOSITORY" +gh workflow view release.yml --repo "$REPOSITORY" +``` + +Each command must print the corresponding workflow instead of reporting that the workflow was not found. + +## 6. Run Release Please + +Before continuing with a public release, confirm that `.github/workflows/release.yml` on the default branch contains `publish-release: true`. Release Please also needs at least one releasable Conventional Commit after the version recorded in `.release-please-manifest.json`. Do not create an empty release commit to satisfy this condition. + +When a releasable change is present, dispatch Release Please and inspect its run: + +```bash +gh workflow run release-please.yml --repo "$REPOSITORY" --ref "$DEFAULT_BRANCH" +gh run list \ + --repo "$REPOSITORY" \ + --workflow release-please.yml \ + --limit 5 +``` + +The successful run creates or updates one Release Please pull request. Find it by its workflow-managed label: + +```bash +export RELEASE_PR="$(gh pr list \ + --repo "$REPOSITORY" \ + --label 'autorelease: pending' \ + --json number \ + --jq '.[0].number')" +test -n "$RELEASE_PR" +gh pr view "$RELEASE_PR" --repo "$REPOSITORY" +``` + +Review the version, changelog, and manifest changes. If they are correct and required checks pass, squash-merge the release pull request: + +```bash +gh pr merge "$RELEASE_PR" \ + --repo "$REPOSITORY" \ + --squash \ + --delete-branch +``` + +The merge triggers Release Please again. A successful run creates a `v*` tag and a matching draft GitHub Release through the Release App. The App-created tag then triggers `.github/workflows/release.yml`. + +Inspect both workflows: + +```bash +gh run list --repo "$REPOSITORY" --workflow release-please.yml --limit 5 +gh run list --repo "$REPOSITORY" --workflow release.yml --limit 5 +``` + +With `publish-release: true`, the Release workflow builds one authoritative artifact, verifies its handoff and signed checksum manifest, uploads the closed asset set to the draft, creates GitHub attestations for the checksummed payloads, verifies GitHub's asset digests, and changes the draft to a published release. It does not create a second release. + +For an unmodified new example, the first tag is `v0.1.0`. For another repository, set `TAG` to the exact tag shown by the successful Release Please run: + +```bash +export TAG=v0.1.0 +gh release view "$TAG" \ + --repo "$REPOSITORY" \ + --json tagName,isDraft,isPrerelease,publishedAt,url +``` + +The final result must report the expected tag, `"isDraft": false`, and `"isPrerelease": false`. The release contains six platform archives, six archive SBOMs, `checksums.txt`, and `checksums.txt.sigstore.json`. + +To stop before publication and inspect the populated draft, follow [Rehearse and recover GitHub Releases](rehearse-and-recover-github-releases.md). + +## 7. Verify the published release + +Create a new directory and download the exact release: + +```bash +export ASSET_DIR="release-assets-${TAG#v}" +test ! -e "$ASSET_DIR" +mkdir "$ASSET_DIR" +gh release download "$TAG" \ + --repo "$REPOSITORY" \ + --dir "$ASSET_DIR" +cd "$ASSET_DIR" +``` + +On macOS, verify every payload named by the checksum manifest: + +```bash +shasum -a 256 --check checksums.txt +``` + +On Linux, use: + +```bash +sha256sum --check checksums.txt +``` + +Every listed archive and SBOM must report `OK`. + +Verify that the checksum manifest was signed by the canonical reusable pre-publish workflow revision: + +```bash +mise exec -- cosign verify-blob \ + --bundle checksums.txt.sigstore.json \ + --certificate-identity 'https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca' \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + checksums.txt +``` + +Cosign must exit successfully with the certificate identity and issuer constraints in place. + +Finally, verify the GitHub build-provenance attestation for every checksummed payload: + +```bash +while IFS= read -r entry; do + test -n "$entry" || continue + asset="${entry:66}" + mise exec -- gh attestation verify "$asset" \ + --repo "$REPOSITORY" \ + --signer-workflow meigma/release/.github/workflows/publish-github-release.yml \ + --signer-digest 5be87cc60f2f11ac11fe401d8129c7644edc17ca \ + --source-ref "refs/tags/$TAG" \ + --deny-self-hosted-runners +done < checksums.txt +``` + +Each invocation must exit successfully. The signer workflow names the reusable publisher rather than the consumer caller, and `--signer-digest` binds it to the canonical revision. GitHub attestations cover the twelve payloads in `checksums.txt`; the checksum manifest and Cosign bundle are control files and are not attestation subjects. diff --git a/docs/how-to/rehearse-and-recover-github-releases.md b/docs/how-to/rehearse-and-recover-github-releases.md new file mode 100644 index 0000000..9a29a7d --- /dev/null +++ b/docs/how-to/rehearse-and-recover-github-releases.md @@ -0,0 +1,325 @@ +# Rehearse and recover GitHub Releases + +Use this guide to populate a draft GitHub Release without publishing it, then resume publication through the same tag and draft. Complete [Configure GitHub Releases](configure-github-releases.md) first. The [GitHub Release contract](../reference/github-release-contract.md) defines the checks that each run enforces. + +## Prerequisites + +Before starting a rehearsal, confirm that: + +- the release configuration and organization credentials are present on the default branch; +- the Meigma Release App has selected-repository access; +- any protected `v*` tag rule permits the App to create release tags; +- the candidate version has no existing public release; and +- you can update the rehearsal tag if resumption requires moving it to a recovery commit. + +Tag updates are a separate permission from App-created tag creation. If a ruleset prevents you from updating a rehearsal tag, do not weaken the production rule solely for this procedure. Perform the rehearsal in a disposable repository or use an organization-approved break-glass process. + +Record the consumer repository: + +```bash +gh auth status +export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" +export DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" +``` + +## 1. Configure a draft-only run + +In `.github/workflows/release.yml`, set the publisher input to: + +```yaml +publish-release: false +``` + +The copyable example already uses this value. Merge the change into the default branch before Release Please creates the candidate tag. The tag must contain the draft-only caller; changing an untagged branch after the tag exists does not change that run. + +Confirm the value on the default branch: + +```bash +git fetch origin "$DEFAULT_BRANCH" +git show "origin/$DEFAULT_BRANCH:.github/workflows/release.yml" | + grep 'publish-release: false' +``` + +The command must print the draft-only input. Do not start the release if it prints nothing. + +## 2. Create the candidate tag and draft + +Release Please needs a releasable Conventional Commit after the version recorded in `.release-please-manifest.json`. When that condition is met, dispatch it: + +```bash +gh workflow run release-please.yml \ + --repo "$REPOSITORY" \ + --ref "$DEFAULT_BRANCH" +gh run list \ + --repo "$REPOSITORY" \ + --workflow release-please.yml \ + --limit 5 +``` + +Review and squash-merge the Release Please pull request. The subsequent Release Please run creates the `v*` tag and matching draft through the Release App. The tag triggers the Release workflow. + +Set `TAG` to the exact tag created by Release Please. The unmodified new example creates `v0.1.0` first: + +```bash +export TAG=v0.1.0 +git fetch origin "refs/tags/$TAG:refs/tags/$TAG" +export TAG_SHA="$(git rev-list -n 1 "$TAG")" +gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$TAG_SHA" \ + --event push \ + --limit 100 \ + --json databaseId,headBranch,headSha,event,status,url +``` + +After the exact tag and commit appear, assert that the query selects one run and watch it: + +```bash +test "$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$TAG_SHA" \ + --event push \ + --limit 100 \ + --json databaseId \ + --jq 'length')" -eq 1 +export RELEASE_RUN_ID="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$TAG_SHA" \ + --event push \ + --limit 100 \ + --json databaseId \ + --jq '.[0].databaseId')" +gh run watch "$RELEASE_RUN_ID" \ + --repo "$REPOSITORY" \ + --compact \ + --exit-status +``` + +At the documented current revision, a successful draft-only run leaves the Release workflow green and the release unpublished with six platform archives, six archive SBOMs, `checksums.txt`, and `checksums.txt.sigstore.json`. When rehearsing another revision, use the target asset contract in [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). + +## 3. Inspect the populated draft + +Query the authoritative releases collection for the exact tag. This matches the publisher's draft-discovery path and keeps the inspection query aligned with the workflow: + +```bash +test "$(gh api --paginate --slurp \ + "repos/$REPOSITORY/releases?per_page=100" \ + --jq "[.[][] | select(.tag_name == \"$TAG\")] | length")" -eq 1 +export RELEASE_ID="$(gh api --paginate --slurp \ + "repos/$REPOSITORY/releases?per_page=100" \ + --jq "[.[][] | select(.tag_name == \"$TAG\")][0].id")" +gh api "repos/$REPOSITORY/releases/$RELEASE_ID" \ + --jq '{id, tag_name, draft, prerelease, assets: [.assets[].name]}' +``` + +For the documented current revision, the query must select exactly one release with the exact tag, `"draft": true`, `"prerelease": false`, and fourteen assets. Twelve asset names come from `checksums.txt`; the other two are the checksum manifest and its Cosign bundle. For an upgrade rehearsal, require the names and count defined by the target contract instead. + +Use the paginated releases API above or the repository's Releases UI while the release remains a draft. This procedure does not use by-tag CLI commands for draft discovery. + +Before resuming, inspect the Release workflow log and the draft asset list. Do not manually publish the draft. The final workflow must perform digest verification immediately before publication. + +## 4. Resume through the same tag and draft + +Change `.github/workflows/release.yml` to: + +```yaml +publish-release: true +``` + +Submit and merge that change. Fetch the resulting default-branch commit and record it: + +```bash +git fetch origin "$DEFAULT_BRANCH" --tags +export RECOVERY_SHA="$(git rev-parse "origin/$DEFAULT_BRANCH")" +printf 'Recovery commit: %s\n' "$RECOVERY_SHA" +``` + +The `push`-on-tag caller has no manual dispatch input. A rerun of the original Actions run would use the original tagged workflow with `publish-release: false`. To exercise the updated caller, move the same rehearsal tag to the recovery commit and push that tag update: + +```bash +git tag --force "$TAG" "$RECOVERY_SHA" +git push --force origin "refs/tags/$TAG" +``` + +Use this tag move only for the controlled unpublished rehearsal. Never move a tag for a published release. + +After the run for the exact tag and `RECOVERY_SHA` appears, assert that the query selects one run and watch it: + +```bash +gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$RECOVERY_SHA" \ + --event push \ + --limit 100 \ + --json databaseId,headBranch,headSha,event,status,url +test "$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$RECOVERY_SHA" \ + --event push \ + --limit 100 \ + --json databaseId \ + --jq 'length')" -eq 1 +export RESUME_RUN_ID="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$RECOVERY_SHA" \ + --event push \ + --limit 100 \ + --json databaseId \ + --jq '.[0].databaseId')" +gh run watch "$RESUME_RUN_ID" \ + --repo "$REPOSITORY" \ + --compact \ + --exit-status +``` + +The new run builds and signs a new authoritative artifact for `RECOVERY_SHA`. After validating that bundle, the publisher allows only names from its signed checksum manifest and replaces those expected names with `--clobber`. It does not accept an unexpected asset. + +Confirm that publication reused the same release ID and changed its state: + +```bash +export FINAL_RELEASE_ID="$(gh release view "$TAG" \ + --repo "$REPOSITORY" \ + --json databaseId \ + --jq .databaseId)" +test "$FINAL_RELEASE_ID" = "$RELEASE_ID" +gh release view "$TAG" \ + --repo "$REPOSITORY" \ + --json tagName,isDraft,isPrerelease,publishedAt,url +``` + +The ID comparison must succeed. The final result must report `"isDraft": false` and the original tag name. Run the checksum, Cosign identity, and GitHub attestation verification commands in [Configure GitHub Releases](configure-github-releases.md#7-verify-the-published-release) against the final assets. + +## Diagnose a failed run + +Resolve the commit currently named by the unpublished tag, then select the failed push run for that exact tag and commit: + +```bash +git fetch origin "refs/tags/$TAG:refs/tags/$TAG" --force +export FAILED_SHA="$(git rev-list -n 1 "$TAG")" +gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$FAILED_SHA" \ + --event push \ + --status failure \ + --limit 100 \ + --json databaseId,headBranch,headSha,event,status,conclusion,url +test "$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$FAILED_SHA" \ + --event push \ + --status failure \ + --limit 100 \ + --json databaseId \ + --jq 'length')" -eq 1 +export FAILED_RUN_ID="$(gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --branch "$TAG" \ + --commit "$FAILED_SHA" \ + --event push \ + --status failure \ + --limit 100 \ + --json databaseId \ + --jq '.[0].databaseId')" +gh run view "$FAILED_RUN_ID" \ + --repo "$REPOSITORY" \ + --log-failed +``` + +Keep the release as a draft while diagnosing any failure below. + +If recovery changes source, workflow configuration, or tool pins, merge that correction, record its commit SHA, and trigger a new run by authorized movement of the unpublished tag to that commit. Then select the run by the exact tag and SHA as shown above. If the tag cannot be moved safely, abandon the incomplete candidate and cut a new one. Use a plain Actions rerun only when no repository content changes, such as recovery from artifact expiry or a transient service failure. + +### The matching draft is missing + +The publisher polls the releases collection for the current tag and then reports `No GitHub Release found` if none appears. + +1. Check the Release Please run that created the tag. +2. Query the releases collection with the command in [Inspect the populated draft](#3-inspect-the-populated-draft). +3. Confirm that the App installation and both organization credentials include the consumer repository. +4. Confirm that Release Please created both the exact tag and a draft with that tag. + +Do not create an unrelated draft to make the publisher proceed. Release Please owns the release notes, tag, and initial draft. If Release Please created the tag without its draft and cannot reconcile it, remove the incomplete unpublished candidate through an authorized incident process, then cut a new candidate from Release Please. + +### The tag moved or resolves to a different commit + +The publisher requires the workflow's tag to resolve to `github.sha`. It fails when those values differ. + +1. Fetch the remote tag and inspect its commit: + + ```bash + git fetch origin "refs/tags/$TAG:refs/tags/$TAG" --force + git rev-list -n 1 "$TAG" + ``` + +2. Compare the result with the commit shown by the failed Actions run. +3. If the tag was intentionally advanced for resumption, do not rerun the stale draft-only run. Use the new run triggered by the tag update. +4. If the move was unintended, stop publication and follow the repository's release incident process. Do not silently move a published tag backward. + +A draft can be recovered after an authorized tag move because the workflow revalidates the tag and release ID. A published tag is immutable operational history; release a corrected version instead. + +### Artifact handoff fails + +The publisher rejects an invalid artifact ID, an expired artifact, a digest mismatch, or an artifact produced by another workflow run. + +If the repository does not need a correction, rerun the complete top-level workflow with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY"`; do not rerun only the publisher job or substitute an artifact from another run. The producer and publisher must exchange the artifact ID and digest within that run. If the handoff failure requires a source, workflow, or pin correction, merge the correction and move the unpublished tag to that commit to create a new tag-triggered run. The authoritative artifact is retained for seven days; an expired artifact can be replaced by a plain complete rerun when repository content is unchanged. + +### Checksum or Cosign verification fails + +For the documented current revision, the publisher requires a nonempty `checksums.txt`, the exact closed payload list, matching payload hashes, a regular Cosign bundle file, issuer `https://token.actions.githubusercontent.com`, and this certificate identity: + +```text +https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca +``` + +For an upgrade rehearsal, replace the current-revision identity with the target value described in [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). + +Do not upload files manually or relax the identity. Correct the producer configuration or its pinned workflow reference, merge that correction, and move the unpublished tag to the correction commit so a new tag-triggered run builds and signs it. If authorized tag movement is unavailable, abandon the candidate and cut a new one. Expected draft assets can be replaced only after the new signed bundle passes validation. + +### The draft has unexpected assets + +The publisher stops before upload when the draft contains an asset name absent from the signed checksum manifest. + +1. Inspect the draft in the Releases UI and through the releases API. +2. Determine who uploaded the asset and whether it belongs to the candidate. +3. If the asset is not part of the release contract, remove it manually from the draft with an authorized account. +4. If the asset is required, change the producer so the signed checksum manifest includes it. Merge the correction and move the unpublished tag to the correction commit, or abandon the candidate and cut a new one. + +Do not use `--clobber` for an unexpected name. The workflow uses `--clobber` only for the expected closed name set after checksum and signature validation. + +### Uploaded asset digest verification fails + +The publisher waits until every expected asset is uploaded and GitHub reports its digest. It then requires the exact asset count, unique names, and a GitHub-reported SHA-256 digest matching the locally validated bundle. + +If no repository content changes, rerun the complete workflow with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY"`. The validated expected names may be replaced in the same draft. If the failure requires a source, workflow, or pin correction, merge the correction and move the unpublished tag to that commit; otherwise abandon the candidate and cut a new one. If the failure repeats, leave the release as a draft and inspect the release asset state and workflow logs; do not publish through the UI. Manual removal is required only when an unexpected or otherwise unreconcilable asset prevents the workflow from restoring the closed name set. + +If the final API call changed the release to non-draft before a later check failed, the workflow cannot roll that state back. Confirm the state with `gh release view "$TAG" --repo "$REPOSITORY" --json isDraft,publishedAt,url`. A subsequent run will reject the public release because it is no longer a draft; preserve it and cut a corrected version unless the organization authorizes a release-incident removal. + +## When manual cleanup is required + +Manual cleanup is required when: + +- an unexpected asset must be removed from a draft; +- an incomplete unpublished tag or release prevents Release Please from creating the correct candidate; +- repository rules require an authorized administrator to approve the controlled rehearsal tag move; or +- a draft contains a release association or asset state that the validated `--clobber` path cannot reconcile. + +Manual cleanup is not required for an expired authoritative artifact, an expected asset from the first draft-only run, or a failed checksum/signature check. Use a complete rerun when repository content is unchanged. For a source, configuration, or pin correction, merge the correction and trigger a new tag run or abandon the candidate. + +If a release is already public, do not delete it or move its tag as routine recovery. Preserve the published record and release a corrected version unless the organization declares a separate release incident and explicitly authorizes removal. diff --git a/docs/how-to/upgrade-github-release-workflows.md b/docs/how-to/upgrade-github-release-workflows.md new file mode 100644 index 0000000..2bd890a --- /dev/null +++ b/docs/how-to/upgrade-github-release-workflows.md @@ -0,0 +1,249 @@ +# Upgrade GitHub Release workflows + +Use this guide to move a consumer repository from the current workflow revision, `5be87cc60f2f11ac11fe401d8129c7644edc17ca`, to a reviewed immutable revision. Do not use a branch or tag as a reusable workflow reference. The [GitHub Release contract](../reference/github-release-contract.md) defines the current interface and asset boundary. + +## Prerequisites + +Before changing the consumer repository, confirm that: + +- the target `meigma/release` commit has completed review; +- the target commit is available in the public `meigma/release` repository; +- the consumer currently passes its required repository checks; +- `mise`, Git, GitHub CLI, and the repository-approved `actionlint` installation are available locally; and +- you can perform a draft-only release and, if necessary, update its unpublished rehearsal tag. + +Record the consumer, the current baseline, and a local checkout of `meigma/release`: + +```bash +export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" +export CURRENT_RELEASE_REVISION=5be87cc60f2f11ac11fe401d8129c7644edc17ca +read -r -p 'Reviewed full meigma/release commit SHA: ' NEW_RELEASE_REVISION +export NEW_RELEASE_REVISION +read -r -p 'Path to the meigma/release checkout: ' RELEASE_CHECKOUT +export RELEASE_CHECKOUT +[[ "$NEW_RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]] +test "$NEW_RELEASE_REVISION" != "$CURRENT_RELEASE_REVISION" +test "$(gh api "repos/meigma/release/commits/$NEW_RELEASE_REVISION" --jq .sha)" = \ + "$NEW_RELEASE_REVISION" +test "$(git -C "$RELEASE_CHECKOUT" rev-parse --is-inside-work-tree)" = true +git -C "$RELEASE_CHECKOUT" fetch \ + origin \ + "$CURRENT_RELEASE_REVISION" \ + "$NEW_RELEASE_REVISION" +test "$(git -C "$RELEASE_CHECKOUT" rev-parse "$NEW_RELEASE_REVISION^{commit}")" = \ + "$NEW_RELEASE_REVISION" +``` + +The commands must succeed. They prevent a shortened, mistyped, unavailable, or locally unresolved revision from entering the caller. + +## 1. Assess the contract change + +API compare summaries and patches can omit or truncate relevant content. Review a local Git diff between the exact commit objects instead: + +```bash +git -C "$RELEASE_CHECKOUT" diff \ + --no-ext-diff \ + --find-renames \ + "$CURRENT_RELEASE_REVISION^{commit}" \ + "$NEW_RELEASE_REVISION^{commit}" \ + -- \ + .github/workflows/go-pre-publish.yml \ + .github/workflows/publish-github-release.yml \ + .github/workflows/release.yml \ + .github/workflows/release-please.yml \ + docs/reference/github-release-contract.md \ + examples/go-release +``` + +Read the complete target contract after reviewing the diff: + +```bash +git -C "$RELEASE_CHECKOUT" show \ + "$NEW_RELEASE_REVISION:docs/reference/github-release-contract.md" +``` + +Before adoption, identify changes to: + +- reusable workflow inputs, outputs, secrets, and caller permissions; +- checksum signer and attestation identities; +- artifact handoff, payload names, SBOMs, checksums, and publication states; +- consumer source and GoReleaser configuration requirements; +- GitHub App credentials, tag rules, or other external prerequisites; +- runner and mise requirements; and +- required Go, GoReleaser, Syft, Cosign, or GitHub CLI versions. + +Stop if the target revision removes a required consumer capability or if any migration or rollback step is unresolved. Do not infer compatibility from an unchanged workflow filename. + +## 2. Apply the target contract atomically + +In `.github/workflows/release.yml`, replace the current revision with `NEW_RELEASE_REVISION` in all three locations: + +1. `uses: meigma/release/.github/workflows/go-pre-publish.yml@...` +2. `uses: meigma/release/.github/workflows/publish-github-release.yml@...` +3. `checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@...` + +Keep `publish-release: false` for the upgrade rehearsal. The two reusable workflow references and the checksum signing identity must change in the same pull request and commit. A mixed revision fails the signing boundary or runs producer and publisher contracts that were not reviewed together. + +Apply every other target-contract change in that same upgrade: + +- update caller permissions, inputs, output consumption, and secrets; +- update source, GoReleaser, asset, and other repository configuration requirements; +- update locked tools as described below; and +- complete required App installation, tag-rule, credential, or organization-setting changes before the upgraded workflow runs. + +External prerequisites cannot be committed atomically with repository files. Assign an owner and completion condition for each one, complete it in the documented order, and do not merge or trigger a release while the repository and external states describe different contracts. + +Check the edited caller: + +```bash +test "$(grep -F -c "$NEW_RELEASE_REVISION" .github/workflows/release.yml)" -eq 3 +! grep -F -q "$CURRENT_RELEASE_REVISION" .github/workflows/release.yml +grep -F 'publish-release: false' .github/workflows/release.yml +``` + +All three commands must succeed: the caller must contain three target references, no baseline reference, and the draft-only input. + +Write a migration and rollback checklist in the upgrade pull request. It must record: + +- the current and target full commit SHAs; +- every caller interface, permission, source, asset, configuration, and tool change; +- every external prerequisite, its owner, and its observable completion result; +- the expected draft asset and identity checks; and +- the repository and external changes that reverse the entire upgrade before publication. + +Do not merge with an unchecked migration item or an unexecutable rollback item. + +## 3. Update locked tools only when required + +Compare the target contract's repository and toolchain requirements with `mise.toml` and `mise.lock` in the consumer. + +If the target contract keeps the current compatible tool versions, leave both files unchanged. Do not regenerate the lock merely because the workflow revision changed. + +If the target contract requires different tools or versions: + +1. Update only the required declarations in `mise.toml`. +2. Regenerate the supported-platform lock entries: + + ```bash + mise lock --platform linux-x64,linux-arm64,macos-x64,macos-arm64 + ``` + +3. Include `mise.toml` and `mise.lock` in the same upgrade pull request as the caller. + +The target workflow revision and the tool lock must reach the default branch together. + +## 4. Validate the upgrade pull request + +Run the locked tool installation and GoReleaser configuration check: + +```bash +mise install --locked +mise exec -- goreleaser check +actionlint .github/workflows/release.yml .github/workflows/release-please.yml +``` + +All three commands must exit successfully. Use the repository's pinned or otherwise approved `actionlint` installation; do not add an unreviewed download command to the upgrade. + +Run the consumer repository's normal local check commands after these release-specific checks. Use the same build, test, lint, and policy entry points required for an ordinary pull request. Do not merge while any required check fails. + +Review the final diff and confirm that it contains no moving reusable workflow reference. Submit every repository change in the migration checklist as one pull request. Confirm the external prerequisites in the checklist before merging or triggering the rehearsal. + +## 5. Rehearse the target revision + +After the upgrade reaches the default branch, perform the draft-only procedure in [Rehearse and recover GitHub Releases](rehearse-and-recover-github-releases.md). Do not change `publish-release` to `true` until the target revision has populated and verified a draft. + +Use the linked guide for its release creation, exact run selection, draft lookup, resumption, and recovery mechanics. During an upgrade rehearsal, the target contract overrides every baseline constant in that guide. In particular, require the target asset names and count, the checksum certificate identity ending in `@$NEW_RELEASE_REVISION`, and the publisher `--signer-digest "$NEW_RELEASE_REVISION"`. Do not reject a target-compliant draft because it differs from the baseline fourteen-asset set or baseline revision. + +The rehearsal must produce these observable results: + +- Release Please creates the candidate `v*` tag and matching draft; +- the producer and publisher jobs run at `NEW_RELEASE_REVISION`; +- the draft contains exactly the assets allowed by the target contract; +- the checksum manifest validates every listed payload; and +- the release remains a draft. + +Use the authenticated GitHub CLI session to download every asset from the exact draft release ID recorded by the rehearsal guide. Run this Bash block from the consumer repository after setting `TAG`, `REPOSITORY`, and `RELEASE_ID`: + +```bash +set -euo pipefail +export DRAFT_ASSET_DIR="release-assets-upgrade-${TAG#v}" +test ! -e "$DRAFT_ASSET_DIR" +mkdir "$DRAFT_ASSET_DIR" +gh api --paginate --slurp \ + "repos/$REPOSITORY/releases/$RELEASE_ID/assets?per_page=100" \ + --jq '.[][] | [.id, .name] | @tsv' \ + > "$DRAFT_ASSET_DIR/.assets.tsv" +test -s "$DRAFT_ASSET_DIR/.assets.tsv" +while IFS=$'\t' read -r asset_id asset_name; do + [[ "$asset_id" =~ ^[0-9]+$ ]] + [[ "$asset_name" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] + gh api \ + -H 'Accept: application/octet-stream' \ + "repos/$REPOSITORY/releases/assets/$asset_id" \ + > "$DRAFT_ASSET_DIR/$asset_name" +done < "$DRAFT_ASSET_DIR/.assets.tsv" +rm -- "$DRAFT_ASSET_DIR/.assets.tsv" +cd "$DRAFT_ASSET_DIR" +test -s checksums.txt +test -s checksums.txt.sigstore.json +if command -v sha256sum >/dev/null 2>&1; then + sha256sum --check checksums.txt +elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 --check checksums.txt +else + printf 'No SHA-256 checksum command is available.\n' >&2 + exit 1 +fi +``` + +Every listed payload must report `OK`. Verify the target checksum signer identity only after checksum verification succeeds: + +```bash +mise exec -- cosign verify-blob \ + --bundle checksums.txt.sigstore.json \ + --certificate-identity "https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@$NEW_RELEASE_REVISION" \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + checksums.txt +``` + +Cosign must exit successfully. An identity containing the current baseline or any other revision is an upgrade failure. + +Verify each checksummed payload against the target publisher revision. Set `TAG` and `REPOSITORY` to the candidate values before running this command: + +```bash +while IFS= read -r entry; do + test -n "$entry" || continue + asset="${entry:66}" + mise exec -- gh attestation verify "$asset" \ + --repo "$REPOSITORY" \ + --signer-workflow meigma/release/.github/workflows/publish-github-release.yml \ + --signer-digest "$NEW_RELEASE_REVISION" \ + --source-ref "refs/tags/$TAG" \ + --deny-self-hosted-runners +done < checksums.txt +``` + +Every invocation must exit successfully. `--signer-digest` binds the reusable publisher to the reviewed target commit; the source-ref constraint binds the attestation to the consumer's candidate tag. + +After these checks pass, change `publish-release` to `true` and resume through the same tag and populated draft as described in the rehearsal guide. The resume run must use `NEW_RELEASE_REVISION` in all three caller locations. + +## Roll back before publication + +If no candidate tag exists, reverse every repository and external-prerequisite change recorded in the migration checklist. Restore caller interfaces and permissions, workflow references, source and asset configuration, tool declarations and lock entries, App access, tag rules, credentials, and organization settings to their prior states. Apply the repository rollback in one pull request, sequence external rollback steps so the restored workflow remains operable, and run the validation commands again before merging. + +If the target revision has populated an unpublished draft: + +1. Reverse every repository change in the migration checklist in one rollback commit, including both `uses:` entries, `checksum-signing-workflow-ref`, caller permissions and interfaces, source and asset configuration, and tool pins and lock entries. +2. Restore every changed external prerequisite to the prior state recorded in the checklist. Sequence those changes so the rollback workflow remains operable, and record each observable restored state. +3. Keep `publish-release: false`. +4. Run the locked install, GoReleaser check, actionlint, and repository checks against the complete rollback. +5. Move the same unpublished tag to the rollback commit and trigger a new top-level Release run, following the recovery procedure. +6. Verify the restored asset contract, Cosign identity, and GitHub signer digest before enabling publication. + +The validated rollback run may replace expected asset names with `--clobber`. If the target revision added asset names that the previous signed manifest does not allow, those names are unexpected during rollback and block upload. Remove them manually from the draft only after confirming that they came from the abandoned target revision. Do not delete and recreate the draft. + +## Correct after publication + +A public release is no longer a recoverable draft. The publisher rejects it, and there is no workflow rollback that can safely replace its assets or move its tag. + +Preserve the public release and tag. Restore or advance the entire workflow contract for future releases in a reviewed pull request, including caller interfaces, permissions, source and asset configuration, tools, and applicable external prerequisites. Run the same validation and draft rehearsal, then publish a corrected new version. Delete or rewrite a public release only through an explicitly authorized release-incident process. diff --git a/docs/reference/github-release-contract.md b/docs/reference/github-release-contract.md new file mode 100644 index 0000000..347001b --- /dev/null +++ b/docs/reference/github-release-contract.md @@ -0,0 +1,314 @@ +# GitHub release contract reference + +This page defines the cross-repository contract for the reusable Go producer and GitHub Release publisher at revision `5be87cc60f2f11ac11fe401d8129c7644edc17ca`. + +For configuration steps, see [Configure GitHub releases](../how-to/configure-github-releases.md). For draft rehearsals and recovery steps, see [Rehearse and recover GitHub releases](../how-to/rehearse-and-recover-github-releases.md). To adopt another immutable revision, see [Upgrade GitHub release workflows](../how-to/upgrade-github-release-workflows.md). A complete consumer repository is available in the [Go release example](../../examples/go-release/). + +## Canonical workflow references + +Consumer repositories must pin both reusable workflows to the full revision: + +```yaml +uses: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca +``` + +```yaml +uses: meigma/release/.github/workflows/publish-github-release.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca +``` + +The checksum signer identity input must name the same producer workflow revision: + +```yaml +checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca +``` + +## Caller contract + +The supported caller runs on creation or movement of a `v*` tag. Both reusable workflows reject a non-tag ref. Tag deletion events must not start the producer job. + +```yaml +name: Release + +on: + push: + tags: + - 'v*' + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-assets: + name: Build release assets + if: github.event.deleted == false + permissions: + contents: read + id-token: write + uses: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + + github-release: + name: Publish GitHub Release + needs: release-assets + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + uses: meigma/release/.github/workflows/publish-github-release.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + with: + artifact-id: ${{ needs.release-assets.outputs.artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-release: true + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} +``` + +The top-level `permissions: {}` prevents permissions from being granted implicitly. Each called job grants its reusable workflow only the permissions listed above. A called workflow cannot elevate permissions beyond those granted by its caller. + +The concurrency key serializes runs for the same workflow and tag. `cancel-in-progress: false` prevents a later release run from canceling an earlier run. + +## Reusable workflow interfaces + +### `go-pre-publish.yml` + +The Go producer has no inputs and accepts no secrets. + +| Output | Value | +| --- | --- | +| `artifact-id` | ID returned by `actions/upload-artifact` for the `release-assets` artifact. | +| `artifact-url` | URL returned for the `release-assets` artifact. | +| `artifact-digest` | SHA-256 digest returned for the `release-assets` artifact. | + +The job requires these caller permissions: + +| Permission | Access | Use | +| --- | --- | --- | +| `contents` | `read` | Check out the consumer repository and its tag history. | +| `id-token` | `write` | Obtain the OIDC identity used by keyless Cosign signing. | + +The workflow runs on `ubuntu-24.04` with a 20-minute timeout. It declares `permissions: {}` at workflow scope, so the caller must grant the job permissions explicitly. Its `release-assets` artifact is retained for seven days and is uploaded with compression disabled. + +### `publish-github-release.yml` + +| Input | Type | Required | Default | Value | +| --- | --- | --- | --- | --- | +| `artifact-id` | string | Yes | None | Positive integer ID from `go-pre-publish.yml`. | +| `artifact-digest` | string | Yes | None | Expected SHA-256 digest from `go-pre-publish.yml`. The comparison accepts the digest with or without a `sha256:` prefix. | +| `checksum-signing-workflow-ref` | string | Yes | None | Exact owner, repository, workflow path, and revision used as the checksum certificate identity after the `https://github.com/` prefix is added. | +| `release-app-client-id` | string | Yes | None | Client ID used to mint the Release App installation token. | +| `publish-release` | boolean | No | `true` | Whether to change the populated draft to a non-draft release after verification. | + +| Secret | Required | Value | +| --- | --- | --- | +| `release-app-private-key` | Yes | Private key used with `release-app-client-id` to mint the Release App installation token. | + +| Output | Value | +| --- | --- | +| `attestation-url` | URL returned by the GitHub build-provenance attestation step. | +| `release-url` | HTML URL of the populated release, whether it remains a draft or is published. | + +The publisher job requires these caller permissions: + +| Permission | Access | Use | +| --- | --- | --- | +| `actions` | `read` | Read and download the authoritative Actions artifact. | +| `artifact-metadata` | `write` | Write metadata used by GitHub artifact attestations. | +| `attestations` | `write` | Create GitHub build-provenance attestations. | +| `contents` | `read` | Check out the consumer repository at the tag. Draft and release operations use the App token instead. | +| `id-token` | `write` | Obtain the OIDC identity for GitHub build-provenance attestations. | + +The workflow runs on `ubuntu-24.04` with a 10-minute timeout. The reusable workflow declares `permissions: {}` at workflow scope; the caller must grant the job permissions explicitly. + +## Versioning and credentials + +The current versioning workflow runs Release Please on pushes to `main` and on `workflow_dispatch`. It declares `permissions: {}` at workflow scope. Its job declares `contents: write`, `pull-requests: write`, and `issues: write`, then passes a Release App installation token to `googleapis/release-please-action`. + +The supported Release Please configuration has these release-boundary values: + +| Setting | Current value | Contract effect | +| --- | --- | --- | +| `release-type` | `go` | Applies Release Please's Go versioning strategy. | +| Manifest version | `0.0.0` | Records that no release has been published. | +| `initial-version` | `0.1.0` | Selects the first proposed release version. | +| `include-v-in-tag` | `true` | Produces tags accepted by the caller's `v*` filter. | +| `include-component-in-tag` | `false` | Produces an unscoped version tag. | +| `force-tag-creation` | `true` | Creates the release tag when the release is cut. | +| `draft` | `true` | Creates the draft required by the publisher. | +| `bump-minor-pre-major` | `true` | Uses a minor bump for pre-1.0 features. | +| `bump-patch-for-minor-pre-major` | `true` | Uses a patch bump for pre-1.0 fixes. | + +The initial version and pre-1.0 bump rules are current versioning policy, not reusable-workflow defaults. The publisher's hard requirement is a matching draft and tag; it does not calculate a version or create either object. + +Both versioning and publication use these organization-level credential identifiers: + +- Variable: `MEIGMA_RELEASE_APP_CLIENT_ID` +- Secret: `MEIGMA_RELEASE_APP_PRIVATE_KEY` + +The Meigma Release GitHub App must be installed on the consumer repository. Release publication requests an installation token with `contents: write`. Release Please also uses the App to update release pull requests and create the draft release and tag. If a repository protects `v*` tags, its rules must allow this App to bypass tag-creation restrictions. The App-created tag is the event that starts the release caller. + +## Tag and draft invariants + +The publisher proceeds only when all of these conditions hold: + +- `github.ref_type` is `tag`. +- The artifact ID is a positive safe integer. +- The artifact has not expired. +- The artifact belongs to the current workflow run. +- The artifact's GitHub-reported digest matches `artifact-digest`. +- A GitHub Release exists whose `tag_name` equals `github.ref_name`. +- The matching release is still a draft. +- `git rev-list -n 1 ` equals `github.sha` for the run. +- Before upload, the tag resolves uniquely to the previously selected release ID. + +The publisher polls the release list up to 24 times, waiting five seconds after an unsuccessful lookup. It fails instead of creating a missing draft. It also fails if the release is already published or if the tag resolves to a different commit than the run. + +## Repository and toolchain contract + +The producer checks out the consumer repository with full history and runs GoReleaser in that repository. The consumer therefore supplies the Go module, command source, `.goreleaser.yaml`, `mise.toml`, and `mise.lock` used for its build. + +The repository must declare and lock these mise tool identifiers: + +- `go` +- `aqua:goreleaser/goreleaser` +- `aqua:anchore/syft` +- `aqua:sigstore/cosign` +- `aqua:cli/cli` + +The producer installs the first four tools. The publisher installs GitHub CLI and Cosign. Both workflows set `MISE_EXEC_AUTO_INSTALL=false` and invoke tools through `mise exec`; undeclared tools are not installed as a fallback. The producer also sets `GOTOOLCHAIN=local` and verifies that `go`, `goreleaser`, `syft`, and `cosign` resolve to their mise-managed executables. + +The canonical workflows install mise `2026.8.8`. These repository pins are the current known-compatible baseline, not versions selected automatically by the reusable workflows: + +| Tool | Current repository pin | +| --- | --- | +| Go | `1.26.6` | +| GoReleaser | `2.17.1` | +| Syft | `1.51.0` | +| Cosign | `3.1.3` | +| GitHub CLI | `2.97.0` | + +The lock must contain entries that mise can install on the `ubuntu-24.04` runner. The workflows use the versions selected by the consumer repository's locked mise configuration. + +## GoReleaser contract + +The reusable producer runs this command in the consumer repository: + +```text +goreleaser release --clean --skip=publish +``` + +A compatible `.goreleaser.yaml` uses schema version 2 and writes the release bundle under `dist`. The supported Go profile has these requirements: + +- Build Darwin, Linux, and Windows binaries for `amd64` and `arm64` with `CGO_ENABLED=0`. +- Package Darwin and Linux binaries as `tar.gz`; package Windows binaries as `zip`. +- Name archives `___` before the format extension. +- Build with `-trimpath` and linker flags `-s -w -buildid=`. +- Populate `main.version` from `{{ .Version }}` and `main.commit` from `{{ .FullCommit }}`. +- Set `mod_timestamp` to `{{ .CommitTimestamp }}`. +- Use GoReleaser's module-proxy mode and the local Go toolchain. The current profile sets `GOPROXY=https://proxy.golang.org,direct` and `GOSUMDB=sum.golang.org` for module resolution. +- Emit one archive SBOM per archive through GoReleaser's `artifacts: archive` SBOM configuration. +- Write the SHA-256 manifest as `checksums.txt`. +- Sign `checksums.txt` with `cosign sign-blob --bundle=${signature} ${artifact} --yes` and name the bundle `checksums.txt.sigstore.json`. +- Disable GoReleaser changelog generation and the GoReleaser release pipe. Release Please owns release notes and the draft; the reusable publisher owns asset upload and publication. + +The command also supplies `--skip=publish`. `release.disable: true` is the repository requirement; the command-line skip is a second boundary against GoReleaser publication. + +The project name, command path, and binary name are consumer values. The [copyable example](../../examples/go-release/) uses `example`, `./cmd/example`, and `example`. They are not inputs to the reusable workflow. + +## Authoritative artifact and asset contract + +The producer uploads one Actions artifact named `release-assets`. Its upload set is limited to: + +```text +dist/*.tar.gz +dist/*.zip +dist/*.sbom.json +dist/checksums.txt +dist/checksums.txt.sigstore.json +``` + +For the supported three-operating-system, two-architecture Go profile, this is six archives, six archive SBOMs, `checksums.txt`, and `checksums.txt.sigstore.json`: fourteen files in total. + +Before upload, the producer runs `sha256sum --check checksums.txt` and requires a nonempty `checksums.txt.sigstore.json`. The publisher separately checks the Actions artifact metadata and downloads it with `digest-mismatch: error`. + +`checksums.txt` is the authoritative payload list. It may end with a newline; every entry line must contain a 64-digit hexadecimal SHA-256 digest, a standard text or binary marker, and a flat filename matching this character set: + +```text +[A-Za-z0-9][A-Za-z0-9._+-]* +``` + +The bundle verifier enforces these rules: + +- The manifest contains at least one payload. +- Every payload name is unique. +- Payloads are regular files. Directories and symbolic links are rejected. +- Every listed payload exists and matches its recorded SHA-256 digest. +- `checksums.txt` and `checksums.txt.sigstore.json` are control files and cannot list themselves as payloads. +- The downloaded `dist` directory contains exactly the listed payloads and the two control files. Any other entry is rejected. + +The publisher uploads the listed payloads plus the two control files. The GitHub Release must end with exactly that closed name set. Duplicate names, missing names, unexpected names, non-uploaded asset states, missing GitHub digests, or digest differences cause failure. + +GitHub build-provenance attestations use `dist/checksums.txt` as `subject-checksums`. The checksummed archives and SBOMs are attestation subjects. The checksum manifest and its Cosign bundle are uploaded control files, not entries in their own manifest. + +## Trust identities + +The checksum signature is accepted only when Cosign verifies all of the following: + +| Field | Required value | +| --- | --- | +| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca` | +| Certificate OIDC issuer | `https://token.actions.githubusercontent.com` | +| Signed blob | `checksums.txt` | +| Bundle | `checksums.txt.sigstore.json` | + +The exact identity comes from `checksum-signing-workflow-ref`; a branch name, tag name, different commit, or different workflow path does not satisfy the documented identity. + +The publisher at `meigma/release/.github/workflows/publish-github-release.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca` creates GitHub build-provenance attestations in the consumer repository. Its job token creates attestations but has only `contents: read`. A short-lived Release App installation token with `contents: write` performs draft discovery, asset upload, and the final draft-state change. + +## Publication states + +| State | Entry condition | Workflow behavior | Exit condition | +| --- | --- | --- | --- | +| Version prepared | Release Please runs on `main` or by manual dispatch. | Release Please updates its release pull request according to the manifest configuration. | The release change reaches `main`. | +| Draft created | Release Please cuts the version through the Release App. | Release Please creates the `v*` tag and matching draft release. | The App-created tag starts the release caller. | +| Artifact built | The producer runs on the tag. | GoReleaser builds once in that run, creates SBOMs and checksums, signs the checksum manifest, and uploads `release-assets`. | The artifact ID and digest pass to the publisher in the same workflow run. | +| Draft populated | The publisher validates the artifact, signature, tag, and draft. | It attests the checksummed payloads, uploads the closed asset set, and verifies every GitHub-reported asset digest. | Asset names, states, and digests match the signed bundle. | +| Rehearsal complete | `publish-release` is `false`. | The publisher verifies that the release remains a draft. | The populated draft is available for inspection or a later recovery run. | +| Published | `publish-release` is `true` and asset verification succeeds. | The publisher sets `draft: false` and verifies the resulting state. | The same release URL identifies a non-draft GitHub Release. | + +The publisher does not create a release, generate release notes, change a tag, or upload an asset before validating the signed bundle. It does not set `draft: false` until the uploaded asset name and digest sets match the bundle. + +## Retry and recovery behavior + +The artifact verification, draft lookup, upload, and final verification steps request three retries from `actions/github-script` for retryable API failures. Draft discovery additionally polls up to 24 times at five-second intervals. Final asset inspection polls up to 12 times at one-second intervals for every expected asset to report an `uploaded` state and a digest. + +A failed run does not roll back uploaded assets or delete the draft. Recovery is convergent while the release remains a draft: + +- The publisher accepts only an unexpired artifact whose workflow run ID equals `github.run_id`; an artifact from another run cannot be supplied. A new tag-triggered run builds and signs its own artifact. +- Existing assets whose names are in the newly verified manifest may be replaced because upload uses `gh release upload --clobber`. +- Existing assets whose names are outside the newly verified manifest block upload. The workflow does not delete them automatically. +- After upload, the workflow verifies the complete name set and every GitHub-computed SHA-256 digest before it can publish. +- The tag must still resolve to `github.sha`, and the release for that tag must still be a draft. + +A draft rehearsal sets `publish-release: false`. To resume, the caller changes the input to `true`, commits that change, and uses authorized movement of the same unpublished tag name to trigger a new run against the existing populated draft; it does not delete and recreate the draft. The workflow replaces expected assets only after the new artifact, checksums, and Cosign bundle pass validation. Any source, workflow configuration, or tool-pin correction follows the same commit and tag-movement requirement. If the unpublished tag cannot be moved safely, the incomplete candidate must be abandoned and a new candidate cut. A plain Actions rerun is reserved for failures that require no repository-content change, such as artifact expiry or a transient service failure. See the [rehearsal and recovery guide](../how-to/rehearse-and-recover-github-releases.md) for the procedure. + +If the final API call has already changed the release to non-draft before a later failure, the workflow provides no rollback. A subsequent run rejects that published release because the draft invariant no longer holds. + +## Non-goals + +This contract does not provide or imply: + +- OCI image construction or registry publication. +- Homebrew, MacPorts, Nix, Scoop, mise registry, or other package-manager publication. +- DEB, RPM, APK, package-repository, or installer publication. +- Release support for languages other than the documented Go producer profile. +- Consumer CI policy or tests in the OIDC-enabled release job. +- Release-note generation in GoReleaser. +- Automatic creation of a missing draft, deletion of unexpected assets, or rollback after publication. +- Repository ruleset, immutable-release, branch-protection, or credential provisioning automation. +- Automatic adoption by existing repositories. diff --git a/examples/go-release/.github/workflows/release-please.yml b/examples/go-release/.github/workflows/release-please.yml new file mode 100644 index 0000000..11f1be0 --- /dev/null +++ b/examples/go-release/.github/workflows/release-please.yml @@ -0,0 +1,39 @@ +# Required release app settings: +# - vars.MEIGMA_RELEASE_APP_CLIENT_ID +# - secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY +# - protected-tag ruleset bypass for the release app, if protected v* tags are enabled. + +name: Release Please + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: {} + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + issues: write + steps: + - name: Create release app token + id: release-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} + + - name: Run Release Please + id: release + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.release-app.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/examples/go-release/.github/workflows/release.yml b/examples/go-release/.github/workflows/release.yml new file mode 100644 index 0000000..d3ad72a --- /dev/null +++ b/examples/go-release/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-assets: + name: Build release assets + if: github.event.deleted == false + permissions: + contents: read + id-token: write + uses: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + + github-release: + name: Publish GitHub Release + needs: release-assets + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + uses: meigma/release/.github/workflows/publish-github-release.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + with: + artifact-id: ${{ needs.release-assets.outputs.artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-release: false + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} diff --git a/examples/go-release/.goreleaser.yaml b/examples/go-release/.goreleaser.yaml new file mode 100644 index 0000000..73e5f2a --- /dev/null +++ b/examples/go-release/.goreleaser.yaml @@ -0,0 +1,69 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +project_name: example + +gomod: + proxy: true + env: + - GOTOOLCHAIN=local + - GOPROXY=https://proxy.golang.org,direct + - GOSUMDB=sum.golang.org + +builds: + - id: example + main: ./cmd/example + binary: example + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + flags: + - -trimpath + ldflags: + - -s -w -buildid= + - -X main.version={{ .Version }} + - -X main.commit={{ .FullCommit }} + mod_timestamp: "{{ .CommitTimestamp }}" + +archives: + - id: example + ids: + - example + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + +checksum: + name_template: checksums.txt + +sboms: + - id: archives + artifacts: archive + +signs: + - id: checksums + cmd: cosign + artifacts: checksum + signature: "${artifact}.sigstore.json" + args: + - sign-blob + - "--bundle=${signature}" + - "${artifact}" + - --yes + +changelog: + disable: true + +release: + disable: true diff --git a/examples/go-release/.release-please-manifest.json b/examples/go-release/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/examples/go-release/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/examples/go-release/README.md b/examples/go-release/README.md new file mode 100644 index 0000000..8278901 --- /dev/null +++ b/examples/go-release/README.md @@ -0,0 +1,47 @@ +# Go GitHub Release example + +This directory models a repository named `example` with module `example.com/meigma/release-consumer` and command `./cmd/example`. It contains the minimum source needed to demonstrate the release contract and the release-specific infrastructure. It is not a complete CI policy: add the consumer repository's own build, test, review, and branch-protection controls. + +See [Configure GitHub Releases](../../docs/how-to/configure-github-releases.md) for credential setup, adoption, and verification. See [Rehearse and recover GitHub Releases](../../docs/how-to/rehearse-and-recover-github-releases.md) before the first publication. Use [Upgrade GitHub Release workflows](../../docs/how-to/upgrade-github-release-workflows.md) to change the pinned revision. The reusable workflow interface is defined in the [GitHub Release contract](../../docs/reference/github-release-contract.md). + +## Files to copy + +Copy these release files into an existing Go repository, preserving their paths: + +- `.github/workflows/release-please.yml` +- `.github/workflows/release.yml` +- `.goreleaser.yaml` +- `.release-please-manifest.json` +- `release-please-config.json` +- `mise.toml` +- `mise.lock` + +To reproduce the complete minimal consumer in a new empty repository, also copy: + +- `go.mod` +- `cmd/example/main.go` + +Do not copy this README into the consumer repository. + +## Values to replace + +Replace these project-specific example values: + +- `example.com/meigma/release-consumer` in `go.mod` with the consumer's module path. +- `./cmd/example` in `.goreleaser.yaml` with the consumer command package. +- Project name, build ID, archive ID, binary name, and Release Please package name `example` with the consumer's project and binary names. +- The literal command name and default output in `cmd/example/main.go` if you copy the sample command. +- Branch name `main` in `.github/workflows/release-please.yml` if the consumer uses another default branch. +- `initial-version` value `0.1.0` in `release-please-config.json` if the first intended release differs. +- Manifest value `0.0.0` in `.release-please-manifest.json` if the consumer already has a release. Use its latest released version without the `v` prefix. +- Linker variables `main.version` and `main.commit` in `.goreleaser.yaml` if the consumer command exposes version data through different variables. The copied sample defines both variables and prints `example ()` for `--version`. + +Keep these contract values unchanged: + +- both reusable workflow references at `5be87cc60f2f11ac11fe401d8129c7644edc17ca`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca`; +- organization variable `MEIGMA_RELEASE_APP_CLIENT_ID`; +- organization secret `MEIGMA_RELEASE_APP_PRIVATE_KEY`; and +- the locked Go 1.26.6, GoReleaser 2.17.1, Syft 1.51.0, Cosign 3.1.3, and GitHub CLI 2.97.0 versions unless the shared workflow contract is deliberately updated. + +The caller sets `publish-release: false` so the first run leaves a populated draft. After inspecting that draft, change the input to `true` and follow the recovery guide to publish through the same tag and release. diff --git a/examples/go-release/cmd/example/main.go b/examples/go-release/cmd/example/main.go new file mode 100644 index 0000000..35efb87 --- /dev/null +++ b/examples/go-release/cmd/example/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +var ( + version = "dev" + commit = "none" +) + +func main() { + os.Exit(run()) +} + +func run() int { + showVersion := flag.Bool("version", false, "print the project version and commit") + flag.Parse() + + if *showVersion { + fmt.Printf("example %s (%s)\n", version, commit) + return 0 + } + + fmt.Println("example is a copyable Meigma GitHub Release consumer.") + return 0 +} diff --git a/examples/go-release/go.mod b/examples/go-release/go.mod new file mode 100644 index 0000000..0d83667 --- /dev/null +++ b/examples/go-release/go.mod @@ -0,0 +1,3 @@ +module example.com/meigma/release-consumer + +go 1.26.6 diff --git a/examples/go-release/mise.lock b/examples/go-release/mise.lock new file mode 100644 index 0000000..90560e1 --- /dev/null +++ b/examples/go-release/mise.lock @@ -0,0 +1,113 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html + +[[tools."aqua:anchore/syft"]] +version = "1.51.0" +backend = "aqua:anchore/syft" + +[tools."aqua:anchore/syft"."platforms.linux-arm64"] +checksum = "sha256:6c0466811541ea03add5213a60a1562f0851e4c0b0ecfdee1a694a9455285900" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_linux_arm64.tar.gz" + +[tools."aqua:anchore/syft"."platforms.linux-x64"] +checksum = "sha256:2a2e837a2c8d59ec9af5472ee22d3b04ee463c4e44476ecf993fd1e5ab6ebc7f" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_linux_amd64.tar.gz" + +[tools."aqua:anchore/syft"."platforms.macos-arm64"] +checksum = "sha256:4f37f4c7fefce0a68e4cf71ba3f5f9829a99e65d89b29f7ee41b8c2c10ea8c59" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_darwin_arm64.tar.gz" + +[tools."aqua:anchore/syft"."platforms.macos-x64"] +checksum = "sha256:cddf9a044145caf0a1a3194d00d1dd51a1666f4814f2919cdb4768a0c062ad95" +url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_darwin_amd64.tar.gz" + +[[tools."aqua:cli/cli"]] +version = "2.97.0" +backend = "aqua:cli/cli" + +[tools."aqua:cli/cli"."platforms.linux-arm64"] +checksum = "sha256:73ea440ecad9c9e284429997ee6f93577bc6f7bc6fba357ef62c53ad8fb641a5" +url = "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_linux_arm64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:cli/cli"."platforms.linux-x64"] +checksum = "sha256:a2c9b8497e1f85b1ad0dfcb78b5a622e098801b8e461e459e88e1ee12f018112" +url = "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_linux_amd64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:cli/cli"."platforms.macos-arm64"] +checksum = "sha256:a58b8fd77b417a38f47a0b54d1370c59b0fcdb324ccc9ca002b0998f7c4c999e" +url = "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_macOS_arm64.zip" +provenance = "github-attestations" + +[tools."aqua:cli/cli"."platforms.macos-x64"] +checksum = "sha256:63298c998cc2a924c9e254c6af6a1caad6ece281122687a91f079bc0a462700e" +url = "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_macOS_amd64.zip" +provenance = "github-attestations" + +[[tools."aqua:goreleaser/goreleaser"]] +version = "2.17.1" +backend = "aqua:goreleaser/goreleaser" + +[tools."aqua:goreleaser/goreleaser"."platforms.linux-arm64"] +checksum = "sha256:702f03769ac8bcb0e47839c82243cc614ae995633599a98c63062e13ea85f829" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.17.1/goreleaser_Linux_arm64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:goreleaser/goreleaser"."platforms.linux-x64"] +checksum = "sha256:a99bbc7ae0d8d897b07c4c497a9b62f222558804715ef219d1af05a7e417bc80" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.17.1/goreleaser_Linux_x86_64.tar.gz" +provenance = "github-attestations" + +[tools."aqua:goreleaser/goreleaser"."platforms.macos-arm64"] +checksum = "sha256:f49d4fe67d283b5b5130c380c983087dca0a4d4ec15b6637b18fe1ab096780d8" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.17.1/goreleaser_Darwin_all.tar.gz" +provenance = "github-attestations" + +[tools."aqua:goreleaser/goreleaser"."platforms.macos-x64"] +checksum = "sha256:f49d4fe67d283b5b5130c380c983087dca0a4d4ec15b6637b18fe1ab096780d8" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.17.1/goreleaser_Darwin_all.tar.gz" +provenance = "github-attestations" + +[[tools."aqua:sigstore/cosign"]] +version = "3.1.3" +backend = "aqua:sigstore/cosign" + +[tools."aqua:sigstore/cosign"."platforms.linux-arm64"] +checksum = "sha256:c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a" +url = "https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-arm64" +provenance = "cosign" + +[tools."aqua:sigstore/cosign"."platforms.linux-x64"] +checksum = "sha256:4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71" +url = "https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64" +provenance = "cosign" + +[tools."aqua:sigstore/cosign"."platforms.macos-arm64"] +checksum = "sha256:5cf948c2f4dfe59687bdd0b8523709067383e03982cc543475c8a7dc70e92a76" +url = "https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-darwin-arm64" +provenance = "cosign" + +[tools."aqua:sigstore/cosign"."platforms.macos-x64"] +checksum = "sha256:2347488e5d5b25336644024dfeca5601b190e91197a71a917bda44744aff106c" +url = "https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-darwin-amd64" +provenance = "cosign" + +[[tools.go]] +version = "1.26.6" +backend = "core:go" + +[tools.go."platforms.linux-arm64"] +checksum = "sha256:d0507e9e9d7fe012aae570108cbd76c15de879e17130ab8cb90d4d7445cb1f2e" +url = "https://dl.google.com/go/go1.26.6.linux-arm64.tar.gz" + +[tools.go."platforms.linux-x64"] +checksum = "sha256:708effb774be8237570d0add163225abbdfaf4fca28b2611df167beba4feef89" +url = "https://dl.google.com/go/go1.26.6.linux-amd64.tar.gz" + +[tools.go."platforms.macos-arm64"] +checksum = "sha256:2dc95ce4675829f2df0e86b28bcef3283635902062a5f0580ca659bf570f3204" +url = "https://dl.google.com/go/go1.26.6.darwin-arm64.tar.gz" + +[tools.go."platforms.macos-x64"] +checksum = "sha256:08b65a63f244115121ced6c3b55ad38d801a7442acad5c949a17aad84ae6d684" +url = "https://dl.google.com/go/go1.26.6.darwin-amd64.tar.gz" diff --git a/examples/go-release/mise.toml b/examples/go-release/mise.toml new file mode 100644 index 0000000..a0848c4 --- /dev/null +++ b/examples/go-release/mise.toml @@ -0,0 +1,13 @@ +[tools] +go = "1.26.6" +"aqua:goreleaser/goreleaser" = "2.17.1" +"aqua:cli/cli" = "2.97.0" +"aqua:anchore/syft" = "1.51.0" +"aqua:sigstore/cosign" = "3.1.3" + +[env] +GOTOOLCHAIN = "local" + +[settings] +lockfile = true +locked = true diff --git a/examples/go-release/release-please-config.json b/examples/go-release/release-please-config.json new file mode 100644 index 0000000..bd9d535 --- /dev/null +++ b/examples/go-release/release-please-config.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "go", + "initial-version": "0.1.0", + "include-v-in-tag": true, + "include-component-in-tag": false, + "force-tag-creation": true, + "draft": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "packages": { + ".": { + "package-name": "example", + "changelog-path": "CHANGELOG.md" + } + }, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "deps", "section": "Dependencies" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "chore", "section": "Chores", "hidden": true } + ] +} From 4ab8f9de3867432337b5bf17243b9e75c7d4bb10 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 22:03:08 -0700 Subject: [PATCH 33/46] feat(release): package canonical binaries as APKs --- .goreleaser.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2c9023e..145d7e9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -44,6 +44,20 @@ archives: name_template: >- {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} +nfpms: + - id: release-mvp + package_name: release-mvp + ids: + - release-mvp + file_name_template: "{{ .ConventionalFileName }}" + vendor: Meigma + maintainer: Meigma + homepage: https://github.com/meigma/release + description: Exercise the Meigma release pipeline. + formats: + - apk + bindir: /usr/bin + checksum: name_template: checksums.txt From f880a4cefdad74cf65d22688cd64e7238390f52d Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 22:40:10 -0700 Subject: [PATCH 34/46] feat(release): build canonical OCI image artifacts --- .github/workflows/go-oci-build.yml | 374 +++++++++++++++++++++++++++ .github/workflows/go-pre-publish.yml | 49 ++++ .github/workflows/release.yml | 15 +- .goreleaser.yaml | 14 - apko.yaml | 33 +++ examples/go-release/apko.yaml | 33 +++ examples/go-release/melange.yaml | 26 ++ examples/go-release/mise.lock | 40 +++ examples/go-release/mise.toml | 2 + melange.yaml | 26 ++ 10 files changed, 597 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/go-oci-build.yml create mode 100644 apko.yaml create mode 100644 examples/go-release/apko.yaml create mode 100644 examples/go-release/melange.yaml create mode 100644 melange.yaml diff --git a/.github/workflows/go-oci-build.yml b/.github/workflows/go-oci-build.yml new file mode 100644 index 0000000..8a91271 --- /dev/null +++ b/.github/workflows/go-oci-build.yml @@ -0,0 +1,374 @@ +name: Reusable Go OCI Builder + +on: + workflow_call: + inputs: + artifact-id: + description: ID of the canonical Linux binaries artifact. + required: true + type: string + artifact-digest: + description: Expected SHA-256 digest of the canonical Linux binaries artifact. + required: true + type: string + melange-config: + description: Path to the consumer's Melange package configuration. + required: false + default: melange.yaml + type: string + apko-config: + description: Path to the consumer's apko image configuration. + required: false + default: apko.yaml + type: string + outputs: + artifact-id: + description: ID of the authoritative OCI image artifact. + value: ${{ jobs.oci-image.outputs.artifact-id }} + artifact-url: + description: URL of the authoritative OCI image artifact. + value: ${{ jobs.oci-image.outputs.artifact-url }} + artifact-digest: + description: SHA-256 digest of the authoritative OCI image artifact. + value: ${{ jobs.oci-image.outputs.artifact-digest }} + image-digest: + description: Digest of the OCI image index. + value: ${{ jobs.oci-image.outputs.image-digest }} + +permissions: {} + +jobs: + oci-image: + name: Build authoritative OCI image + runs-on: ubuntu-24.04 + timeout-minutes: 20 + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + artifact-url: ${{ steps.upload.outputs.artifact-url }} + artifact-digest: ${{ steps.upload.outputs.artifact-digest }} + image-digest: ${{ steps.verify.outputs.image-digest }} + permissions: + actions: read + contents: read + env: + MISE_EXEC_AUTO_INSTALL: 'false' + steps: + - name: Require a tag ref + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + if (process.env.GITHUB_REF_TYPE !== 'tag') { + throw new Error('Go OCI production must run against a tag ref.') + } + if (!process.env.GITHUB_REF_NAME.startsWith('v')) { + throw new Error(`Expected a v-prefixed tag, got ${process.env.GITHUB_REF_NAME}.`) + } + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + filter: 'blob:none' + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + install_args: >- + aqua:cli/cli + aqua:chainguard-dev/melange + aqua:chainguard-dev/apko + cache: true + add_shims_to_path: false + export_path: false + + - name: Verify OCI build tools + shell: bash + run: | + mise exec -- bash -euo pipefail -c ' + for tool in gh melange apko; do + active=$(command -v "${tool}") + managed=$(mise which "${tool}") + test "$(realpath "${active}")" = "$(realpath "${managed}")" + printf "Using %s: %s\n" "${tool}" "${active}" + done + melange version + apko version + ' + + - name: Verify canonical binary handoff + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ARTIFACT_ID: ${{ inputs.artifact-id }} + EXPECTED_DIGEST: ${{ inputs.artifact-digest }} + with: + retries: 3 + script: | + const artifactId = Number(process.env.ARTIFACT_ID) + if (!Number.isSafeInteger(artifactId) || artifactId <= 0) { + throw new Error(`Invalid artifact ID: ${process.env.ARTIFACT_ID}`) + } + + const {data: artifact} = await github.rest.actions.getArtifact({ + ...context.repo, + artifact_id: artifactId, + }) + if (artifact.expired) { + throw new Error(`Artifact ${artifactId} has expired.`) + } + if (artifact.workflow_run?.id !== Number(context.runId)) { + throw new Error( + `Artifact ${artifactId} belongs to workflow run ` + + `${artifact.workflow_run?.id}, expected ${context.runId}.`, + ) + } + const normalize = (digest) => String(digest ?? '') + .replace(/^sha256:/, '') + .toLowerCase() + const expected = normalize(process.env.EXPECTED_DIGEST) + const actual = normalize(artifact.digest) + if (!expected || actual !== expected) { + throw new Error( + `Artifact digest mismatch: expected ${process.env.EXPECTED_DIGEST}, got ${artifact.digest}.`, + ) + } + + - name: Download canonical Linux binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: oci-input + digest-mismatch: error + + - name: Stage OCI build inputs + id: stage + env: + APKO_CONFIG: ${{ inputs.apko-config }} + MELANGE_CONFIG: ${{ inputs.melange-config }} + shell: bash + run: | + set -euo pipefail + test -f "${APKO_CONFIG}" + test -f "${MELANGE_CONFIG}" + test -f oci-input/artifacts.json + + work=${RUNNER_TEMP}/oci-build + output=${GITHUB_WORKSPACE}/oci-output + rm -rf "${work}" "${output}" + mkdir -p \ + "${work}/sources/x86_64" \ + "${work}/sources/aarch64" \ + "${output}/configuration" \ + "${output}/packages" \ + "${output}/layout" \ + "${output}/sboms" + + input_root=$(realpath oci-input) + binary_name= + for mapping in amd64:x86_64 arm64:aarch64; do + goarch=${mapping%%:*} + apkarch=${mapping##*:} + record=$(jq -cer --arg arch "${goarch}" ' + [ + .[] + | select( + .type == "Binary" and + .goos == "linux" and + .goarch == $arch + ) + ] + | if length == 1 then .[0] else error( + "expected exactly one linux/" + $arch + " binary" + ) end + ' oci-input/artifacts.json) + artifact_path=$(jq -er '.path' <<<"${record}") + artifact_name=$(jq -er '.name' <<<"${record}") + relative_path=${artifact_path#dist/} + if [[ "${relative_path}" == "${artifact_path}" ]]; then + echo "::error::Unexpected GoReleaser artifact path: ${artifact_path}" + exit 1 + fi + source_path=$(realpath "oci-input/${relative_path}") + if [[ "${source_path}" != "${input_root}/"* ]]; then + echo "::error::GoReleaser artifact escapes its handoff directory." + exit 1 + fi + test -x "${source_path}" + if [[ -n "${binary_name}" && "${artifact_name}" != "${binary_name}" ]]; then + echo '::error::Linux architecture binaries have different names.' + exit 1 + fi + binary_name=${artifact_name} + install -m755 "${source_path}" "${work}/sources/${apkarch}/application" + done + + version=${GITHUB_REF_NAME#v} + jq -n --arg version "${version}" '{version: $version}' > "${work}/vars.json" + build_date=$(git show -s --format=%cI "${GITHUB_SHA}") + + install -m644 "${APKO_CONFIG}" "${output}/configuration/apko.yaml" + install -m644 "${MELANGE_CONFIG}" "${output}/configuration/melange.yaml" + ( + cd "${work}" + sha256sum \ + sources/x86_64/application \ + sources/aarch64/application + ) > "${output}/canonical-binaries.sha256" + + { + echo "work=${work}" + echo "output=${output}" + echo "version=${version}" + echo "build-date=${build_date}" + echo "binary-name=${binary_name}" + } >> "${GITHUB_OUTPUT}" + + - name: Build signed APK repositories + env: + BUILD_DATE: ${{ steps.stage.outputs.build-date }} + VERSION: ${{ steps.stage.outputs.version }} + WORK: ${{ steps.stage.outputs.work }} + OUTPUT: ${{ steps.stage.outputs.output }} + shell: bash + run: | + set -euo pipefail + mise exec -- melange compile \ + --arch x86_64 \ + --vars-file "${WORK}/vars.json" \ + "${OUTPUT}/configuration/melange.yaml" >/dev/null + mise exec -- melange keygen "${WORK}/apk-signing.rsa" + install -m644 "${WORK}/apk-signing.rsa.pub" "${OUTPUT}/apk-signing.rsa.pub" + + for arch in x86_64 aarch64; do + mise exec -- melange build \ + --arch "${arch}" \ + --runner docker \ + --source-dir "${WORK}/sources/${arch}" \ + --out-dir "${OUTPUT}/packages" \ + --signing-key "${WORK}/apk-signing.rsa" \ + --namespace "${GITHUB_REPOSITORY_OWNER}" \ + --build-date "${BUILD_DATE}" \ + --git-repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ + --git-commit "${GITHUB_SHA}" \ + --vars-file "${WORK}/vars.json" \ + --generate-provenance \ + "${OUTPUT}/configuration/melange.yaml" + done + + for arch in x86_64 aarch64; do + test -s "${OUTPUT}/packages/${arch}/APKINDEX.tar.gz" + package_count=$(compgen -G "${OUTPUT}/packages/${arch}/*.apk" | wc -l) + test "${package_count}" -eq 1 + done + + - name: Compose locked multi-architecture image + env: + BUILD_DATE: ${{ steps.stage.outputs.build-date }} + OUTPUT: ${{ steps.stage.outputs.output }} + VERSION: ${{ steps.stage.outputs.version }} + shell: bash + run: | + set -euo pipefail + cd "${OUTPUT}" + mise exec -- apko lock \ + --arch x86_64 \ + --arch aarch64 \ + --repository-append packages \ + --keyring-append apk-signing.rsa.pub \ + --output apko.lock.json \ + configuration/apko.yaml + mise exec -- apko build \ + --arch x86_64 \ + --arch aarch64 \ + --repository-append packages \ + --keyring-append apk-signing.rsa.pub \ + --lockfile apko.lock.json \ + --build-date "${BUILD_DATE}" \ + --sbom-path sboms \ + --annotations "org.opencontainers.image.version:${VERSION}" \ + --annotations "org.opencontainers.image.revision:${GITHUB_SHA}" \ + configuration/apko.yaml \ + "local/${GITHUB_REPOSITORY#*/}:${VERSION}" \ + layout/ + + - name: Verify authoritative OCI image + id: verify + env: + OUTPUT: ${{ steps.stage.outputs.output }} + VERSION: ${{ steps.stage.outputs.version }} + WORK: ${{ steps.stage.outputs.work }} + shell: bash + run: | + set -euo pipefail + cd "${OUTPUT}" + jq -e ' + ((.manifests | length) == 2) and + (([.manifests[].platform.architecture] | sort) == ["amd64", "arm64"]) and + (all(.manifests[]; .platform.os == "linux")) + ' layout/index.json >/dev/null + + while IFS=$'\t' read -r architecture manifest_digest; do + case "${architecture}" in + amd64) apkarch=x86_64 ;; + arm64) apkarch=aarch64 ;; + *) echo "::error::Unexpected architecture: ${architecture}"; exit 1 ;; + esac + manifest=layout/blobs/sha256/${manifest_digest#sha256:} + jq -e '(.layers | length) == 1' "${manifest}" >/dev/null + config_digest=$(jq -er '.config.digest' "${manifest}") + layer_digest=$(jq -er '.layers[0].digest' "${manifest}") + config=layout/blobs/sha256/${config_digest#sha256:} + layer=layout/blobs/sha256/${layer_digest#sha256:} + entrypoint=$(jq -er ' + .config.Entrypoint + | if length == 1 then .[0] else error("expected one entrypoint") end + ' "${config}") + entry=${entrypoint#/} + + owner=$( + tar --numeric-owner -tvf "${layer}" "${entry}" | + awk '{ + if ($2 ~ /^[0-9]+\/[0-9]+$/) { + print $2 + } else { + print $3 "/" $4 + } + }' + ) + if [[ "${owner}" != '0/0' ]]; then + echo "::error::${entrypoint} is owned by ${owner}; expected 0/0." + exit 1 + fi + expected=$(sha256sum "${WORK}/sources/${apkarch}/application" | cut -d' ' -f1) + actual=$(tar -xOf "${layer}" "${entry}" | sha256sum | cut -d' ' -f1) + if [[ "${actual}" != "${expected}" ]]; then + echo "::error::${architecture} image binary differs from its canonical input." + exit 1 + fi + done < <( + jq -r '.manifests[] | [.platform.architecture, .digest] | @tsv' layout/index.json + ) + + for sbom in sboms/sbom-x86_64.spdx.json sboms/sbom-aarch64.spdx.json; do + jq -e --arg version "${VERSION}-r0" ' + any(.packages[]; + .primaryPackagePurpose == "APPLICATION" and + .versionInfo == $version + ) + ' "${sbom}" >/dev/null + done + + image_digest=sha256:$(sha256sum layout/index.json | cut -d' ' -f1) + printf '%s\n' "${image_digest}" > image-digest.txt + echo "image-digest=${image_digest}" >> "${GITHUB_OUTPUT}" + + - name: Upload authoritative OCI image + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oci-image + path: oci-output/ + if-no-files-found: error + retention-days: 7 + compression-level: 0 diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml index 968f231..054ef8f 100644 --- a/.github/workflows/go-pre-publish.yml +++ b/.github/workflows/go-pre-publish.yml @@ -12,6 +12,15 @@ on: artifact-digest: description: SHA-256 digest of the authoritative release-assets artifact. value: ${{ jobs.release-assets.outputs.artifact-digest }} + oci-input-artifact-id: + description: ID of the canonical Linux binaries artifact. + value: ${{ jobs.release-assets.outputs.oci-input-artifact-id }} + oci-input-artifact-url: + description: URL of the canonical Linux binaries artifact. + value: ${{ jobs.release-assets.outputs.oci-input-artifact-url }} + oci-input-artifact-digest: + description: SHA-256 digest of the canonical Linux binaries artifact. + value: ${{ jobs.release-assets.outputs.oci-input-artifact-digest }} permissions: {} @@ -24,6 +33,9 @@ jobs: artifact-id: ${{ steps.upload.outputs.artifact-id }} artifact-url: ${{ steps.upload.outputs.artifact-url }} artifact-digest: ${{ steps.upload.outputs.artifact-digest }} + oci-input-artifact-id: ${{ steps.upload-oci-input.outputs.artifact-id }} + oci-input-artifact-url: ${{ steps.upload-oci-input.outputs.artifact-url }} + oci-input-artifact-digest: ${{ steps.upload-oci-input.outputs.artifact-digest }} permissions: contents: read id-token: write @@ -80,6 +92,43 @@ jobs: sha256sum --check checksums.txt test -s checksums.txt.sigstore.json + - name: Verify canonical Linux binaries + shell: bash + run: | + mapfile -t binaries < <( + jq -r ' + .[] + | select(.type == "Binary" and .goos == "linux") + | [.goarch, .path] + | @tsv + ' dist/artifacts.json + ) + if [[ ${#binaries[@]} -ne 2 ]]; then + echo "::error::Expected two canonical Linux binaries; found ${#binaries[@]}." + exit 1 + fi + printf '%s\n' "${binaries[@]}" | + cut -f1 | + sort | + diff -u <(printf 'amd64\narm64\n') - + + for record in "${binaries[@]}"; do + path=${record#*$'\t'} + test -x "${path}" + done + + - name: Upload canonical Linux binaries + id: upload-oci-input + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oci-build-inputs + path: | + dist/artifacts.json + dist/*_linux_amd64*/** + dist/*_linux_arm64*/** + if-no-files-found: error + retention-days: 7 + compression-level: 0 - name: Upload authoritative release assets id: upload uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d63dd1..d990d62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,10 +19,23 @@ jobs: contents: read id-token: write uses: ./.github/workflows/go-pre-publish.yml + oci-image: + name: Build OCI image + needs: release-assets + permissions: + actions: read + contents: read + uses: ./.github/workflows/go-oci-build.yml + with: + artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} + github-release: name: Publish GitHub Release - needs: release-assets + needs: + - release-assets + - oci-image permissions: actions: read artifact-metadata: write diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 145d7e9..2c9023e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -44,20 +44,6 @@ archives: name_template: >- {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} -nfpms: - - id: release-mvp - package_name: release-mvp - ids: - - release-mvp - file_name_template: "{{ .ConventionalFileName }}" - vendor: Meigma - maintainer: Meigma - homepage: https://github.com/meigma/release - description: Exercise the Meigma release pipeline. - formats: - - apk - bindir: /usr/bin - checksum: name_template: checksums.txt diff --git a/apko.yaml b/apko.yaml new file mode 100644 index 0000000..4febe96 --- /dev/null +++ b/apko.yaml @@ -0,0 +1,33 @@ +contents: + repositories: + - https://dl-cdn.alpinelinux.org/alpine/v3.24/main + packages: + - alpine-release + - ca-certificates-bundle + - release-mvp + +entrypoint: + command: /usr/bin/release-mvp + +accounts: + groups: + - groupname: nonroot + gid: 65532 + users: + - username: nonroot + uid: 65532 + run-as: nonroot + +environment: + PATH: /usr/bin + SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt + +archs: + - amd64 + - arm64 + +annotations: + org.opencontainers.image.title: release-mvp + org.opencontainers.image.description: Exercise the Meigma release pipeline. + org.opencontainers.image.source: https://github.com/meigma/release + org.opencontainers.image.licenses: LicenseRef-Proprietary diff --git a/examples/go-release/apko.yaml b/examples/go-release/apko.yaml new file mode 100644 index 0000000..aec61cc --- /dev/null +++ b/examples/go-release/apko.yaml @@ -0,0 +1,33 @@ +contents: + repositories: + - https://dl-cdn.alpinelinux.org/alpine/v3.24/main + packages: + - alpine-release + - ca-certificates-bundle + - example + +entrypoint: + command: /usr/bin/example + +accounts: + groups: + - groupname: nonroot + gid: 65532 + users: + - username: nonroot + uid: 65532 + run-as: nonroot + +environment: + PATH: /usr/bin + SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt + +archs: + - amd64 + - arm64 + +annotations: + org.opencontainers.image.title: example + org.opencontainers.image.description: Example Go command using the Meigma release workflows. + org.opencontainers.image.source: https://github.com/OWNER/REPOSITORY + org.opencontainers.image.licenses: LicenseRef-Proprietary diff --git a/examples/go-release/melange.yaml b/examples/go-release/melange.yaml new file mode 100644 index 0000000..f30013a --- /dev/null +++ b/examples/go-release/melange.yaml @@ -0,0 +1,26 @@ +package: + name: example + version: ${{vars.version}} + epoch: 0 + description: Example Go command using the Meigma release workflows. + target-architecture: + - x86_64 + - aarch64 + copyright: + - license: LicenseRef-Proprietary + +vars: + version: 0.0.0 + +environment: + contents: + repositories: + - https://packages.wolfi.dev/os + keyring: + - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub + packages: + - busybox + +pipeline: + - runs: | + install -Dm755 -o 0 -g 0 application "${{targets.destdir}}/usr/bin/example" diff --git a/examples/go-release/mise.lock b/examples/go-release/mise.lock index 90560e1..fd3df3e 100644 --- a/examples/go-release/mise.lock +++ b/examples/go-release/mise.lock @@ -20,6 +20,46 @@ url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_dar checksum = "sha256:cddf9a044145caf0a1a3194d00d1dd51a1666f4814f2919cdb4768a0c062ad95" url = "https://github.com/anchore/syft/releases/download/v1.51.0/syft_1.51.0_darwin_amd64.tar.gz" +[[tools."aqua:chainguard-dev/apko"]] +version = "1.2.37" +backend = "aqua:chainguard-dev/apko" + +[tools."aqua:chainguard-dev/apko"."platforms.linux-arm64"] +checksum = "sha256:43e94fcda75df76e0b9fe78a98b2277ce4cc7eabe246e02df7c6f52c75b50f5d" +url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.37/apko_1.2.37_linux_arm64.tar.gz" + +[tools."aqua:chainguard-dev/apko"."platforms.linux-x64"] +checksum = "sha256:9fa4ed893d0d87483a5f709fd5e2f1a1e6f93e40b35195949df2d9cf4f7093cc" +url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.37/apko_1.2.37_linux_amd64.tar.gz" + +[tools."aqua:chainguard-dev/apko"."platforms.macos-arm64"] +checksum = "sha256:097ebf8e1f19278bca2e5fd788b7df002b40f25b2cbcda90165e5b78982b8c5b" +url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.37/apko_1.2.37_darwin_arm64.tar.gz" + +[tools."aqua:chainguard-dev/apko"."platforms.macos-x64"] +checksum = "sha256:7edc3c7c5839b7c8352c78e11f510537ff261f0bdcaf7b70c8fe985a1e3d5119" +url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.37/apko_1.2.37_darwin_amd64.tar.gz" + +[[tools."aqua:chainguard-dev/melange"]] +version = "0.59.1" +backend = "aqua:chainguard-dev/melange" + +[tools."aqua:chainguard-dev/melange"."platforms.linux-arm64"] +checksum = "sha256:aa5d221f92a248ba9aa490d1441c733417ad4c02f32fa9a13b8800acb42946f9" +url = "https://github.com/chainguard-dev/melange/releases/download/v0.59.1/melange_0.59.1_linux_arm64.tar.gz" + +[tools."aqua:chainguard-dev/melange"."platforms.linux-x64"] +checksum = "sha256:90f76f3e5fcb90ddc4c932ad352982bda98d58001d2bcccb982dd7fb2e608a1f" +url = "https://github.com/chainguard-dev/melange/releases/download/v0.59.1/melange_0.59.1_linux_amd64.tar.gz" + +[tools."aqua:chainguard-dev/melange"."platforms.macos-arm64"] +checksum = "sha256:6485fcff49ca60fc3f75a773eb1f7f81562cf394b86cc9fe8371f7d55e8f9b98" +url = "https://github.com/chainguard-dev/melange/releases/download/v0.59.1/melange_0.59.1_darwin_arm64.tar.gz" + +[tools."aqua:chainguard-dev/melange"."platforms.macos-x64"] +checksum = "sha256:0a1080caa973c9a10b9e331542e927531710810647c4f3b9ec9e53bf22f2e960" +url = "https://github.com/chainguard-dev/melange/releases/download/v0.59.1/melange_0.59.1_darwin_amd64.tar.gz" + [[tools."aqua:cli/cli"]] version = "2.97.0" backend = "aqua:cli/cli" diff --git a/examples/go-release/mise.toml b/examples/go-release/mise.toml index a0848c4..62367f5 100644 --- a/examples/go-release/mise.toml +++ b/examples/go-release/mise.toml @@ -4,6 +4,8 @@ go = "1.26.6" "aqua:cli/cli" = "2.97.0" "aqua:anchore/syft" = "1.51.0" "aqua:sigstore/cosign" = "3.1.3" +"aqua:chainguard-dev/melange" = "0.59.1" +"aqua:chainguard-dev/apko" = "1.2.37" [env] GOTOOLCHAIN = "local" diff --git a/melange.yaml b/melange.yaml new file mode 100644 index 0000000..8f0b0b2 --- /dev/null +++ b/melange.yaml @@ -0,0 +1,26 @@ +package: + name: release-mvp + version: ${{vars.version}} + epoch: 0 + description: Exercise the Meigma release pipeline. + target-architecture: + - x86_64 + - aarch64 + copyright: + - license: LicenseRef-Proprietary + +vars: + version: 0.0.0 + +environment: + contents: + repositories: + - https://packages.wolfi.dev/os + keyring: + - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub + packages: + - busybox + +pipeline: + - runs: | + install -Dm755 -o 0 -g 0 application "${{targets.destdir}}/usr/bin/release-mvp" From 5c50673aa82997d28548f3d92696939cc837e5da Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 22:41:15 -0700 Subject: [PATCH 35/46] docs(release): add OCI consumer configuration --- .../go-release/.github/workflows/release.yml | 21 +++++++++++++++---- examples/go-release/README.md | 16 +++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/examples/go-release/.github/workflows/release.yml b/examples/go-release/.github/workflows/release.yml index d3ad72a..d07b48a 100644 --- a/examples/go-release/.github/workflows/release.yml +++ b/examples/go-release/.github/workflows/release.yml @@ -18,22 +18,35 @@ jobs: permissions: contents: read id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + uses: meigma/release/.github/workflows/go-pre-publish.yml@f880a4cefdad74cf65d22688cd64e7238390f52d + + oci-image: + name: Build OCI image + needs: release-assets + permissions: + actions: read + contents: read + uses: meigma/release/.github/workflows/go-oci-build.yml@f880a4cefdad74cf65d22688cd64e7238390f52d + with: + artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} github-release: name: Publish GitHub Release - needs: release-assets + needs: + - release-assets + - oci-image permissions: actions: read artifact-metadata: write attestations: write contents: read id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + uses: meigma/release/.github/workflows/publish-github-release.yml@f880a4cefdad74cf65d22688cd64e7238390f52d with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@f880a4cefdad74cf65d22688cd64e7238390f52d release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} publish-release: false secrets: diff --git a/examples/go-release/README.md b/examples/go-release/README.md index 8278901..dba0a8d 100644 --- a/examples/go-release/README.md +++ b/examples/go-release/README.md @@ -1,6 +1,6 @@ -# Go GitHub Release example +# Go release example -This directory models a repository named `example` with module `example.com/meigma/release-consumer` and command `./cmd/example`. It contains the minimum source needed to demonstrate the release contract and the release-specific infrastructure. It is not a complete CI policy: add the consumer repository's own build, test, review, and branch-protection controls. +This directory models a repository named `example` with module `example.com/meigma/release-consumer` and command `./cmd/example`. It contains the minimum source needed to build GitHub Release assets and a multi-architecture OCI image. It is not a complete CI policy: add the consumer repository's own build, test, review, and branch-protection controls. See [Configure GitHub Releases](../../docs/how-to/configure-github-releases.md) for credential setup, adoption, and verification. See [Rehearse and recover GitHub Releases](../../docs/how-to/rehearse-and-recover-github-releases.md) before the first publication. Use [Upgrade GitHub Release workflows](../../docs/how-to/upgrade-github-release-workflows.md) to change the pinned revision. The reusable workflow interface is defined in the [GitHub Release contract](../../docs/reference/github-release-contract.md). @@ -11,6 +11,8 @@ Copy these release files into an existing Go repository, preserving their paths: - `.github/workflows/release-please.yml` - `.github/workflows/release.yml` - `.goreleaser.yaml` +- `apko.yaml` +- `melange.yaml` - `.release-please-manifest.json` - `release-please-config.json` - `mise.toml` @@ -30,6 +32,8 @@ Replace these project-specific example values: - `example.com/meigma/release-consumer` in `go.mod` with the consumer's module path. - `./cmd/example` in `.goreleaser.yaml` with the consumer command package. - Project name, build ID, archive ID, binary name, and Release Please package name `example` with the consumer's project and binary names. +- Package name, description, license, and installed command path in `melange.yaml`. +- Package name, entrypoint, image annotations, and source URL in `apko.yaml`. - The literal command name and default output in `cmd/example/main.go` if you copy the sample command. - Branch name `main` in `.github/workflows/release-please.yml` if the consumer uses another default branch. - `initial-version` value `0.1.0` in `release-please-config.json` if the first intended release differs. @@ -38,10 +42,12 @@ Replace these project-specific example values: Keep these contract values unchanged: -- both reusable workflow references at `5be87cc60f2f11ac11fe401d8129c7644edc17ca`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca`; +- all three reusable workflow references at `f880a4cefdad74cf65d22688cd64e7238390f52d`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@f880a4cefdad74cf65d22688cd64e7238390f52d`; - organization variable `MEIGMA_RELEASE_APP_CLIENT_ID`; - organization secret `MEIGMA_RELEASE_APP_PRIVATE_KEY`; and -- the locked Go 1.26.6, GoReleaser 2.17.1, Syft 1.51.0, Cosign 3.1.3, and GitHub CLI 2.97.0 versions unless the shared workflow contract is deliberately updated. +- the locked Go 1.26.6, GoReleaser 2.17.1, Syft 1.51.0, Cosign 3.1.3, GitHub CLI 2.97.0, Melange 0.59.1, and apko 1.2.37 versions unless the shared workflow contract is deliberately updated. The caller sets `publish-release: false` so the first run leaves a populated draft. After inspecting that draft, change the input to `true` and follow the recovery guide to publish through the same tag and release. + +The OCI job retains the signed APK repository, apko lock, SPDX files, and OCI layout in the `oci-image` workflow artifact. It does not publish the image to a registry. From c0cdf1d84fa68de7e51b54e5da635c6d84bd5cb0 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 23:07:35 -0700 Subject: [PATCH 36/46] fix(release): restore executable OCI inputs --- .github/workflows/go-oci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go-oci-build.yml b/.github/workflows/go-oci-build.yml index 8a91271..5f73980 100644 --- a/.github/workflows/go-oci-build.yml +++ b/.github/workflows/go-oci-build.yml @@ -194,7 +194,7 @@ jobs: echo "::error::GoReleaser artifact escapes its handoff directory." exit 1 fi - test -x "${source_path}" + test -f "${source_path}" if [[ -n "${binary_name}" && "${artifact_name}" != "${binary_name}" ]]; then echo '::error::Linux architecture binaries have different names.' exit 1 From 590457073d615c9063a06f8c34cee6ebbc87a936 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 23:10:30 -0700 Subject: [PATCH 37/46] fix(release): emulate ARM OCI package builds --- .github/workflows/go-oci-build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/go-oci-build.yml b/.github/workflows/go-oci-build.yml index 5f73980..652a920 100644 --- a/.github/workflows/go-oci-build.yml +++ b/.github/workflows/go-oci-build.yml @@ -83,6 +83,12 @@ jobs: add_shims_to_path: false export_path: false + - name: Setup QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + with: + image: tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 + platforms: arm64 + - name: Verify OCI build tools shell: bash run: | From 495625373e97aa433e634bde0e097c5bba173a48 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 17 Aug 2026 23:15:11 -0700 Subject: [PATCH 38/46] docs(release): pin validated OCI producer --- examples/go-release/.github/workflows/release.yml | 8 ++++---- examples/go-release/README.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/go-release/.github/workflows/release.yml b/examples/go-release/.github/workflows/release.yml index d07b48a..7af3085 100644 --- a/examples/go-release/.github/workflows/release.yml +++ b/examples/go-release/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: permissions: contents: read id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@f880a4cefdad74cf65d22688cd64e7238390f52d + uses: meigma/release/.github/workflows/go-pre-publish.yml@590457073d615c9063a06f8c34cee6ebbc87a936 oci-image: name: Build OCI image @@ -26,7 +26,7 @@ jobs: permissions: actions: read contents: read - uses: meigma/release/.github/workflows/go-oci-build.yml@f880a4cefdad74cf65d22688cd64e7238390f52d + uses: meigma/release/.github/workflows/go-oci-build.yml@590457073d615c9063a06f8c34cee6ebbc87a936 with: artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} @@ -42,11 +42,11 @@ jobs: attestations: write contents: read id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@f880a4cefdad74cf65d22688cd64e7238390f52d + uses: meigma/release/.github/workflows/publish-github-release.yml@590457073d615c9063a06f8c34cee6ebbc87a936 with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@f880a4cefdad74cf65d22688cd64e7238390f52d + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@590457073d615c9063a06f8c34cee6ebbc87a936 release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} publish-release: false secrets: diff --git a/examples/go-release/README.md b/examples/go-release/README.md index dba0a8d..c465492 100644 --- a/examples/go-release/README.md +++ b/examples/go-release/README.md @@ -42,8 +42,8 @@ Replace these project-specific example values: Keep these contract values unchanged: -- all three reusable workflow references at `f880a4cefdad74cf65d22688cd64e7238390f52d`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@f880a4cefdad74cf65d22688cd64e7238390f52d`; +- all three reusable workflow references at `590457073d615c9063a06f8c34cee6ebbc87a936`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@590457073d615c9063a06f8c34cee6ebbc87a936`; - organization variable `MEIGMA_RELEASE_APP_CLIENT_ID`; - organization secret `MEIGMA_RELEASE_APP_PRIVATE_KEY`; and - the locked Go 1.26.6, GoReleaser 2.17.1, Syft 1.51.0, Cosign 3.1.3, GitHub CLI 2.97.0, Melange 0.59.1, and apko 1.2.37 versions unless the shared workflow contract is deliberately updated. From 33fb9175eab068353cd436a0c2b6f0eda5f06525 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 08:36:01 -0700 Subject: [PATCH 39/46] feat(release): publish signed OCI images --- .github/workflows/publish-oci-image.yml | 274 ++++++++++++++++++++++++ .github/workflows/release.yml | 18 ++ 2 files changed, 292 insertions(+) create mode 100644 .github/workflows/publish-oci-image.yml diff --git a/.github/workflows/publish-oci-image.yml b/.github/workflows/publish-oci-image.yml new file mode 100644 index 0000000..c3b108b --- /dev/null +++ b/.github/workflows/publish-oci-image.yml @@ -0,0 +1,274 @@ +name: Reusable OCI Image Publisher + +on: + workflow_call: + inputs: + artifact-id: + description: ID of the authoritative OCI image artifact. + required: true + type: string + artifact-digest: + description: Expected SHA-256 digest of the authoritative OCI image artifact. + required: true + type: string + image-digest: + description: Expected SHA-256 digest of the OCI image index. + required: true + type: string + publish-image: + description: Publish, sign, and attest the image after verification. + required: false + default: false + type: boolean + outputs: + image-name: + description: Canonical GHCR image name. + value: ${{ jobs.publish.outputs.image-name }} + image-reference: + description: Published image reference pinned by digest; empty when publication is disabled. + value: ${{ jobs.publish.outputs.image-reference }} + image-digest: + description: Verified OCI image index digest. + value: ${{ jobs.publish.outputs.image-digest }} + provenance-attestation-url: + description: GitHub provenance attestation URL; empty when publication is disabled. + value: ${{ jobs.publish.outputs.provenance-attestation-url }} + amd64-sbom-attestation-url: + description: GitHub amd64 SBOM attestation URL; empty when publication is disabled. + value: ${{ jobs.publish.outputs.amd64-sbom-attestation-url }} + arm64-sbom-attestation-url: + description: GitHub arm64 SBOM attestation URL; empty when publication is disabled. + value: ${{ jobs.publish.outputs.arm64-sbom-attestation-url }} + +jobs: + publish: + name: Publish OCI image + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + packages: write + outputs: + image-name: ${{ steps.stage.outputs.image-name }} + image-reference: ${{ steps.push.outputs.reference }} + image-digest: ${{ steps.stage.outputs.image-digest }} + provenance-attestation-url: ${{ steps.provenance.outputs.attestation-url }} + amd64-sbom-attestation-url: ${{ steps.amd64-sbom.outputs.attestation-url }} + arm64-sbom-attestation-url: ${{ steps.arm64-sbom.outputs.attestation-url }} + steps: + - name: Require a stable release tag + shell: bash + run: | + set -euo pipefail + if [[ ! "${GITHUB_REF}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::OCI publication requires a stable vMAJOR.MINOR.PATCH tag; got ${GITHUB_REF}." + exit 1 + fi + + - name: Set up publisher tools + uses: jdx/mise-action@c320a4ae849d7c0fa92bb80e9b79e0456b4340b0 # v4.2.5 + with: + version: 2026.8.8 + mise_toml: | + [tools] + "aqua:oras-project/oras" = "1.3.3" + "aqua:sigstore/cosign" = "3.1.3" + install_args: aqua:oras-project/oras aqua:sigstore/cosign + cache: true + + - name: Verify authoritative OCI artifact + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ARTIFACT_ID: ${{ inputs.artifact-id }} + EXPECTED_DIGEST: ${{ inputs.artifact-digest }} + with: + retries: 3 + script: | + const artifactId = Number(process.env.ARTIFACT_ID) + if (!Number.isSafeInteger(artifactId) || artifactId <= 0) { + throw new Error(`Invalid artifact ID: ${process.env.ARTIFACT_ID}`) + } + + const {data: artifact} = await github.rest.actions.getArtifact({ + ...context.repo, + artifact_id: artifactId, + }) + if (artifact.expired) { + throw new Error(`Artifact ${artifactId} has expired.`) + } + if (artifact.workflow_run?.id !== Number(context.runId)) { + throw new Error( + `Artifact ${artifactId} belongs to workflow run ` + + `${artifact.workflow_run?.id}, expected ${context.runId}.`, + ) + } + const normalize = (digest) => String(digest ?? '') + .replace(/^sha256:/, '') + .toLowerCase() + const expected = normalize(process.env.EXPECTED_DIGEST) + const actual = normalize(artifact.digest) + if (!expected || actual !== expected) { + throw new Error( + `Artifact digest mismatch: expected ${process.env.EXPECTED_DIGEST}, got ${artifact.digest}.`, + ) + } + + - name: Download authoritative OCI image + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: oci-image + digest-mismatch: error + + - name: Verify OCI image contents + id: stage + env: + EXPECTED_IMAGE_DIGEST: ${{ inputs.image-digest }} + shell: bash + run: | + set -euo pipefail + + expected=${EXPECTED_IMAGE_DIGEST,,} + if [[ ! "${expected}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Invalid image digest: ${EXPECTED_IMAGE_DIGEST}" + exit 1 + fi + + test -f oci-image/layout/oci-layout + test -f oci-image/layout/index.json + test -f oci-image/image-digest.txt + test -f oci-image/sboms/sbom-x86_64.spdx.json + test -f oci-image/sboms/sbom-aarch64.spdx.json + + recorded=$(tr -d '\r\n' < oci-image/image-digest.txt) + computed=sha256:$(sha256sum oci-image/layout/index.json | cut -d' ' -f1) + if [[ "${recorded}" != "${expected}" || "${computed}" != "${expected}" ]]; then + echo "::error::OCI index digest mismatch: expected ${expected}, recorded ${recorded}, computed ${computed}." + exit 1 + fi + + jq -e ' + (.schemaVersion == 2) and + (.mediaType == "application/vnd.oci.image.index.v1+json") and + ((.manifests | length) == 2) and + (([.manifests[].platform.architecture] | sort) == ["amd64", "arm64"]) and + (all(.manifests[]; .platform.os == "linux")) + ' oci-image/layout/index.json >/dev/null + jq -e . oci-image/sboms/sbom-x86_64.spdx.json >/dev/null + jq -e . oci-image/sboms/sbom-aarch64.spdx.json >/dev/null + + amd64_digest=$(jq -er ' + .manifests[] | + select(.platform.os == "linux" and .platform.architecture == "amd64") | + .digest + ' oci-image/layout/index.json) + arm64_digest=$(jq -er ' + .manifests[] | + select(.platform.os == "linux" and .platform.architecture == "arm64") | + .digest + ' oci-image/layout/index.json) + for digest in "${amd64_digest}" "${arm64_digest}"; do + [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]] + test -f "oci-image/layout/blobs/sha256/${digest#sha256:}" + done + + version=${GITHUB_REF_NAME#v} + IFS=. read -r major minor patch <<< "${version}" + test -n "${major}" + test -n "${minor}" + test -n "${patch}" + image_name=ghcr.io/${GITHUB_REPOSITORY,,} + image_tags=${version},${major}.${minor},${major},latest + + { + echo "image-name=${image_name}" + echo "image-tags=${image_tags}" + echo "image-digest=${expected}" + echo "amd64-digest=${amd64_digest}" + echo "arm64-digest=${arm64_digest}" + } >> "${GITHUB_OUTPUT}" + + - name: Log in to GHCR + if: inputs.publish-image + env: + GHCR_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + printf '%s' "${GHCR_TOKEN}" | + oras login ghcr.io --username "${GITHUB_ACTOR}" --password-stdin + + - name: Publish OCI image + if: inputs.publish-image + id: push + env: + EXPECTED_DIGEST: ${{ steps.stage.outputs.image-digest }} + IMAGE_NAME: ${{ steps.stage.outputs.image-name }} + IMAGE_TAGS: ${{ steps.stage.outputs.image-tags }} + shell: bash + run: | + set -euo pipefail + oras cp \ + --from-oci-layout \ + --no-tty \ + "oci-image/layout@${EXPECTED_DIGEST}" \ + "${IMAGE_NAME}:${IMAGE_TAGS}" + + IFS=, read -ra tags <<< "${IMAGE_TAGS}" + for tag in "${tags[@]}"; do + actual=$(oras resolve "${IMAGE_NAME}:${tag}") + if [[ "${actual}" != "${EXPECTED_DIGEST}" ]]; then + echo "::error::Published ${IMAGE_NAME}:${tag} resolved to ${actual}; expected ${EXPECTED_DIGEST}." + exit 1 + fi + done + + { + echo "digest=${EXPECTED_DIGEST}" + echo "reference=${IMAGE_NAME}@${EXPECTED_DIGEST}" + } >> "${GITHUB_OUTPUT}" + + - name: Sign OCI image and platform manifests + if: inputs.publish-image + env: + IMAGE_REFERENCE: ${{ steps.push.outputs.reference }} + shell: bash + run: cosign sign --yes --recursive "${IMAGE_REFERENCE}" + + - name: Attest OCI image provenance + if: inputs.publish-image + id: provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ steps.stage.outputs.image-name }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + - name: Attest amd64 image SBOM + if: inputs.publish-image + id: amd64-sbom + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ steps.stage.outputs.image-name }} + subject-digest: ${{ steps.stage.outputs.amd64-digest }} + sbom-path: oci-image/sboms/sbom-x86_64.spdx.json + push-to-registry: true + + - name: Attest arm64 image SBOM + if: inputs.publish-image + id: arm64-sbom + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ steps.stage.outputs.image-name }} + subject-digest: ${{ steps.stage.outputs.arm64-digest }} + sbom-path: oci-image/sboms/sbom-aarch64.spdx.json + push-to-registry: true + + - name: Log out of GHCR + if: always() && inputs.publish-image + shell: bash + run: oras logout ghcr.io diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d990d62..e685082 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,23 @@ jobs: with: artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} + oci-publish: + name: Publish OCI image + needs: oci-image + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + packages: write + uses: ./.github/workflows/publish-oci-image.yml + with: + artifact-id: ${{ needs.oci-image.outputs.artifact-id }} + artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} + image-digest: ${{ needs.oci-image.outputs.image-digest }} + publish-image: true + github-release: @@ -36,6 +53,7 @@ jobs: needs: - release-assets - oci-image + - oci-publish permissions: actions: read artifact-metadata: write From 638079621a446f8c3d79686d3a9c8f47e9a5e853 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 09:08:16 -0700 Subject: [PATCH 40/46] fix(release): pin resolvable mise action --- .github/workflows/publish-oci-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-oci-image.yml b/.github/workflows/publish-oci-image.yml index c3b108b..0e70deb 100644 --- a/.github/workflows/publish-oci-image.yml +++ b/.github/workflows/publish-oci-image.yml @@ -70,7 +70,7 @@ jobs: fi - name: Set up publisher tools - uses: jdx/mise-action@c320a4ae849d7c0fa92bb80e9b79e0456b4340b0 # v4.2.5 + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 with: version: 2026.8.8 mise_toml: | From 72945990eda349f83c0f7628e85521fb30071fc6 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 09:12:44 -0700 Subject: [PATCH 41/46] fix(release): publish OCI layout manifests --- .github/workflows/publish-oci-image.yml | 26 ++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-oci-image.yml b/.github/workflows/publish-oci-image.yml index 0e70deb..91882b5 100644 --- a/.github/workflows/publish-oci-image.yml +++ b/.github/workflows/publish-oci-image.yml @@ -212,11 +212,27 @@ jobs: shell: bash run: | set -euo pipefail - oras cp \ - --from-oci-layout \ - --no-tty \ - "oci-image/layout@${EXPECTED_DIGEST}" \ - "${IMAGE_NAME}:${IMAGE_TAGS}" + + while IFS=$'\t' read -r manifest_digest media_type; do + manifest_path="oci-image/layout/blobs/sha256/${manifest_digest#sha256:}" + + while IFS= read -r blob_digest; do + oras blob push \ + --no-tty \ + "${IMAGE_NAME}@${blob_digest}" \ + "oci-image/layout/blobs/sha256/${blob_digest#sha256:}" + done < <(jq -r '.config.digest, .layers[].digest' "${manifest_path}") + + oras manifest push \ + --media-type "${media_type}" \ + "${IMAGE_NAME}@${manifest_digest}" \ + "${manifest_path}" + done < <(jq -r '.manifests[] | [.digest, .mediaType] | @tsv' oci-image/layout/index.json) + + oras manifest push \ + --media-type application/vnd.oci.image.index.v1+json \ + "${IMAGE_NAME}:${IMAGE_TAGS}" \ + oci-image/layout/index.json IFS=, read -ra tags <<< "${IMAGE_TAGS}" for tag in "${tags[@]}"; do From 09312cc934d445855dd39c5f73fe9e49ad724700 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 09:20:07 -0700 Subject: [PATCH 42/46] docs(release): document OCI image publication --- README.md | 8 +- docs/how-to/configure-github-releases.md | 21 +- docs/how-to/configure-oci-images.md | 242 ++++++++++++++++ .../rehearse-and-recover-github-releases.md | 18 +- .../upgrade-github-release-workflows.md | 36 ++- docs/reference/github-release-contract.md | 61 +++- docs/reference/oci-image-contract.md | 270 ++++++++++++++++++ .../go-release/.github/workflows/release.yml | 26 +- examples/go-release/README.md | 10 +- 9 files changed, 633 insertions(+), 59 deletions(-) create mode 100644 docs/how-to/configure-oci-images.md create mode 100644 docs/reference/oci-image-contract.md diff --git a/README.md b/README.md index 79acabd..a87e995 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Meigma release workflows -This repository defines the reusable workflows and repository contract that Meigma uses to build, sign, attest, and publish Go binaries through GitHub Releases. - -Current documented automation covers GitHub Releases only. The repository does not yet provide OCI publication, package-manager publication, native package repositories, or installer distribution. +This repository defines the reusable workflows and repository contract that Meigma uses to build, sign, attest, and publish Go binaries through GitHub Releases and multi-architecture OCI images through GHCR. ## Documentation - [Configure GitHub releases](docs/how-to/configure-github-releases.md) +- [Configure OCI image publication](docs/how-to/configure-oci-images.md) - [Rehearse and recover GitHub releases](docs/how-to/rehearse-and-recover-github-releases.md) - [Upgrade GitHub release workflows](docs/how-to/upgrade-github-release-workflows.md) - [GitHub release contract reference](docs/reference/github-release-contract.md) +- [OCI image contract reference](docs/reference/oci-image-contract.md) - [Copyable Go release example](examples/go-release/) -Consumer repositories call the reusable workflows at a full commit SHA. The documented revision is `5be87cc60f2f11ac11fe401d8129c7644edc17ca`. +Consumer repositories call the reusable workflows at a full commit SHA. The documented revision is `72945990eda349f83c0f7628e85521fb30071fc6`. diff --git a/docs/how-to/configure-github-releases.md b/docs/how-to/configure-github-releases.md index f4ab7bd..0ca05b2 100644 --- a/docs/how-to/configure-github-releases.md +++ b/docs/how-to/configure-github-releases.md @@ -66,6 +66,8 @@ mkdir -p "$CONSUMER/.github/workflows" cp examples/go-release/.github/workflows/release-please.yml "$CONSUMER/.github/workflows/" cp examples/go-release/.github/workflows/release.yml "$CONSUMER/.github/workflows/" cp examples/go-release/.goreleaser.yaml "$CONSUMER/" +cp examples/go-release/apko.yaml "$CONSUMER/" +cp examples/go-release/melange.yaml "$CONSUMER/" cp examples/go-release/.release-please-manifest.json "$CONSUMER/" cp examples/go-release/release-please-config.json "$CONSUMER/" cp examples/go-release/mise.toml "$CONSUMER/" @@ -94,19 +96,20 @@ In the copied files, replace the example values with values from the consumer re - In `release-please-config.json`, replace package name `example` and choose the intended first release in `initial-version`. - In `.release-please-manifest.json`, keep `0.0.0` only for a repository that has never released. For an existing project, set `.` to its latest released version without the `v` prefix. - In `.github/workflows/release-please.yml`, replace `main` if the consumer's default branch is different. +- In `melange.yaml` and `apko.yaml`, replace the package name, command path, description, license, source URL, and image annotations as described in [Configure OCI image publication](configure-oci-images.md). Do not replace these shared contract values: -- reusable workflow revision `5be87cc60f2f11ac11fe401d8129c7644edc17ca`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca`; +- reusable workflow revision `72945990eda349f83c0f7628e85521fb30071fc6`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6`; - variable name `MEIGMA_RELEASE_APP_CLIENT_ID`; or - secret name `MEIGMA_RELEASE_APP_PRIVATE_KEY`. -To change the immutable revision later, follow [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). Update both reusable workflow references and the checksum signing identity together; do not edit one reference in isolation. +To change the immutable revision later, follow [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). Update all reusable workflow references and the checksum signing identity together; do not edit one reference in isolation. The copied GoReleaser configuration builds Darwin, Linux, and Windows archives for amd64 and arm64. Confirm that the consumer command supports those targets before releasing it. -The copied release caller sets `publish-release: false`. Keep that value for a draft rehearsal. Before a public release, change it to `true` and merge the change before Release Please creates the tag. The [rehearsal and recovery guide](rehearse-and-recover-github-releases.md) gives the safer first-run sequence. +The copied release caller sets both `publish-image: false` and `publish-release: false`. Keep both values for the first rehearsal. Before a public release, change both to `true` and merge the change before Release Please creates the tag. The [rehearsal and recovery guide](rehearse-and-recover-github-releases.md) gives the safer first-run sequence. ## 5. Generate and validate the tool lock @@ -119,7 +122,7 @@ mise exec -- goreleaser check mise exec -- go list ./cmd/... ``` -`mise lock` must leave `mise.lock` with entries for the pinned Go, GoReleaser, Syft, Cosign, and GitHub CLI tools. `mise install --locked` must complete without changing a requested version, and `goreleaser check` must accept `.goreleaser.yaml`. Confirm that `go list` includes the command path configured in `.goreleaser.yaml`. +`mise lock` must leave `mise.lock` with entries for the pinned Go, GoReleaser, Syft, Cosign, GitHub CLI, Melange, and apko tools. `mise install --locked` must complete without changing a requested version, and `goreleaser check` must accept `.goreleaser.yaml`. Confirm that `go list` includes the command path configured in `.goreleaser.yaml`. Commit both `mise.toml` and the generated `mise.lock` with the other release files. Submit the change through the repository's normal pull request review and squash-merge process. @@ -134,7 +137,7 @@ Each command must print the corresponding workflow instead of reporting that the ## 6. Run Release Please -Before continuing with a public release, confirm that `.github/workflows/release.yml` on the default branch contains `publish-release: true`. Release Please also needs at least one releasable Conventional Commit after the version recorded in `.release-please-manifest.json`. Do not create an empty release commit to satisfy this condition. +Before continuing with a public release, confirm that `.github/workflows/release.yml` on the default branch contains both `publish-image: true` and `publish-release: true`. Release Please also needs at least one releasable Conventional Commit after the version recorded in `.release-please-manifest.json`. Do not create an empty release commit to satisfy this condition. When a releasable change is present, dispatch Release Please and inspect its run: @@ -176,7 +179,7 @@ gh run list --repo "$REPOSITORY" --workflow release-please.yml --limit 5 gh run list --repo "$REPOSITORY" --workflow release.yml --limit 5 ``` -With `publish-release: true`, the Release workflow builds one authoritative artifact, verifies its handoff and signed checksum manifest, uploads the closed asset set to the draft, creates GitHub attestations for the checksummed payloads, verifies GitHub's asset digests, and changes the draft to a published release. It does not create a second release. +With both publishers enabled, the Release workflow builds the authoritative archives and OCI image, verifies their handoffs, publishes and signs the GHCR image, creates image and release attestations, uploads the closed asset set to the draft, verifies GitHub's asset digests, and changes the draft to a published release. It does not create a second release. For an unmodified new example, the first tag is `v0.1.0`. For another repository, set `TAG` to the exact tag shown by the successful Release Please run: @@ -224,7 +227,7 @@ Verify that the checksum manifest was signed by the canonical reusable pre-publi ```bash mise exec -- cosign verify-blob \ --bundle checksums.txt.sigstore.json \ - --certificate-identity 'https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca' \ + --certificate-identity 'https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ checksums.txt ``` @@ -240,7 +243,7 @@ while IFS= read -r entry; do mise exec -- gh attestation verify "$asset" \ --repo "$REPOSITORY" \ --signer-workflow meigma/release/.github/workflows/publish-github-release.yml \ - --signer-digest 5be87cc60f2f11ac11fe401d8129c7644edc17ca \ + --signer-digest 72945990eda349f83c0f7628e85521fb30071fc6 \ --source-ref "refs/tags/$TAG" \ --deny-self-hosted-runners done < checksums.txt diff --git a/docs/how-to/configure-oci-images.md b/docs/how-to/configure-oci-images.md new file mode 100644 index 0000000..930dd3f --- /dev/null +++ b/docs/how-to/configure-oci-images.md @@ -0,0 +1,242 @@ +# Configure OCI image publication + +Use this guide to add signed, multi-architecture GHCR images to a repository that already uses the Meigma Go release workflows. The [OCI image contract](../reference/oci-image-contract.md) defines the reusable workflow interfaces, image contents, tags, signatures, attestations, and recovery behavior. + +The documented workflow revision is `72945990eda349f83c0f7628e85521fb30071fc6`. + +## Prerequisites + +Before changing the consumer repository, confirm that: + +- [Configure GitHub Releases](configure-github-releases.md) is complete; +- the Go command builds as a static Linux binary for both `amd64` and `arm64`; +- GitHub Actions policy permits the pinned Meigma workflows and actions; +- the repository's workflow token policy permits `packages: write`; +- GitHub Packages is enabled for the organization; +- `mise`, Git, GitHub CLI, ORAS, Cosign, and Docker are available for local verification; and +- GitHub CLI is authenticated with package read access. + +Record the consumer repository and immutable workflow revision: + +```bash +export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" +export RELEASE_REVISION=72945990eda349f83c0f7628e85521fb30071fc6 +export IMAGE="ghcr.io/${REPOSITORY,,}" +``` + +The publisher does not accept a custom image name. A repository named `OWNER/REPOSITORY` publishes `ghcr.io/owner/repository`. + +## 1. Copy the image configuration + +From a checkout of `meigma/release`, copy the example configuration into the consumer repository. Stop and merge by hand if either destination already exists. + +```bash +export CONSUMER=/absolute/path/to/consumer +test ! -e "$CONSUMER/melange.yaml" +test ! -e "$CONSUMER/apko.yaml" +cp examples/go-release/melange.yaml "$CONSUMER/" +cp examples/go-release/apko.yaml "$CONSUMER/" +``` + +The shared workflow uses Melange to package the canonical GoReleaser Linux binaries as signed APKs. apko composes those packages into one OCI index for `linux/amd64` and `linux/arm64`. It does not compile the command again. + +Ensure the consumer's `mise.toml` and `mise.lock` contain the Melange and apko versions required by the target workflow revision. The current example uses Melange `0.59.1` and apko `1.2.37`. + +## 2. Set project values + +Edit `melange.yaml`: + +- replace package name `example` with the command's binary name; +- replace the description; +- replace `LicenseRef-Proprietary` with the repository's SPDX license expression; and +- replace `/usr/bin/example` with the intended image command path. + +Keep these contract values: + +- `version: ${{vars.version}}`; +- target architectures `x86_64` and `aarch64`; +- the Wolfi repository and keyring; +- installation mode `0755`; and +- installation ownership `0:0`. + +Edit `apko.yaml`: + +- replace package name `example` with the Melange package name; +- replace `/usr/bin/example` with the installed command path; +- replace the title and description annotations; +- replace `https://github.com/OWNER/REPOSITORY` with the consumer repository URL; and +- replace `LicenseRef-Proprietary` with the repository's SPDX license expression. + +Keep these contract values: + +- the `nonroot` user and group at ID `65532`; +- `run-as: nonroot`; +- architectures `amd64` and `arm64`; +- the CA certificate package and `SSL_CERT_FILE`; and +- `/usr/bin` in `PATH`. + +Do not add a compiler or source build to either configuration. The authoritative executable comes from GoReleaser through the verified `oci-input` artifact. + +## 3. Add the builder and publisher jobs + +Use the complete caller in `examples/go-release/.github/workflows/release.yml` as the source. The image path consists of two jobs: + +1. `oci-image` calls `go-oci-build.yml` with the canonical Linux artifact ID and digest from `release-assets`. +2. `oci-publish` calls `publish-oci-image.yml` with the authoritative OCI artifact ID, artifact digest, and image index digest from `oci-image`. + +The publisher job must grant only these permissions: + +```yaml +permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + packages: write +``` + +Pin every reusable workflow to the same full revision: + +```text +72945990eda349f83c0f7628e85521fb30071fc6 +``` + +Make `github-release` depend on `oci-publish`. That ordering keeps the GitHub Release in draft state when registry publication, signing, or attestation fails. + +## 4. Rehearse without registry writes + +Keep both publication controls disabled for the first tag rehearsal: + +```yaml +publish-image: false +publish-release: false +``` + +The run still builds the APK repository and OCI index, verifies the canonical binaries and image metadata, and validates the publisher's artifact handoff. It does not log in to GHCR, create tags, sign an image, create OCI attestations, or publish the GitHub Release. + +Inspect the `oci-image` workflow artifact. It must contain: + +```text +apko-lock.json +apk-signing.rsa.pub +configuration/apko.yaml +configuration/melange.yaml +image-digest.txt +layout/index.json +layout/oci-layout +layout/blobs/sha256/* +packages/aarch64/* +packages/x86_64/* +sboms/sbom-aarch64.spdx.json +sboms/sbom-x86_64.spdx.json +``` + +Follow [Rehearse and recover GitHub Releases](rehearse-and-recover-github-releases.md) for the tag and draft procedure. + +## 5. Publish the image + +After the rehearsal passes, change both controls in the same reviewed commit: + +```yaml +publish-image: true +publish-release: true +``` + +Create the next stable `vMAJOR.MINOR.PATCH` release through Release Please. The image publisher rejects non-stable tags. + +A successful `v1.2.3` run publishes these tags, all resolving to the same index digest: + +```text +1.2.3 +1.2 +1 +latest +``` + +The exact version tag is immutable by release policy. The other three tags move on later stable releases. Consumers that require repeatable deployment must use `ghcr.io/owner/repository@sha256:...`, not a moving tag. + +Package visibility follows the organization's package-creation setting; it does not inherit repository visibility. After the first complete publication, inspect the package: + +```bash +gh api "orgs/${REPOSITORY%%/*}/packages/container/${REPOSITORY#*/}" --jq .visibility +``` + +The required delivery state is `public`. If the result is `private`, an organization owner must inspect the signed and attested image, then use the package settings page to change its visibility to **Public**. GitHub does not expose a supported Packages REST operation for this visibility change. Until it is public, anonymous pulls fail. + +## 6. Verify the published image + +Set the release tag and authenticate ORAS. Authentication is required while the package remains private. + +```bash +export TAG=v1.2.3 +gh auth token | oras login ghcr.io --username "$(gh api user --jq .login)" --password-stdin +export DIGEST="$(oras resolve "$IMAGE:${TAG#v}")" +test "$DIGEST" = "$(oras resolve "$IMAGE:latest")" +printf 'Image: %s@%s\n' "$IMAGE" "$DIGEST" +``` + +Verify the keyless Cosign signature against the reusable publisher identity: + +```bash +cosign verify \ + --certificate-identity "https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@$RELEASE_REVISION" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "$IMAGE@$DIGEST" +``` + +Verify the GitHub provenance attestation from both GitHub and the registry: + +```bash +gh attestation verify "oci://$IMAGE@$DIGEST" \ + --repo "$REPOSITORY" \ + --signer-workflow meigma/release/.github/workflows/publish-oci-image.yml \ + --signer-digest "$RELEASE_REVISION" \ + --source-ref "refs/tags/$TAG" \ + --deny-self-hosted-runners + +gh attestation verify "oci://$IMAGE@$DIGEST" \ + --repo "$REPOSITORY" \ + --bundle-from-oci \ + --signer-workflow meigma/release/.github/workflows/publish-oci-image.yml \ + --signer-digest "$RELEASE_REVISION" \ + --source-ref "refs/tags/$TAG" \ + --deny-self-hosted-runners +``` + +Verify each platform SBOM attestation: + +```bash +for ARCH in amd64 arm64; do + PLATFORM_DIGEST="$( + oras manifest fetch "$IMAGE@$DIGEST" | + jq -r --arg arch "$ARCH" \ + '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $arch) | .digest' + )" + gh attestation verify "oci://$IMAGE@$PLATFORM_DIGEST" \ + --repo "$REPOSITORY" \ + --bundle-from-oci \ + --predicate-type https://spdx.dev/Document/v2.3 \ + --signer-workflow meigma/release/.github/workflows/publish-oci-image.yml \ + --signer-digest "$RELEASE_REVISION" \ + --source-ref "refs/tags/$TAG" \ + --deny-self-hosted-runners +done +``` + +Finally, run both published platforms: + +```bash +docker run --rm --platform linux/amd64 "$IMAGE@$DIGEST" --version +docker run --rm --platform linux/arm64 "$IMAGE@$DIGEST" --version +``` + +Both commands must report the release version and commit. Running a non-native platform requires binfmt/QEMU support. + +## Recovery + +A failed publisher leaves the GitHub Release draft unpublished because `github-release` depends on `oci-publish`. + +Fix the source problem and rerun through the same unpublished tag procedure. Re-publishing the same OCI index is content-addressed: ORAS resolves every tag back to the expected digest before signing or attesting. A rerun after a partial success can add duplicate valid signatures or attestations; it cannot change the exact version's intended image without also changing the expected digest. + +Never move a tag after its GitHub Release is public. Never delete and recreate a public exact-version image tag to substitute different content. Publish a corrective release instead. diff --git a/docs/how-to/rehearse-and-recover-github-releases.md b/docs/how-to/rehearse-and-recover-github-releases.md index 9a29a7d..90345f2 100644 --- a/docs/how-to/rehearse-and-recover-github-releases.md +++ b/docs/how-to/rehearse-and-recover-github-releases.md @@ -24,23 +24,24 @@ export DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef --jq .defaultBranc ## 1. Configure a draft-only run -In `.github/workflows/release.yml`, set the publisher input to: +In `.github/workflows/release.yml`, disable both publishers: ```yaml +publish-image: false publish-release: false ``` -The copyable example already uses this value. Merge the change into the default branch before Release Please creates the candidate tag. The tag must contain the draft-only caller; changing an untagged branch after the tag exists does not change that run. +The copyable example already uses both values. Merge the change into the default branch before Release Please creates the candidate tag. The tag must contain the rehearsal caller; changing an untagged branch after the tag exists does not change that run. Confirm the value on the default branch: ```bash git fetch origin "$DEFAULT_BRANCH" git show "origin/$DEFAULT_BRANCH:.github/workflows/release.yml" | - grep 'publish-release: false' + grep -E 'publish-(image|release): false' ``` -The command must print the draft-only input. Do not start the release if it prints nothing. +The command must print both disabled inputs. Do not start the release if either is missing. ## 2. Create the candidate tag and draft @@ -101,7 +102,7 @@ gh run watch "$RELEASE_RUN_ID" \ --exit-status ``` -At the documented current revision, a successful draft-only run leaves the Release workflow green and the release unpublished with six platform archives, six archive SBOMs, `checksums.txt`, and `checksums.txt.sigstore.json`. When rehearsing another revision, use the target asset contract in [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). +At the documented current revision, a successful draft-only run leaves the Release workflow green, the release unpublished with six platform archives, six archive SBOMs, `checksums.txt`, and `checksums.txt.sigstore.json`, and GHCR unchanged. The run also retains the verified multi-architecture layout, signed APK repository, apko lock, and image SBOMs in the `oci-image` workflow artifact. When rehearsing another revision, use the target contracts in [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). ## 3. Inspect the populated draft @@ -126,9 +127,10 @@ Before resuming, inspect the Release workflow log and the draft asset list. Do n ## 4. Resume through the same tag and draft -Change `.github/workflows/release.yml` to: +Change both publication controls in `.github/workflows/release.yml`: ```yaml +publish-image: true publish-release: true ``` @@ -140,7 +142,7 @@ export RECOVERY_SHA="$(git rev-parse "origin/$DEFAULT_BRANCH")" printf 'Recovery commit: %s\n' "$RECOVERY_SHA" ``` -The `push`-on-tag caller has no manual dispatch input. A rerun of the original Actions run would use the original tagged workflow with `publish-release: false`. To exercise the updated caller, move the same rehearsal tag to the recovery commit and push that tag update: +The `push`-on-tag caller has no manual dispatch input. A rerun of the original Actions run would use the original tagged workflow with both publishers disabled. To exercise the updated caller, move the same rehearsal tag to the recovery commit and push that tag update: ```bash git tag --force "$TAG" "$RECOVERY_SHA" @@ -285,7 +287,7 @@ If the repository does not need a correction, rerun the complete top-level workf For the documented current revision, the publisher requires a nonempty `checksums.txt`, the exact closed payload list, matching payload hashes, a regular Cosign bundle file, issuer `https://token.actions.githubusercontent.com`, and this certificate identity: ```text -https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca +https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 ``` For an upgrade rehearsal, replace the current-revision identity with the target value described in [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). diff --git a/docs/how-to/upgrade-github-release-workflows.md b/docs/how-to/upgrade-github-release-workflows.md index 2bd890a..2a70859 100644 --- a/docs/how-to/upgrade-github-release-workflows.md +++ b/docs/how-to/upgrade-github-release-workflows.md @@ -1,6 +1,6 @@ # Upgrade GitHub Release workflows -Use this guide to move a consumer repository from the current workflow revision, `5be87cc60f2f11ac11fe401d8129c7644edc17ca`, to a reviewed immutable revision. Do not use a branch or tag as a reusable workflow reference. The [GitHub Release contract](../reference/github-release-contract.md) defines the current interface and asset boundary. +Use this guide to move a consumer repository from the current workflow revision, `72945990eda349f83c0f7628e85521fb30071fc6`, to a reviewed immutable revision. Do not use a branch or tag as a reusable workflow reference. The [GitHub Release contract](../reference/github-release-contract.md) and [OCI image contract](../reference/oci-image-contract.md) define the current interfaces and publication boundaries. ## Prerequisites @@ -16,7 +16,7 @@ Record the consumer, the current baseline, and a local checkout of `meigma/relea ```bash export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -export CURRENT_RELEASE_REVISION=5be87cc60f2f11ac11fe401d8129c7644edc17ca +export CURRENT_RELEASE_REVISION=72945990eda349f83c0f7628e85521fb30071fc6 read -r -p 'Reviewed full meigma/release commit SHA: ' NEW_RELEASE_REVISION export NEW_RELEASE_REVISION read -r -p 'Path to the meigma/release checkout: ' RELEASE_CHECKOUT @@ -48,18 +48,23 @@ git -C "$RELEASE_CHECKOUT" diff \ "$NEW_RELEASE_REVISION^{commit}" \ -- \ .github/workflows/go-pre-publish.yml \ + .github/workflows/go-oci-build.yml \ .github/workflows/publish-github-release.yml \ + .github/workflows/publish-oci-image.yml \ .github/workflows/release.yml \ .github/workflows/release-please.yml \ docs/reference/github-release-contract.md \ + docs/reference/oci-image-contract.md \ examples/go-release ``` -Read the complete target contract after reviewing the diff: +Read both complete target contracts after reviewing the diff: ```bash git -C "$RELEASE_CHECKOUT" show \ "$NEW_RELEASE_REVISION:docs/reference/github-release-contract.md" +git -C "$RELEASE_CHECKOUT" show \ + "$NEW_RELEASE_REVISION:docs/reference/oci-image-contract.md" ``` Before adoption, identify changes to: @@ -70,19 +75,21 @@ Before adoption, identify changes to: - consumer source and GoReleaser configuration requirements; - GitHub App credentials, tag rules, or other external prerequisites; - runner and mise requirements; and -- required Go, GoReleaser, Syft, Cosign, or GitHub CLI versions. +- required Go, GoReleaser, Syft, Cosign, GitHub CLI, Melange, apko, or ORAS versions. Stop if the target revision removes a required consumer capability or if any migration or rollback step is unresolved. Do not infer compatibility from an unchanged workflow filename. ## 2. Apply the target contract atomically -In `.github/workflows/release.yml`, replace the current revision with `NEW_RELEASE_REVISION` in all three locations: +In `.github/workflows/release.yml`, replace the current revision with `NEW_RELEASE_REVISION` in all five locations: 1. `uses: meigma/release/.github/workflows/go-pre-publish.yml@...` -2. `uses: meigma/release/.github/workflows/publish-github-release.yml@...` -3. `checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@...` +2. `uses: meigma/release/.github/workflows/go-oci-build.yml@...` +3. `uses: meigma/release/.github/workflows/publish-oci-image.yml@...` +4. `uses: meigma/release/.github/workflows/publish-github-release.yml@...` +5. `checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@...` -Keep `publish-release: false` for the upgrade rehearsal. The two reusable workflow references and the checksum signing identity must change in the same pull request and commit. A mixed revision fails the signing boundary or runs producer and publisher contracts that were not reviewed together. +Keep both `publish-image: false` and `publish-release: false` for the upgrade rehearsal. All four reusable workflow references and the checksum signing identity must change in the same pull request and commit. A mixed revision fails a signing boundary or runs contracts that were not reviewed together. Apply every other target-contract change in that same upgrade: @@ -96,12 +103,13 @@ External prerequisites cannot be committed atomically with repository files. Ass Check the edited caller: ```bash -test "$(grep -F -c "$NEW_RELEASE_REVISION" .github/workflows/release.yml)" -eq 3 +test "$(grep -F -c "$NEW_RELEASE_REVISION" .github/workflows/release.yml)" -eq 5 ! grep -F -q "$CURRENT_RELEASE_REVISION" .github/workflows/release.yml +grep -F 'publish-image: false' .github/workflows/release.yml grep -F 'publish-release: false' .github/workflows/release.yml ``` -All three commands must succeed: the caller must contain three target references, no baseline reference, and the draft-only input. +All four commands must succeed: the caller must contain five target references, no baseline reference, and both disabled publication controls. Write a migration and rollback checklist in the upgrade pull request. It must record: @@ -150,7 +158,7 @@ Review the final diff and confirm that it contains no moving reusable workflow r ## 5. Rehearse the target revision -After the upgrade reaches the default branch, perform the draft-only procedure in [Rehearse and recover GitHub Releases](rehearse-and-recover-github-releases.md). Do not change `publish-release` to `true` until the target revision has populated and verified a draft. +After the upgrade reaches the default branch, perform the draft-only procedure in [Rehearse and recover GitHub Releases](rehearse-and-recover-github-releases.md). Do not enable either publisher until the target revision has populated and verified a draft and produced the expected OCI artifact. Use the linked guide for its release creation, exact run selection, draft lookup, resumption, and recovery mechanics. During an upgrade rehearsal, the target contract overrides every baseline constant in that guide. In particular, require the target asset names and count, the checksum certificate identity ending in `@$NEW_RELEASE_REVISION`, and the publisher `--signer-digest "$NEW_RELEASE_REVISION"`. Do not reject a target-compliant draft because it differs from the baseline fourteen-asset set or baseline revision. @@ -225,7 +233,7 @@ done < checksums.txt Every invocation must exit successfully. `--signer-digest` binds the reusable publisher to the reviewed target commit; the source-ref constraint binds the attestation to the consumer's candidate tag. -After these checks pass, change `publish-release` to `true` and resume through the same tag and populated draft as described in the rehearsal guide. The resume run must use `NEW_RELEASE_REVISION` in all three caller locations. +After these checks pass, change both `publish-image` and `publish-release` to `true` and resume through the same tag and populated draft as described in the rehearsal guide. The resume run must use `NEW_RELEASE_REVISION` in all five caller locations. ## Roll back before publication @@ -233,9 +241,9 @@ If no candidate tag exists, reverse every repository and external-prerequisite c If the target revision has populated an unpublished draft: -1. Reverse every repository change in the migration checklist in one rollback commit, including both `uses:` entries, `checksum-signing-workflow-ref`, caller permissions and interfaces, source and asset configuration, and tool pins and lock entries. +1. Reverse every repository change in the migration checklist in one rollback commit, including all four `uses:` entries, `checksum-signing-workflow-ref`, caller permissions and interfaces, source and asset configuration, and tool pins and lock entries. 2. Restore every changed external prerequisite to the prior state recorded in the checklist. Sequence those changes so the rollback workflow remains operable, and record each observable restored state. -3. Keep `publish-release: false`. +3. Keep both `publish-image: false` and `publish-release: false`. 4. Run the locked install, GoReleaser check, actionlint, and repository checks against the complete rollback. 5. Move the same unpublished tag to the rollback commit and trigger a new top-level Release run, following the recovery procedure. 6. Verify the restored asset contract, Cosign identity, and GitHub signer digest before enabling publication. diff --git a/docs/reference/github-release-contract.md b/docs/reference/github-release-contract.md index 347001b..449deb5 100644 --- a/docs/reference/github-release-contract.md +++ b/docs/reference/github-release-contract.md @@ -1,30 +1,30 @@ # GitHub release contract reference -This page defines the cross-repository contract for the reusable Go producer and GitHub Release publisher at revision `5be87cc60f2f11ac11fe401d8129c7644edc17ca`. +This page defines the cross-repository contract for the reusable Go producer and GitHub Release publisher at revision `72945990eda349f83c0f7628e85521fb30071fc6`. -For configuration steps, see [Configure GitHub releases](../how-to/configure-github-releases.md). For draft rehearsals and recovery steps, see [Rehearse and recover GitHub releases](../how-to/rehearse-and-recover-github-releases.md). To adopt another immutable revision, see [Upgrade GitHub release workflows](../how-to/upgrade-github-release-workflows.md). A complete consumer repository is available in the [Go release example](../../examples/go-release/). +For configuration steps, see [Configure GitHub releases](../how-to/configure-github-releases.md). For draft rehearsals and recovery steps, see [Rehearse and recover GitHub releases](../how-to/rehearse-and-recover-github-releases.md). The [OCI image contract](oci-image-contract.md) defines the image builder and publisher that gate the complete delivery caller. To adopt another immutable revision, see [Upgrade GitHub release workflows](../how-to/upgrade-github-release-workflows.md). A complete consumer repository is available in the [Go release example](../../examples/go-release/). ## Canonical workflow references -Consumer repositories must pin both reusable workflows to the full revision: +The complete caller pins all four reusable workflows to one full revision. The GitHub Release path directly calls the producer and GitHub publisher: ```yaml -uses: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca +uses: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 ``` ```yaml -uses: meigma/release/.github/workflows/publish-github-release.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca +uses: meigma/release/.github/workflows/publish-github-release.yml@72945990eda349f83c0f7628e85521fb30071fc6 ``` The checksum signer identity input must name the same producer workflow revision: ```yaml -checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca +checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 ``` ## Caller contract -The supported caller runs on creation or movement of a `v*` tag. Both reusable workflows reject a non-tag ref. Tag deletion events must not start the producer job. +The supported caller runs on creation or movement of a `v*` tag. Every reusable workflow rejects a non-tag ref. Tag deletion events must not start the producer job. The GitHub Release publisher waits for successful image build and publication so a registry failure leaves the release draft unpublished. ```yaml name: Release @@ -47,22 +47,53 @@ jobs: permissions: contents: read id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + uses: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 + + oci-image: + name: Build OCI image + needs: release-assets + permissions: + actions: read + contents: read + uses: meigma/release/.github/workflows/go-oci-build.yml@72945990eda349f83c0f7628e85521fb30071fc6 + with: + artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} + + oci-publish: + name: Publish OCI image + needs: oci-image + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + packages: write + uses: meigma/release/.github/workflows/publish-oci-image.yml@72945990eda349f83c0f7628e85521fb30071fc6 + with: + artifact-id: ${{ needs.oci-image.outputs.artifact-id }} + artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} + image-digest: ${{ needs.oci-image.outputs.image-digest }} + publish-image: true github-release: name: Publish GitHub Release - needs: release-assets + needs: + - release-assets + - oci-image + - oci-publish permissions: actions: read artifact-metadata: write attestations: write contents: read id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + uses: meigma/release/.github/workflows/publish-github-release.yml@72945990eda349f83c0f7628e85521fb30071fc6 with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} publish-release: true secrets: @@ -261,14 +292,14 @@ The checksum signature is accepted only when Cosign verifies all of the followin | Field | Required value | | --- | --- | -| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca` | +| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6` | | Certificate OIDC issuer | `https://token.actions.githubusercontent.com` | | Signed blob | `checksums.txt` | | Bundle | `checksums.txt.sigstore.json` | The exact identity comes from `checksum-signing-workflow-ref`; a branch name, tag name, different commit, or different workflow path does not satisfy the documented identity. -The publisher at `meigma/release/.github/workflows/publish-github-release.yml@5be87cc60f2f11ac11fe401d8129c7644edc17ca` creates GitHub build-provenance attestations in the consumer repository. Its job token creates attestations but has only `contents: read`. A short-lived Release App installation token with `contents: write` performs draft discovery, asset upload, and the final draft-state change. +The publisher at `meigma/release/.github/workflows/publish-github-release.yml@72945990eda349f83c0f7628e85521fb30071fc6` creates GitHub build-provenance attestations in the consumer repository. Its job token creates attestations but has only `contents: read`. A short-lived Release App installation token with `contents: write` performs draft discovery, asset upload, and the final draft-state change. ## Publication states @@ -295,7 +326,7 @@ A failed run does not roll back uploaded assets or delete the draft. Recovery is - After upload, the workflow verifies the complete name set and every GitHub-computed SHA-256 digest before it can publish. - The tag must still resolve to `github.sha`, and the release for that tag must still be a draft. -A draft rehearsal sets `publish-release: false`. To resume, the caller changes the input to `true`, commits that change, and uses authorized movement of the same unpublished tag name to trigger a new run against the existing populated draft; it does not delete and recreate the draft. The workflow replaces expected assets only after the new artifact, checksums, and Cosign bundle pass validation. Any source, workflow configuration, or tool-pin correction follows the same commit and tag-movement requirement. If the unpublished tag cannot be moved safely, the incomplete candidate must be abandoned and a new candidate cut. A plain Actions rerun is reserved for failures that require no repository-content change, such as artifact expiry or a transient service failure. See the [rehearsal and recovery guide](../how-to/rehearse-and-recover-github-releases.md) for the procedure. +A complete draft rehearsal sets both `publish-image: false` and `publish-release: false`. To resume, the caller changes both inputs to `true`, commits that change, and uses authorized movement of the same unpublished tag name to trigger a new run against the existing populated draft; it does not delete and recreate the draft. The workflow replaces expected assets only after the new artifact, checksums, and Cosign bundle pass validation. Any source, workflow configuration, or tool-pin correction follows the same commit and tag-movement requirement. If the unpublished tag cannot be moved safely, the incomplete candidate must be abandoned and a new candidate cut. A plain Actions rerun is reserved for failures that require no repository-content change, such as artifact expiry or a transient service failure. If the final API call has already changed the release to non-draft before a later failure, the workflow provides no rollback. A subsequent run rejects that published release because the draft invariant no longer holds. @@ -303,7 +334,7 @@ If the final API call has already changed the release to non-draft before a late This contract does not provide or imply: -- OCI image construction or registry publication. +- OCI construction or publication behavior beyond the dependency ordering defined here; see the separate [OCI image contract](oci-image-contract.md). - Homebrew, MacPorts, Nix, Scoop, mise registry, or other package-manager publication. - DEB, RPM, APK, package-repository, or installer publication. - Release support for languages other than the documented Go producer profile. diff --git a/docs/reference/oci-image-contract.md b/docs/reference/oci-image-contract.md new file mode 100644 index 0000000..c66b095 --- /dev/null +++ b/docs/reference/oci-image-contract.md @@ -0,0 +1,270 @@ +# OCI image contract + +This page defines the cross-repository contract for the reusable Go OCI builder and GHCR publisher at revision `72945990eda349f83c0f7628e85521fb30071fc6`. + +For adoption steps, see [Configure OCI image publication](../how-to/configure-oci-images.md). The [GitHub Release contract](github-release-contract.md) defines the upstream GoReleaser producer and GitHub Release publisher. A complete consumer is available in the [Go release example](../../examples/go-release/). + +## Pipeline boundary + +```text +GoReleaser producer + -> canonical linux/amd64 and linux/arm64 binaries + -> verified oci-input artifact + -> Melange signed APK repository + -> locked apko multi-architecture OCI layout + -> verified oci-image artifact + -> ORAS GHCR publication + -> recursive keyless Cosign signatures + -> GitHub and registry provenance/SBOM attestations + -> public GitHub Release +``` + +The image builder consumes prebuilt GoReleaser binaries. Melange packages them without compiling, stripping, or otherwise replacing them. The publisher consumes the builder's OCI layout and does not check out or execute consumer repository code. + +## Reusable workflows + +Consumers call both workflows at the same immutable revision: + +```yaml +uses: meigma/release/.github/workflows/go-oci-build.yml@72945990eda349f83c0f7628e85521fb30071fc6 +``` + +```yaml +uses: meigma/release/.github/workflows/publish-oci-image.yml@72945990eda349f83c0f7628e85521fb30071fc6 +``` + +Moving branches and tags are not supported workflow references. + +## Builder interface + +`go-oci-build.yml` accepts these inputs: + +| Input | Required | Default | Meaning | +| --- | --- | --- | --- | +| `artifact-id` | yes | none | Numeric ID of the canonical Linux binary artifact from `go-pre-publish.yml`. | +| `artifact-digest` | yes | none | GitHub artifact SHA-256 digest for that artifact. | +| `melange-config` | no | `melange.yaml` | Consumer-relative Melange configuration path. | +| `apko-config` | no | `apko.yaml` | Consumer-relative apko configuration path. | + +It returns: + +| Output | Meaning | +| --- | --- | +| `artifact-id` | Numeric ID of the authoritative `oci-image` artifact. | +| `artifact-url` | GitHub URL for the authoritative artifact. | +| `artifact-digest` | GitHub artifact SHA-256 digest. This covers the uploaded ZIP transport, not the OCI index. | +| `image-digest` | `sha256:` digest of the exact bytes in `layout/index.json`. | + +The caller grants `actions: read` and `contents: read`. The builder has no registry credentials, package write permission, attestation permission, or release credential. + +## Publisher interface + +`publish-oci-image.yml` accepts these inputs: + +| Input | Required | Default | Meaning | +| --- | --- | --- | --- | +| `artifact-id` | yes | none | Numeric ID of the `oci-image` artifact from `go-oci-build.yml`. | +| `artifact-digest` | yes | none | Expected GitHub artifact SHA-256 digest. | +| `image-digest` | yes | none | Expected OCI index digest. | +| `publish-image` | no | `false` | When `true`, push, sign, and attest the verified image. When `false`, stop after verification. | + +It returns: + +| Output | Meaning | +| --- | --- | +| `image-name` | Canonical `ghcr.io/owner/repository` name. | +| `image-reference` | Digest-pinned published reference, or empty when publication is disabled. | +| `image-digest` | Verified OCI index digest, regardless of publication mode. | +| `provenance-attestation-url` | GitHub provenance attestation URL, or empty when publication is disabled. | +| `amd64-sbom-attestation-url` | GitHub amd64 SBOM attestation URL, or empty when publication is disabled. | +| `arm64-sbom-attestation-url` | GitHub arm64 SBOM attestation URL, or empty when publication is disabled. | + +The caller grants: + +```yaml +permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + packages: write +``` + +`packages: write` authenticates ORAS, Cosign, and registry-backed GitHub attestations to GHCR. `id-token: write` supplies short-lived Sigstore identity. The publisher receives no GitHub App key and cannot mutate repository contents or releases. + +## Consumer configuration + +### GoReleaser + +The upstream producer must emit exactly one canonical Linux binary for each pair: + +| GOOS | GOARCH | GOAMD64 | +| --- | --- | --- | +| `linux` | `amd64` | `v1` | +| `linux` | `arm64` | unset | + +The binaries must be static and executable. The OCI builder rejects a missing target, duplicate target, wrong architecture, symlink, non-executable file, or checksum mismatch. + +### Melange + +`melange.yaml` must: + +- declare `x86_64` and `aarch64` target architectures; +- use `${{vars.version}}` as the package version; +- install the staged file named `application` as the intended command; +- preserve mode `0755` and ownership `0:0`; and +- name the package consumed by `apko.yaml`. + +The workflow injects the stable tag version and writes an ephemeral APK signing key. It retains the public key, signed APKs, signed repository indexes, package SBOMs, and Melange provenance in the workflow artifact. The private signing key is never uploaded. + +### apko + +`apko.yaml` must: + +- consume the Melange package; +- define `amd64` and `arm64` only; +- set exactly one executable entrypoint; +- run as numeric user and group `65532` through the `nonroot` account; +- include source, title, description, and SPDX license annotations; and +- include the runtime files the command requires. + +The current example includes Alpine's CA certificate bundle. A command that does not make TLS connections may deliberately omit it after testing; a command that needs other runtime data must declare the corresponding package explicitly. + +## Authoritative artifact + +The builder uploads `oci-image` with seven-day retention and no additional ZIP compression. Its contract includes: + +```text +apko-lock.json +apk-signing.rsa.pub +configuration/apko.yaml +configuration/melange.yaml +image-digest.txt +layout/index.json +layout/oci-layout +layout/blobs/sha256/* +packages/aarch64/* +packages/x86_64/* +sboms/sbom-aarch64.spdx.json +sboms/sbom-x86_64.spdx.json +``` + +The package directories also contain signed APK repository indexes, Melange provenance, embedded package SBOMs, and the signed APKs. Files not listed above may be diagnostic outputs from the pinned tools; consumers must not infer a stable API from undocumented filenames. + +The publisher verifies all three handoff coordinates before a registry write: + +1. artifact ID belongs to the current workflow run; +2. GitHub artifact digest equals the caller-supplied digest; and +3. recorded, recomputed, and caller-supplied OCI index digests are identical. + +It also requires one Linux manifest for `amd64`, one for `arm64`, both referenced blobs, and parseable SPDX JSON for each architecture. + +## Published image + +### Name + +The image name is derived from the caller repository: + +```text +ghcr.io// +``` + +Custom registry hosts, namespaces, and image names are outside the current contract. + +### Tags + +A stable release tag `vMAJOR.MINOR.PATCH` publishes: + +| Image tag | Behavior | +| --- | --- | +| `MAJOR.MINOR.PATCH` | Exact release version. Must never be reassigned to different content. | +| `MAJOR.MINOR` | Moves to the latest stable patch in that minor line. | +| `MAJOR` | Moves to the latest stable release in that major line. | +| `latest` | Moves to the latest stable release. | + +All four tags must resolve to the builder's expected OCI index digest immediately after publication. The publisher rejects prerelease, build-metadata, malformed, branch, and untagged refs. + +Digest-pinned references are the durable consumer interface: + +```text +ghcr.io/owner/repository@sha256: +``` + +### Runtime invariants + +For both platforms, the builder verifies: + +- Linux operating system and expected architecture; +- one apko index containing exactly two platform manifests; +- package and image executable bytes equal the canonical GoReleaser binary; +- executable ownership `0:0` inside the image layer; +- executable mode `0755` from package configuration; +- configured entrypoint; +- runtime user `65532`; +- source, version, revision, title, description, and license annotations; and +- SPDX SBOM inclusion of the application package version. + +## Signatures and attestations + +The publisher signs the index and both platform manifests recursively with Cosign keyless signing. Verification must require: + +| Field | Value | +| --- | --- | +| Certificate identity | `https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@72945990eda349f83c0f7628e85521fb30071fc6` | +| Certificate OIDC issuer | `https://token.actions.githubusercontent.com` | +| Subject | Digest-pinned image or platform manifest. | + +The publisher also creates: + +- one SLSA provenance attestation for the multi-architecture index; +- one SPDX SBOM attestation for the amd64 platform manifest; and +- one SPDX SBOM attestation for the arm64 platform manifest. + +Each attestation is written to the consumer repository's GitHub attestation store and pushed to GHCR as an OCI referrer. Verification should constrain the reusable signer workflow, signer revision, consumer source tag, and GitHub-hosted runner as shown in the configuration guide. + +Cosign signatures and GitHub attestations are distinct. A passing check for one does not prove the other exists. + +## Publication states + +| State | Registry effect | Expected next action | +| --- | --- | --- | +| `publish-image: false` | None. Artifact and index verification only. | Inspect the rehearsal and enable publication in a reviewed commit. | +| Push incomplete | Some blobs or tags may exist. GitHub Release remains draft. | Fix the cause and rerun the same unpublished tag. | +| Push complete, signing incomplete | Tags resolve correctly but trust metadata is incomplete. GitHub Release remains draft. | Rerun; do not advertise or make the package public. | +| Signed, attestation incomplete | Image is signed but does not satisfy the complete contract. GitHub Release remains draft. | Rerun and verify every attestation. | +| Complete, package private | Authenticated pulls work. | Inspect, then perform the one-time public visibility change. | +| Complete, package public | Anonymous digest and tag pulls work. | Monitor and publish only additive corrective releases. | + +A rerun is content-addressed and rechecks every published tag. It may add duplicate valid signatures or attestations after a partial success. It must not publish the GitHub Release until the image publisher job succeeds. + +## GHCR visibility + +GHCR visibility is independent of source repository visibility. The organization's package-creation setting determines the initial visibility. The workflow links the package to its source repository through `GITHUB_TOKEN` publication and the `org.opencontainers.image.source` annotation; inherited repository access permissions do not make the package public. + +The completed delivery state is public. Inspect `visibility` through the Packages REST API after the first complete publication. If it is private, an organization owner must inspect the signed and attested image, then make the package public through its settings page. GitHub does not expose a supported Packages REST operation for this visibility change. + +## Security boundary + +The current boundary is deliberately split: + +- `go-pre-publish.yml` compiles and signs release inputs without release or package write access; +- `go-oci-build.yml` packages and composes the image without registry credentials; +- `publish-oci-image.yml` does not check out consumer source and writes only to the caller's GHCR package and attestation store; and +- `publish-github-release.yml` waits for image publication but uses a separate short-lived Release App token for release mutation. + +The workflow artifact is temporary transport, not a public distribution channel. The OCI digest, registry content, Cosign identity, and attestation identities form the public verification boundary. + +## Unsupported cases + +The current contract does not support: + +- CGO-dependent or dynamically linked commands; +- architectures other than Linux amd64 and arm64; +- prerelease tags; +- multiple commands or entrypoints in one image; +- custom registries or image names; +- private package visibility automation; +- long-lived registry credentials; +- mutable exact-version tags; or +- publication from branch or manual-dispatch refs. diff --git a/examples/go-release/.github/workflows/release.yml b/examples/go-release/.github/workflows/release.yml index 7af3085..96fef52 100644 --- a/examples/go-release/.github/workflows/release.yml +++ b/examples/go-release/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: permissions: contents: read id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@590457073d615c9063a06f8c34cee6ebbc87a936 + uses: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 oci-image: name: Build OCI image @@ -26,27 +26,45 @@ jobs: permissions: actions: read contents: read - uses: meigma/release/.github/workflows/go-oci-build.yml@590457073d615c9063a06f8c34cee6ebbc87a936 + uses: meigma/release/.github/workflows/go-oci-build.yml@72945990eda349f83c0f7628e85521fb30071fc6 with: artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} + oci-publish: + name: Publish OCI image + needs: oci-image + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: read + id-token: write + packages: write + uses: meigma/release/.github/workflows/publish-oci-image.yml@72945990eda349f83c0f7628e85521fb30071fc6 + with: + artifact-id: ${{ needs.oci-image.outputs.artifact-id }} + artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} + image-digest: ${{ needs.oci-image.outputs.image-digest }} + publish-image: false + github-release: name: Publish GitHub Release needs: - release-assets - oci-image + - oci-publish permissions: actions: read artifact-metadata: write attestations: write contents: read id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@590457073d615c9063a06f8c34cee6ebbc87a936 + uses: meigma/release/.github/workflows/publish-github-release.yml@72945990eda349f83c0f7628e85521fb30071fc6 with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@590457073d615c9063a06f8c34cee6ebbc87a936 + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} publish-release: false secrets: diff --git a/examples/go-release/README.md b/examples/go-release/README.md index c465492..815ba78 100644 --- a/examples/go-release/README.md +++ b/examples/go-release/README.md @@ -2,7 +2,7 @@ This directory models a repository named `example` with module `example.com/meigma/release-consumer` and command `./cmd/example`. It contains the minimum source needed to build GitHub Release assets and a multi-architecture OCI image. It is not a complete CI policy: add the consumer repository's own build, test, review, and branch-protection controls. -See [Configure GitHub Releases](../../docs/how-to/configure-github-releases.md) for credential setup, adoption, and verification. See [Rehearse and recover GitHub Releases](../../docs/how-to/rehearse-and-recover-github-releases.md) before the first publication. Use [Upgrade GitHub Release workflows](../../docs/how-to/upgrade-github-release-workflows.md) to change the pinned revision. The reusable workflow interface is defined in the [GitHub Release contract](../../docs/reference/github-release-contract.md). +See [Configure GitHub Releases](../../docs/how-to/configure-github-releases.md) for release credential setup and [Configure OCI image publication](../../docs/how-to/configure-oci-images.md) for image configuration, publication, and verification. See [Rehearse and recover GitHub Releases](../../docs/how-to/rehearse-and-recover-github-releases.md) before the first publication. Use [Upgrade GitHub Release workflows](../../docs/how-to/upgrade-github-release-workflows.md) to change the pinned revision. The reusable interfaces are defined in the [GitHub Release contract](../../docs/reference/github-release-contract.md) and [OCI image contract](../../docs/reference/oci-image-contract.md). ## Files to copy @@ -42,12 +42,12 @@ Replace these project-specific example values: Keep these contract values unchanged: -- all three reusable workflow references at `590457073d615c9063a06f8c34cee6ebbc87a936`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@590457073d615c9063a06f8c34cee6ebbc87a936`; +- all four reusable workflow references at `72945990eda349f83c0f7628e85521fb30071fc6`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6`; - organization variable `MEIGMA_RELEASE_APP_CLIENT_ID`; - organization secret `MEIGMA_RELEASE_APP_PRIVATE_KEY`; and - the locked Go 1.26.6, GoReleaser 2.17.1, Syft 1.51.0, Cosign 3.1.3, GitHub CLI 2.97.0, Melange 0.59.1, and apko 1.2.37 versions unless the shared workflow contract is deliberately updated. -The caller sets `publish-release: false` so the first run leaves a populated draft. After inspecting that draft, change the input to `true` and follow the recovery guide to publish through the same tag and release. +The caller sets both `publish-image: false` and `publish-release: false` so the first run leaves a populated draft without writing to GHCR. After inspecting the draft and `oci-image` artifact, change both inputs to `true` and follow the recovery guide to publish through the same tag and release. -The OCI job retains the signed APK repository, apko lock, SPDX files, and OCI layout in the `oci-image` workflow artifact. It does not publish the image to a registry. +The OCI builder retains the signed APK repository, apko lock, SPDX files, and OCI layout in the `oci-image` workflow artifact. The separate publisher verifies that artifact before pushing, signing, and attesting `ghcr.io/owner/repository`. From 052e8277da00bf6369093ed8736cf5d21195d843 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 11:48:18 -0700 Subject: [PATCH 43/46] fix(release): harden OCI publication semantics --- .github/workflows/go-oci-build.yml | 105 +++++++-- .github/workflows/publish-github-release.yml | 32 +++ .github/workflows/publish-oci-image.yml | 205 ++++++++++++++++-- .github/workflows/release.yml | 2 + .../go-release/.github/workflows/release.yml | 2 + 5 files changed, 312 insertions(+), 34 deletions(-) diff --git a/.github/workflows/go-oci-build.yml b/.github/workflows/go-oci-build.yml index 652a920..4171022 100644 --- a/.github/workflows/go-oci-build.yml +++ b/.github/workflows/go-oci-build.yml @@ -201,6 +201,20 @@ jobs: exit 1 fi test -f "${source_path}" + description=$(file -b "${source_path}") + case "${goarch}" in + amd64) expected_machine='x86-64' ;; + arm64) expected_machine='ARM aarch64' ;; + *) echo "::error::Unexpected Go architecture: ${goarch}"; exit 1 ;; + esac + if [[ + "${description}" != ELF\ 64-bit\ LSB\ executable* || + "${description}" != *"${expected_machine}"* || + "${description}" != *'statically linked'* + ]]; then + echo "::error::linux/${goarch} canonical input is not a static ${expected_machine} ELF executable: ${description}" + exit 1 + fi if [[ -n "${binary_name}" && "${artifact_name}" != "${binary_name}" ]]; then echo '::error::Linux architecture binaries have different names.' exit 1 @@ -303,16 +317,38 @@ jobs: env: OUTPUT: ${{ steps.stage.outputs.output }} VERSION: ${{ steps.stage.outputs.version }} + BINARY_NAME: ${{ steps.stage.outputs.binary-name }} WORK: ${{ steps.stage.outputs.work }} shell: bash run: | set -euo pipefail cd "${OUTPUT}" - jq -e ' - ((.manifests | length) == 2) and - (([.manifests[].platform.architecture] | sort) == ["amd64", "arm64"]) and - (all(.manifests[]; .platform.os == "linux")) - ' layout/index.json >/dev/null + jq -e \ + --arg description "$(jq -er '.annotations["org.opencontainers.image.description"]' layout/index.json)" \ + --arg license "$(jq -er '.annotations["org.opencontainers.image.licenses"]' layout/index.json)" \ + --arg revision "${GITHUB_SHA}" \ + --arg source "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ + --arg title "$(jq -er '.annotations["org.opencontainers.image.title"]' layout/index.json)" \ + --arg version "${VERSION}" ' + (.schemaVersion == 2) and + (.mediaType == "application/vnd.oci.image.index.v1+json") and + ((.manifests | length) == 2) and + (([.manifests[].platform.architecture] | sort) == ["amd64", "arm64"]) and + (all(.manifests[]; .platform.os == "linux")) and + (.annotations["org.opencontainers.image.description"] == $description) and + (.annotations["org.opencontainers.image.licenses"] == $license) and + (.annotations["org.opencontainers.image.revision"] == $revision) and + (.annotations["org.opencontainers.image.source"] == $source) and + (.annotations["org.opencontainers.image.title"] == $title) and + (.annotations["org.opencontainers.image.version"] == $version) and + (($description | length) > 0) and + (($license | length) > 0) and + (($title | length) > 0) + ' layout/index.json >/dev/null + + description=$(jq -er '.annotations["org.opencontainers.image.description"]' layout/index.json) + license=$(jq -er '.annotations["org.opencontainers.image.licenses"]' layout/index.json) + title=$(jq -er '.annotations["org.opencontainers.image.title"]' layout/index.json) while IFS=$'\t' read -r architecture manifest_digest; do case "${architecture}" in @@ -321,27 +357,56 @@ jobs: *) echo "::error::Unexpected architecture: ${architecture}"; exit 1 ;; esac manifest=layout/blobs/sha256/${manifest_digest#sha256:} - jq -e '(.layers | length) == 1' "${manifest}" >/dev/null + jq -e \ + --arg description "${description}" \ + --arg license "${license}" \ + --arg revision "${GITHUB_SHA}" \ + --arg source "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ + --arg title "${title}" \ + --arg version "${VERSION}" ' + (.schemaVersion == 2) and + (.mediaType == "application/vnd.oci.image.manifest.v1+json") and + ((.layers | length) == 1) and + (.annotations["org.opencontainers.image.description"] == $description) and + (.annotations["org.opencontainers.image.licenses"] == $license) and + (.annotations["org.opencontainers.image.revision"] == $revision) and + (.annotations["org.opencontainers.image.source"] == $source) and + (.annotations["org.opencontainers.image.title"] == $title) and + (.annotations["org.opencontainers.image.version"] == $version) + ' "${manifest}" >/dev/null config_digest=$(jq -er '.config.digest' "${manifest}") layer_digest=$(jq -er '.layers[0].digest' "${manifest}") config=layout/blobs/sha256/${config_digest#sha256:} layer=layout/blobs/sha256/${layer_digest#sha256:} - entrypoint=$(jq -er ' - .config.Entrypoint - | if length == 1 then .[0] else error("expected one entrypoint") end - ' "${config}") + entrypoint=/usr/bin/${BINARY_NAME} + jq -e \ + --arg architecture "${architecture}" \ + --arg description "${description}" \ + --arg entrypoint "${entrypoint}" \ + --arg license "${license}" \ + --arg revision "${GITHUB_SHA}" \ + --arg source "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ + --arg title "${title}" \ + --arg version "${VERSION}" ' + (.architecture == $architecture) and + (.os == "linux") and + (.config.Entrypoint == [$entrypoint]) and + (.config.User == "65532") and + (.config.Labels["org.opencontainers.image.description"] == $description) and + (.config.Labels["org.opencontainers.image.licenses"] == $license) and + (.config.Labels["org.opencontainers.image.revision"] == $revision) and + (.config.Labels["org.opencontainers.image.source"] == $source) and + (.config.Labels["org.opencontainers.image.title"] == $title) and + (.config.Labels["org.opencontainers.image.version"] == $version) + ' "${config}" >/dev/null entry=${entrypoint#/} - owner=$( - tar --numeric-owner -tvf "${layer}" "${entry}" | - awk '{ - if ($2 ~ /^[0-9]+\/[0-9]+$/) { - print $2 - } else { - print $3 "/" $4 - } - }' - ) + listing=$(tar --numeric-owner -tvf "${layer}" "${entry}") + read -r mode owner _ <<< "${listing}" + if [[ "${mode}" != '-rwxr-xr-x' ]]; then + echo "::error::${entrypoint} has mode ${mode}; expected -rwxr-xr-x (0755)." + exit 1 + fi if [[ "${owner}" != '0/0' ]]; then echo "::error::${entrypoint} is owned by ${owner}; expected 0/0." exit 1 diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index e4ef67d..6594864 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -24,6 +24,16 @@ on: required: false default: true type: boolean + require-oci-image: + description: Require a published digest-pinned OCI image before making the GitHub Release public. + required: false + default: false + type: boolean + oci-image-reference: + description: Published digest-pinned OCI image reference; required for publication when require-oci-image is true. + required: false + default: '' + type: string secrets: release-app-private-key: description: Private key of the GitHub App that publishes releases. @@ -57,11 +67,33 @@ jobs: steps: - name: Require a tag ref uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + OCI_IMAGE_REFERENCE: ${{ inputs.oci-image-reference }} + PUBLISH_RELEASE: ${{ inputs.publish-release }} + REQUIRE_OCI_IMAGE: ${{ inputs.require-oci-image }} with: script: | if (process.env.GITHUB_REF_TYPE !== 'tag') { throw new Error('GitHub Release publication must run against a tag ref.') } + if ( + process.env.PUBLISH_RELEASE === 'true' && + process.env.REQUIRE_OCI_IMAGE === 'true' + ) { + const expectedName = + `ghcr.io/${context.repo.owner.toLowerCase()}/${context.repo.repo.toLowerCase()}` + const [name, digest, ...extra] = + String(process.env.OCI_IMAGE_REFERENCE ?? '').split('@') + if ( + name !== expectedName || + !/^sha256:[0-9a-f]{64}$/.test(digest) || + extra.length !== 0 + ) { + throw new Error( + `GitHub Release publication requires a published OCI image at ${expectedName}@sha256:.`, + ) + } + } - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/publish-oci-image.yml b/.github/workflows/publish-oci-image.yml index 91882b5..8f27db0 100644 --- a/.github/workflows/publish-oci-image.yml +++ b/.github/workflows/publish-oci-image.yml @@ -45,6 +45,9 @@ jobs: name: Publish OCI image runs-on: ubuntu-24.04 timeout-minutes: 15 + concurrency: + group: oci-publish-${{ github.repository_id }} + cancel-in-progress: false permissions: actions: read artifact-metadata: write @@ -64,7 +67,7 @@ jobs: shell: bash run: | set -euo pipefail - if [[ ! "${GITHUB_REF}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ ! "${GITHUB_REF}" =~ ^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "::error::OCI publication requires a stable vMAJOR.MINOR.PATCH tag; got ${GITHUB_REF}." exit 1 fi @@ -182,11 +185,13 @@ jobs: test -n "${minor}" test -n "${patch}" image_name=ghcr.io/${GITHUB_REPOSITORY,,} - image_tags=${version},${major}.${minor},${major},latest { echo "image-name=${image_name}" - echo "image-tags=${image_tags}" + echo "version=${version}" + echo "exact-tag=${version}" + echo "minor-tag=${major}.${minor}" + echo "major-tag=${major}" echo "image-digest=${expected}" echo "amd64-digest=${amd64_digest}" echo "arm64-digest=${arm64_digest}" @@ -201,14 +206,156 @@ jobs: set -euo pipefail printf '%s' "${GHCR_TOKEN}" | oras login ghcr.io --username "${GITHUB_ACTOR}" --password-stdin + - name: Plan OCI image tags + if: inputs.publish-image + id: tags + env: + EXACT_TAG: ${{ steps.stage.outputs.exact-tag }} + EXPECTED_DIGEST: ${{ steps.stage.outputs.image-digest }} + IMAGE_NAME: ${{ steps.stage.outputs.image-name }} + MAJOR_TAG: ${{ steps.stage.outputs.major-tag }} + MINOR_TAG: ${{ steps.stage.outputs.minor-tag }} + VERSION: ${{ steps.stage.outputs.version }} + shell: bash + run: | + set -euo pipefail + + resolve_optional() { + local reference=$1 + local error_file=${RUNNER_TEMP}/oras-resolve-error + local digest + local message + local status + + if digest=$(oras resolve "${reference}" 2>"${error_file}"); then + printf '%s\n' "${digest}" + return 0 + else + status=$? + message=$(<"${error_file}") + if [[ "${status}" -eq 1 && "${message}" == *': not found' ]]; then + return 1 + fi + printf '%s\n' "${message}" >&2 + return "${status}" + fi + } + + component_is_greater() { + local left=$1 + local right=$2 + + if (( ${#left} != ${#right} )); then + (( ${#left} > ${#right} )) + return + fi + [[ "${left}" > "${right}" ]] + } + + version_is_newer() { + local candidate=$1 + local current=$2 + local index + local -a candidate_parts + local -a current_parts + + IFS=. read -ra candidate_parts <<< "${candidate}" + IFS=. read -ra current_parts <<< "${current}" + for index in 0 1 2; do + if component_is_greater "${candidate_parts[index]}" "${current_parts[index]}"; then + return 0 + fi + if component_is_greater "${current_parts[index]}" "${candidate_parts[index]}"; then + return 1 + fi + done + return 1 + } + + stable_version='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + tags=() + + if current=$(resolve_optional "${IMAGE_NAME}:${EXACT_TAG}"); then + if [[ "${current}" != "${EXPECTED_DIGEST}" ]]; then + echo "::error::Immutable tag ${IMAGE_NAME}:${EXACT_TAG} resolves to ${current}; expected ${EXPECTED_DIGEST}." + exit 1 + fi + echo "Exact-version tag ${EXACT_TAG} already resolves to the authoritative digest." + else + status=$? + if [[ "${status}" -ne 1 ]]; then + exit "${status}" + fi + tags+=("${EXACT_TAG}") + fi + + plan_channel_tag() { + local tag=$1 + local scope=$2 + local current + local current_version + + if current=$(resolve_optional "${IMAGE_NAME}:${tag}"); then + if [[ "${current}" == "${EXPECTED_DIGEST}" ]]; then + echo "Channel tag ${tag} already resolves to the authoritative digest." + return + fi + + current_version=$( + oras manifest fetch "${IMAGE_NAME}:${tag}" | + jq -er '.annotations["org.opencontainers.image.version"]' + ) + if [[ ! "${current_version}" =~ ${stable_version} ]]; then + echo "::error::Channel tag ${IMAGE_NAME}:${tag} has invalid version annotation ${current_version}." + exit 1 + fi + case "${scope}" in + minor) + if [[ "${current_version}" != "${MINOR_TAG}."* ]]; then + echo "::error::Channel tag ${tag} points outside its minor release line: ${current_version}." + exit 1 + fi + ;; + major) + if [[ "${current_version}" != "${MAJOR_TAG}."* ]]; then + echo "::error::Channel tag ${tag} points outside its major release line: ${current_version}." + exit 1 + fi + ;; + latest) ;; + *) + echo "::error::Unknown channel scope ${scope}." + exit 1 + ;; + esac + + if version_is_newer "${VERSION}" "${current_version}"; then + tags+=("${tag}") + else + echo "Keeping ${IMAGE_NAME}:${tag} at newer release ${current_version}." + fi + else + status=$? + if [[ "${status}" -ne 1 ]]; then + exit "${status}" + fi + tags+=("${tag}") + fi + } - - name: Publish OCI image + plan_channel_tag "${MINOR_TAG}" minor + plan_channel_tag "${MAJOR_TAG}" major + plan_channel_tag latest latest + + tag_list=$(IFS=,; printf '%s' "${tags[*]}") + echo "tags=${tag_list}" >> "${GITHUB_OUTPUT}" + + - name: Publish OCI image by digest if: inputs.publish-image id: push env: EXPECTED_DIGEST: ${{ steps.stage.outputs.image-digest }} IMAGE_NAME: ${{ steps.stage.outputs.image-name }} - IMAGE_TAGS: ${{ steps.stage.outputs.image-tags }} shell: bash run: | set -euo pipefail @@ -231,23 +378,21 @@ jobs: oras manifest push \ --media-type application/vnd.oci.image.index.v1+json \ - "${IMAGE_NAME}:${IMAGE_TAGS}" \ + "${IMAGE_NAME}@${EXPECTED_DIGEST}" \ oci-image/layout/index.json - IFS=, read -ra tags <<< "${IMAGE_TAGS}" - for tag in "${tags[@]}"; do - actual=$(oras resolve "${IMAGE_NAME}:${tag}") - if [[ "${actual}" != "${EXPECTED_DIGEST}" ]]; then - echo "::error::Published ${IMAGE_NAME}:${tag} resolved to ${actual}; expected ${EXPECTED_DIGEST}." - exit 1 - fi - done + actual=$(oras resolve "${IMAGE_NAME}@${EXPECTED_DIGEST}") + if [[ "${actual}" != "${EXPECTED_DIGEST}" ]]; then + echo "::error::Published ${IMAGE_NAME}@${EXPECTED_DIGEST} resolved to ${actual}." + exit 1 + fi { echo "digest=${EXPECTED_DIGEST}" echo "reference=${IMAGE_NAME}@${EXPECTED_DIGEST}" } >> "${GITHUB_OUTPUT}" + - name: Sign OCI image and platform manifests if: inputs.publish-image env: @@ -283,6 +428,38 @@ jobs: subject-digest: ${{ steps.stage.outputs.arm64-digest }} sbom-path: oci-image/sboms/sbom-aarch64.spdx.json push-to-registry: true + - name: Publish verified OCI image tags + if: inputs.publish-image + env: + EXACT_TAG: ${{ steps.stage.outputs.exact-tag }} + EXPECTED_DIGEST: ${{ steps.stage.outputs.image-digest }} + IMAGE_NAME: ${{ steps.stage.outputs.image-name }} + IMAGE_TAGS: ${{ steps.tags.outputs.tags }} + shell: bash + run: | + set -euo pipefail + + if [[ -n "${IMAGE_TAGS}" ]]; then + IFS=, read -ra tags <<< "${IMAGE_TAGS}" + oras tag \ + --concurrency 1 \ + "${IMAGE_NAME}@${EXPECTED_DIGEST}" \ + "${tags[@]}" + + for tag in "${tags[@]}"; do + actual=$(oras resolve "${IMAGE_NAME}:${tag}") + if [[ "${actual}" != "${EXPECTED_DIGEST}" ]]; then + echo "::error::Published ${IMAGE_NAME}:${tag} resolved to ${actual}; expected ${EXPECTED_DIGEST}." + exit 1 + fi + done + fi + + exact=$(oras resolve "${IMAGE_NAME}:${EXACT_TAG}") + if [[ "${exact}" != "${EXPECTED_DIGEST}" ]]; then + echo "::error::Exact tag ${IMAGE_NAME}:${EXACT_TAG} resolves to ${exact}; expected ${EXPECTED_DIGEST}." + exit 1 + fi - name: Log out of GHCR if: always() && inputs.publish-image diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e685082..7b33dc7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,8 @@ jobs: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} checksum-signing-workflow-ref: ${{ github.repository }}/.github/workflows/go-pre-publish.yml@${{ github.ref }} + require-oci-image: true + oci-image-reference: ${{ needs.oci-publish.outputs.image-reference }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} publish-release: true secrets: diff --git a/examples/go-release/.github/workflows/release.yml b/examples/go-release/.github/workflows/release.yml index 96fef52..b0b5c5c 100644 --- a/examples/go-release/.github/workflows/release.yml +++ b/examples/go-release/.github/workflows/release.yml @@ -65,6 +65,8 @@ jobs: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 + require-oci-image: true + oci-image-reference: ${{ needs.oci-publish.outputs.image-reference }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} publish-release: false secrets: From a23ddc59b5526a62f97b88ba1e9d55ce61ac3c23 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 12:07:09 -0700 Subject: [PATCH 44/46] docs(release): document OCI publication safety --- README.md | 2 +- docs/how-to/configure-github-releases.md | 8 ++-- docs/how-to/configure-oci-images.md | 35 ++++++++++------- .../rehearse-and-recover-github-releases.md | 10 ++--- .../upgrade-github-release-workflows.md | 4 +- docs/reference/github-release-contract.md | 32 ++++++++------- docs/reference/oci-image-contract.md | 39 ++++++++++--------- .../go-release/.github/workflows/release.yml | 10 ++--- examples/go-release/README.md | 4 +- 9 files changed, 79 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index a87e995..171e199 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,4 @@ This repository defines the reusable workflows and repository contract that Meig - [OCI image contract reference](docs/reference/oci-image-contract.md) - [Copyable Go release example](examples/go-release/) -Consumer repositories call the reusable workflows at a full commit SHA. The documented revision is `72945990eda349f83c0f7628e85521fb30071fc6`. +Consumer repositories call the reusable workflows at a full commit SHA. The documented revision is `052e8277da00bf6369093ed8736cf5d21195d843`. diff --git a/docs/how-to/configure-github-releases.md b/docs/how-to/configure-github-releases.md index 0ca05b2..f55e7d3 100644 --- a/docs/how-to/configure-github-releases.md +++ b/docs/how-to/configure-github-releases.md @@ -100,8 +100,8 @@ In the copied files, replace the example values with values from the consumer re Do not replace these shared contract values: -- reusable workflow revision `72945990eda349f83c0f7628e85521fb30071fc6`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6`; +- reusable workflow revision `052e8277da00bf6369093ed8736cf5d21195d843`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843`; - variable name `MEIGMA_RELEASE_APP_CLIENT_ID`; or - secret name `MEIGMA_RELEASE_APP_PRIVATE_KEY`. @@ -227,7 +227,7 @@ Verify that the checksum manifest was signed by the canonical reusable pre-publi ```bash mise exec -- cosign verify-blob \ --bundle checksums.txt.sigstore.json \ - --certificate-identity 'https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6' \ + --certificate-identity 'https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ checksums.txt ``` @@ -243,7 +243,7 @@ while IFS= read -r entry; do mise exec -- gh attestation verify "$asset" \ --repo "$REPOSITORY" \ --signer-workflow meigma/release/.github/workflows/publish-github-release.yml \ - --signer-digest 72945990eda349f83c0f7628e85521fb30071fc6 \ + --signer-digest 052e8277da00bf6369093ed8736cf5d21195d843 \ --source-ref "refs/tags/$TAG" \ --deny-self-hosted-runners done < checksums.txt diff --git a/docs/how-to/configure-oci-images.md b/docs/how-to/configure-oci-images.md index 930dd3f..426c9a7 100644 --- a/docs/how-to/configure-oci-images.md +++ b/docs/how-to/configure-oci-images.md @@ -2,7 +2,7 @@ Use this guide to add signed, multi-architecture GHCR images to a repository that already uses the Meigma Go release workflows. The [OCI image contract](../reference/oci-image-contract.md) defines the reusable workflow interfaces, image contents, tags, signatures, attestations, and recovery behavior. -The documented workflow revision is `72945990eda349f83c0f7628e85521fb30071fc6`. +The documented workflow revision is `052e8277da00bf6369093ed8736cf5d21195d843`. ## Prerequisites @@ -20,7 +20,7 @@ Record the consumer repository and immutable workflow revision: ```bash export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -export RELEASE_REVISION=72945990eda349f83c0f7628e85521fb30071fc6 +export RELEASE_REVISION=052e8277da00bf6369093ed8736cf5d21195d843 export IMAGE="ghcr.io/${REPOSITORY,,}" ``` @@ -99,7 +99,7 @@ permissions: Pin every reusable workflow to the same full revision: ```text -72945990eda349f83c0f7628e85521fb30071fc6 +052e8277da00bf6369093ed8736cf5d21195d843 ``` Make `github-release` depend on `oci-publish`. That ordering keeps the GitHub Release in draft state when registry publication, signing, or attestation fails. @@ -118,7 +118,7 @@ The run still builds the APK repository and OCI index, verifies the canonical bi Inspect the `oci-image` workflow artifact. It must contain: ```text -apko-lock.json +apko.lock.json apk-signing.rsa.pub configuration/apko.yaml configuration/melange.yaml @@ -145,16 +145,15 @@ publish-release: true Create the next stable `vMAJOR.MINOR.PATCH` release through Release Please. The image publisher rejects non-stable tags. -A successful `v1.2.3` run publishes these tags, all resolving to the same index digest: +A successful `v1.2.3` run always publishes the immutable exact tag: ```text 1.2.3 -1.2 -1 -latest ``` -The exact version tag is immutable by release policy. The other three tags move on later stable releases. Consumers that require repeatable deployment must use `ghcr.io/owner/repository@sha256:...`, not a moving tag. +It also advances `1.2`, `1`, and `latest` when `1.2.3` is newer than each channel's current stable version. An out-of-order or backport release publishes its exact tag and advances only the channels for which it is newer; it never moves a channel backward. + +The publisher enforces exact-tag immutability before uploading registry content. If `1.2.3` already resolves to a different digest, publication fails without writing the candidate image. Consumers that require repeatable deployment must use `ghcr.io/owner/repository@sha256:...`, not a moving tag. Package visibility follows the organization's package-creation setting; it does not inherit repository visibility. After the first complete publication, inspect the package: @@ -204,7 +203,7 @@ gh attestation verify "oci://$IMAGE@$DIGEST" \ --deny-self-hosted-runners ``` -Verify each platform SBOM attestation: +Verify each platform signature and SBOM attestation: ```bash for ARCH in amd64 arm64; do @@ -213,6 +212,10 @@ for ARCH in amd64 arm64; do jq -r --arg arch "$ARCH" \ '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $arch) | .digest' )" + cosign verify \ + --certificate-identity "https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@$RELEASE_REVISION" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "$IMAGE@$PLATFORM_DIGEST" gh attestation verify "oci://$IMAGE@$PLATFORM_DIGEST" \ --repo "$REPOSITORY" \ --bundle-from-oci \ @@ -235,8 +238,14 @@ Both commands must report the release version and commit. Running a non-native p ## Recovery -A failed publisher leaves the GitHub Release draft unpublished because `github-release` depends on `oci-publish`. +A failed publisher leaves the GitHub Release draft unpublished because `github-release` depends on `oci-publish` and requires its digest-pinned image output. + +When repository content is unchanged, rerun only the failed jobs: + +```bash +gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY" --failed +``` -Fix the source problem and rerun through the same unpublished tag procedure. Re-publishing the same OCI index is content-addressed: ORAS resolves every tag back to the expected digest before signing or attesting. A rerun after a partial success can add duplicate valid signatures or attestations; it cannot change the exact version's intended image without also changing the expected digest. +This preserves the successful builder job and reuses its authoritative OCI artifact. The publisher revalidates the artifact, digest, immutable exact tag, and eligible channel tags. A retry after a partial success may add duplicate valid signatures or attestations. Public tags are assigned only after the expected digest and both platform manifests are signed and attested. -Never move a tag after its GitHub Release is public. Never delete and recreate a public exact-version image tag to substitute different content. Publish a corrective release instead. +If source, workflow configuration, or tool pins must change, follow the unpublished-tag recovery procedure in [Rehearse and recover GitHub Releases](rehearse-and-recover-github-releases.md). Never move a tag after its GitHub Release is public. Never delete and recreate a public exact-version image tag to substitute different content. Publish a corrective release instead. diff --git a/docs/how-to/rehearse-and-recover-github-releases.md b/docs/how-to/rehearse-and-recover-github-releases.md index 90345f2..b860f3b 100644 --- a/docs/how-to/rehearse-and-recover-github-releases.md +++ b/docs/how-to/rehearse-and-recover-github-releases.md @@ -246,7 +246,7 @@ gh run view "$FAILED_RUN_ID" \ Keep the release as a draft while diagnosing any failure below. -If recovery changes source, workflow configuration, or tool pins, merge that correction, record its commit SHA, and trigger a new run by authorized movement of the unpublished tag to that commit. Then select the run by the exact tag and SHA as shown above. If the tag cannot be moved safely, abandon the incomplete candidate and cut a new one. Use a plain Actions rerun only when no repository content changes, such as recovery from artifact expiry or a transient service failure. +If recovery changes source, workflow configuration, or tool pins, merge that correction, record its commit SHA, and trigger a new run by authorized movement of the unpublished tag to that commit. Then select the run by the exact tag and SHA as shown above. If the tag cannot be moved safely, abandon the incomplete candidate and cut a new one. When repository content is unchanged and upstream build jobs succeeded, rerun only failed jobs with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY" --failed`; this preserves the authoritative artifacts from the original workflow run. Use a complete rerun only when an upstream artifact must be rebuilt, such as artifact expiry or an artifact-handoff failure. ### The matching draft is missing @@ -280,14 +280,14 @@ A draft can be recovered after an authorized tag move because the workflow reval The publisher rejects an invalid artifact ID, an expired artifact, a digest mismatch, or an artifact produced by another workflow run. -If the repository does not need a correction, rerun the complete top-level workflow with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY"`; do not rerun only the publisher job or substitute an artifact from another run. The producer and publisher must exchange the artifact ID and digest within that run. If the handoff failure requires a source, workflow, or pin correction, merge the correction and move the unpublished tag to that commit to create a new tag-triggered run. The authoritative artifact is retained for seven days; an expired artifact can be replaced by a plain complete rerun when repository content is unchanged. +If the repository does not need a correction, rerun the complete top-level workflow with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY"` so it builds a new authoritative artifact; do not substitute an artifact from another run. The producer and publisher must exchange the artifact ID and digest within one run. If the handoff failure requires a source, workflow, or pin correction, merge the correction and move the unpublished tag to that commit to create a new tag-triggered run. The authoritative artifact is retained for seven days; an expired artifact also requires a complete rerun. ### Checksum or Cosign verification fails For the documented current revision, the publisher requires a nonempty `checksums.txt`, the exact closed payload list, matching payload hashes, a regular Cosign bundle file, issuer `https://token.actions.githubusercontent.com`, and this certificate identity: ```text -https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 +https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 ``` For an upgrade rehearsal, replace the current-revision identity with the target value described in [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). @@ -309,7 +309,7 @@ Do not use `--clobber` for an unexpected name. The workflow uses `--clobber` onl The publisher waits until every expected asset is uploaded and GitHub reports its digest. It then requires the exact asset count, unique names, and a GitHub-reported SHA-256 digest matching the locally validated bundle. -If no repository content changes, rerun the complete workflow with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY"`. The validated expected names may be replaced in the same draft. If the failure requires a source, workflow, or pin correction, merge the correction and move the unpublished tag to that commit; otherwise abandon the candidate and cut a new one. If the failure repeats, leave the release as a draft and inspect the release asset state and workflow logs; do not publish through the UI. Manual removal is required only when an unexpected or otherwise unreconcilable asset prevents the workflow from restoring the closed name set. +If no repository content changes and the producer succeeded, rerun only failed jobs with `gh run rerun "$FAILED_RUN_ID" --repo "$REPOSITORY" --failed`. This reuses the validated artifact and may replace its expected names in the same draft. If the failure requires a source, workflow, or pin correction, merge the correction and move the unpublished tag to that commit; otherwise abandon the candidate and cut a new one. If the failure repeats, leave the release as a draft and inspect the release asset state and workflow logs; do not publish through the UI. Manual removal is required only when an unexpected or otherwise unreconcilable asset prevents the workflow from restoring the closed name set. If the final API call changed the release to non-draft before a later check failed, the workflow cannot roll that state back. Confirm the state with `gh release view "$TAG" --repo "$REPOSITORY" --json isDraft,publishedAt,url`. A subsequent run will reject the public release because it is no longer a draft; preserve it and cut a corrected version unless the organization authorizes a release-incident removal. @@ -322,6 +322,6 @@ Manual cleanup is required when: - repository rules require an authorized administrator to approve the controlled rehearsal tag move; or - a draft contains a release association or asset state that the validated `--clobber` path cannot reconcile. -Manual cleanup is not required for an expired authoritative artifact, an expected asset from the first draft-only run, or a failed checksum/signature check. Use a complete rerun when repository content is unchanged. For a source, configuration, or pin correction, merge the correction and trigger a new tag run or abandon the candidate. +Manual cleanup is not required for an expected asset from the first draft-only run or a failed checksum/signature check. Rerun only failed jobs when upstream artifacts remain valid and repository content is unchanged. Use a complete rerun for an expired or invalid artifact. For a source, configuration, or pin correction, merge the correction and trigger a new tag run or abandon the candidate. If a release is already public, do not delete it or move its tag as routine recovery. Preserve the published record and release a corrected version unless the organization declares a separate release incident and explicitly authorizes removal. diff --git a/docs/how-to/upgrade-github-release-workflows.md b/docs/how-to/upgrade-github-release-workflows.md index 2a70859..35c728e 100644 --- a/docs/how-to/upgrade-github-release-workflows.md +++ b/docs/how-to/upgrade-github-release-workflows.md @@ -1,6 +1,6 @@ # Upgrade GitHub Release workflows -Use this guide to move a consumer repository from the current workflow revision, `72945990eda349f83c0f7628e85521fb30071fc6`, to a reviewed immutable revision. Do not use a branch or tag as a reusable workflow reference. The [GitHub Release contract](../reference/github-release-contract.md) and [OCI image contract](../reference/oci-image-contract.md) define the current interfaces and publication boundaries. +Use this guide to move a consumer repository from the current workflow revision, `052e8277da00bf6369093ed8736cf5d21195d843`, to a reviewed immutable revision. Do not use a branch or tag as a reusable workflow reference. The [GitHub Release contract](../reference/github-release-contract.md) and [OCI image contract](../reference/oci-image-contract.md) define the current interfaces and publication boundaries. ## Prerequisites @@ -16,7 +16,7 @@ Record the consumer, the current baseline, and a local checkout of `meigma/relea ```bash export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -export CURRENT_RELEASE_REVISION=72945990eda349f83c0f7628e85521fb30071fc6 +export CURRENT_RELEASE_REVISION=052e8277da00bf6369093ed8736cf5d21195d843 read -r -p 'Reviewed full meigma/release commit SHA: ' NEW_RELEASE_REVISION export NEW_RELEASE_REVISION read -r -p 'Path to the meigma/release checkout: ' RELEASE_CHECKOUT diff --git a/docs/reference/github-release-contract.md b/docs/reference/github-release-contract.md index 449deb5..05eed55 100644 --- a/docs/reference/github-release-contract.md +++ b/docs/reference/github-release-contract.md @@ -1,6 +1,6 @@ # GitHub release contract reference -This page defines the cross-repository contract for the reusable Go producer and GitHub Release publisher at revision `72945990eda349f83c0f7628e85521fb30071fc6`. +This page defines the cross-repository contract for the reusable Go producer and GitHub Release publisher at revision `052e8277da00bf6369093ed8736cf5d21195d843`. For configuration steps, see [Configure GitHub releases](../how-to/configure-github-releases.md). For draft rehearsals and recovery steps, see [Rehearse and recover GitHub releases](../how-to/rehearse-and-recover-github-releases.md). The [OCI image contract](oci-image-contract.md) defines the image builder and publisher that gate the complete delivery caller. To adopt another immutable revision, see [Upgrade GitHub release workflows](../how-to/upgrade-github-release-workflows.md). A complete consumer repository is available in the [Go release example](../../examples/go-release/). @@ -9,17 +9,17 @@ For configuration steps, see [Configure GitHub releases](../how-to/configure-git The complete caller pins all four reusable workflows to one full revision. The GitHub Release path directly calls the producer and GitHub publisher: ```yaml -uses: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 +uses: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 ``` ```yaml -uses: meigma/release/.github/workflows/publish-github-release.yml@72945990eda349f83c0f7628e85521fb30071fc6 +uses: meigma/release/.github/workflows/publish-github-release.yml@052e8277da00bf6369093ed8736cf5d21195d843 ``` The checksum signer identity input must name the same producer workflow revision: ```yaml -checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 +checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 ``` ## Caller contract @@ -47,7 +47,7 @@ jobs: permissions: contents: read id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 + uses: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 oci-image: name: Build OCI image @@ -55,7 +55,7 @@ jobs: permissions: actions: read contents: read - uses: meigma/release/.github/workflows/go-oci-build.yml@72945990eda349f83c0f7628e85521fb30071fc6 + uses: meigma/release/.github/workflows/go-oci-build.yml@052e8277da00bf6369093ed8736cf5d21195d843 with: artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} @@ -70,7 +70,7 @@ jobs: contents: read id-token: write packages: write - uses: meigma/release/.github/workflows/publish-oci-image.yml@72945990eda349f83c0f7628e85521fb30071fc6 + uses: meigma/release/.github/workflows/publish-oci-image.yml@052e8277da00bf6369093ed8736cf5d21195d843 with: artifact-id: ${{ needs.oci-image.outputs.artifact-id }} artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} @@ -89,11 +89,13 @@ jobs: attestations: write contents: read id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@72945990eda349f83c0f7628e85521fb30071fc6 + uses: meigma/release/.github/workflows/publish-github-release.yml@052e8277da00bf6369093ed8736cf5d21195d843 with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 + require-oci-image: true + oci-image-reference: ${{ needs.oci-publish.outputs.image-reference }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} publish-release: true secrets: @@ -102,7 +104,7 @@ jobs: The top-level `permissions: {}` prevents permissions from being granted implicitly. Each called job grants its reusable workflow only the permissions listed above. A called workflow cannot elevate permissions beyond those granted by its caller. -The concurrency key serializes runs for the same workflow and tag. `cancel-in-progress: false` prevents a later release run from canceling an earlier run. +The caller concurrency key serializes runs for the same workflow and tag. `cancel-in-progress: false` prevents a later run for that tag from canceling an earlier run. The OCI publisher adds repository-wide serialization across different release tags so shared channel tags cannot race. ## Reusable workflow interfaces @@ -134,6 +136,8 @@ The workflow runs on `ubuntu-24.04` with a 20-minute timeout. It declares `permi | `checksum-signing-workflow-ref` | string | Yes | None | Exact owner, repository, workflow path, and revision used as the checksum certificate identity after the `https://github.com/` prefix is added. | | `release-app-client-id` | string | Yes | None | Client ID used to mint the Release App installation token. | | `publish-release` | boolean | No | `true` | Whether to change the populated draft to a non-draft release after verification. | +| `require-oci-image` | boolean | No | `false` | Whether public GitHub Release publication requires a validated digest-pinned GHCR image reference for the caller repository. | +| `oci-image-reference` | string | No | Empty | `ghcr.io//@sha256:` returned by the successful OCI publisher. | | Secret | Required | Value | | --- | --- | --- | @@ -292,14 +296,14 @@ The checksum signature is accepted only when Cosign verifies all of the followin | Field | Required value | | --- | --- | -| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6` | +| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843` | | Certificate OIDC issuer | `https://token.actions.githubusercontent.com` | | Signed blob | `checksums.txt` | | Bundle | `checksums.txt.sigstore.json` | The exact identity comes from `checksum-signing-workflow-ref`; a branch name, tag name, different commit, or different workflow path does not satisfy the documented identity. -The publisher at `meigma/release/.github/workflows/publish-github-release.yml@72945990eda349f83c0f7628e85521fb30071fc6` creates GitHub build-provenance attestations in the consumer repository. Its job token creates attestations but has only `contents: read`. A short-lived Release App installation token with `contents: write` performs draft discovery, asset upload, and the final draft-state change. +The publisher at `meigma/release/.github/workflows/publish-github-release.yml@052e8277da00bf6369093ed8736cf5d21195d843` creates GitHub build-provenance attestations in the consumer repository. Its job token creates attestations but has only `contents: read`. A short-lived Release App installation token with `contents: write` performs draft discovery, asset upload, and the final draft-state change. ## Publication states @@ -310,9 +314,9 @@ The publisher at `meigma/release/.github/workflows/publish-github-release.yml@72 | Artifact built | The producer runs on the tag. | GoReleaser builds once in that run, creates SBOMs and checksums, signs the checksum manifest, and uploads `release-assets`. | The artifact ID and digest pass to the publisher in the same workflow run. | | Draft populated | The publisher validates the artifact, signature, tag, and draft. | It attests the checksummed payloads, uploads the closed asset set, and verifies every GitHub-reported asset digest. | Asset names, states, and digests match the signed bundle. | | Rehearsal complete | `publish-release` is `false`. | The publisher verifies that the release remains a draft. | The populated draft is available for inspection or a later recovery run. | -| Published | `publish-release` is `true` and asset verification succeeds. | The publisher sets `draft: false` and verifies the resulting state. | The same release URL identifies a non-draft GitHub Release. | +| Published | `publish-release` is `true`, asset verification succeeds, and any required digest-pinned OCI image reference is valid. | The publisher sets `draft: false` and verifies the resulting state. | The same release URL identifies a non-draft GitHub Release. | -The publisher does not create a release, generate release notes, change a tag, or upload an asset before validating the signed bundle. It does not set `draft: false` until the uploaded asset name and digest sets match the bundle. +The publisher does not create a release, generate release notes, change a tag, or upload an asset before validating the signed bundle. It does not set `draft: false` until the uploaded asset name and digest sets match the bundle. When `require-oci-image` is `true`, it also requires the successful OCI publisher's exact `ghcr.io//@sha256:` output before any release mutation. ## Retry and recovery behavior diff --git a/docs/reference/oci-image-contract.md b/docs/reference/oci-image-contract.md index c66b095..b01d736 100644 --- a/docs/reference/oci-image-contract.md +++ b/docs/reference/oci-image-contract.md @@ -1,6 +1,6 @@ # OCI image contract -This page defines the cross-repository contract for the reusable Go OCI builder and GHCR publisher at revision `72945990eda349f83c0f7628e85521fb30071fc6`. +This page defines the cross-repository contract for the reusable Go OCI builder and GHCR publisher at revision `052e8277da00bf6369093ed8736cf5d21195d843`. For adoption steps, see [Configure OCI image publication](../how-to/configure-oci-images.md). The [GitHub Release contract](github-release-contract.md) defines the upstream GoReleaser producer and GitHub Release publisher. A complete consumer is available in the [Go release example](../../examples/go-release/). @@ -14,7 +14,7 @@ GoReleaser producer -> locked apko multi-architecture OCI layout -> verified oci-image artifact -> ORAS GHCR publication - -> recursive keyless Cosign signatures + -> keyless Cosign signatures for the index and both platform manifests -> GitHub and registry provenance/SBOM attestations -> public GitHub Release ``` @@ -26,11 +26,11 @@ The image builder consumes prebuilt GoReleaser binaries. Melange packages them w Consumers call both workflows at the same immutable revision: ```yaml -uses: meigma/release/.github/workflows/go-oci-build.yml@72945990eda349f83c0f7628e85521fb30071fc6 +uses: meigma/release/.github/workflows/go-oci-build.yml@052e8277da00bf6369093ed8736cf5d21195d843 ``` ```yaml -uses: meigma/release/.github/workflows/publish-oci-image.yml@72945990eda349f83c0f7628e85521fb30071fc6 +uses: meigma/release/.github/workflows/publish-oci-image.yml@052e8277da00bf6369093ed8736cf5d21195d843 ``` Moving branches and tags are not supported workflow references. @@ -136,7 +136,7 @@ The current example includes Alpine's CA certificate bundle. A command that does The builder uploads `oci-image` with seven-day retention and no additional ZIP compression. Its contract includes: ```text -apko-lock.json +apko.lock.json apk-signing.rsa.pub configuration/apko.yaml configuration/melange.yaml @@ -178,12 +178,12 @@ A stable release tag `vMAJOR.MINOR.PATCH` publishes: | Image tag | Behavior | | --- | --- | -| `MAJOR.MINOR.PATCH` | Exact release version. Must never be reassigned to different content. | -| `MAJOR.MINOR` | Moves to the latest stable patch in that minor line. | -| `MAJOR` | Moves to the latest stable release in that major line. | -| `latest` | Moves to the latest stable release. | +| `MAJOR.MINOR.PATCH` | Immutable exact release version. Publication fails before any registry upload when this tag already resolves to a different digest. | +| `MAJOR.MINOR` | Advances only when the candidate is a greater stable version in the same minor line. | +| `MAJOR` | Advances only when the candidate is a greater stable version in the same major line. | +| `latest` | Advances only when the candidate is greater than its current stable version. | -All four tags must resolve to the builder's expected OCI index digest immediately after publication. The publisher rejects prerelease, build-metadata, malformed, branch, and untagged refs. +The exact tag must resolve to the builder's expected OCI index digest after publication. Each eligible channel tag must resolve to that digest; an out-of-order or backport release leaves newer channel tags unchanged. The publisher resolves and validates every existing tag before uploading the image. A repository-wide publisher concurrency group prevents different release tags from planning and updating channels concurrently. Prerelease, build-metadata, malformed, branch, and untagged refs are rejected. Digest-pinned references are the durable consumer interface: @@ -199,7 +199,7 @@ For both platforms, the builder verifies: - one apko index containing exactly two platform manifests; - package and image executable bytes equal the canonical GoReleaser binary; - executable ownership `0:0` inside the image layer; -- executable mode `0755` from package configuration; +- executable mode `0755` inside the image layer; - configured entrypoint; - runtime user `65532`; - source, version, revision, title, description, and license annotations; and @@ -207,11 +207,11 @@ For both platforms, the builder verifies: ## Signatures and attestations -The publisher signs the index and both platform manifests recursively with Cosign keyless signing. Verification must require: +The publisher signs the index and both platform manifests with Cosign keyless signing. Verification must require: | Field | Value | | --- | --- | -| Certificate identity | `https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@72945990eda349f83c0f7628e85521fb30071fc6` | +| Certificate identity | `https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@052e8277da00bf6369093ed8736cf5d21195d843` | | Certificate OIDC issuer | `https://token.actions.githubusercontent.com` | | Subject | Digest-pinned image or platform manifest. | @@ -229,14 +229,15 @@ Cosign signatures and GitHub attestations are distinct. A passing check for one | State | Registry effect | Expected next action | | --- | --- | --- | -| `publish-image: false` | None. Artifact and index verification only. | Inspect the rehearsal and enable publication in a reviewed commit. | -| Push incomplete | Some blobs or tags may exist. GitHub Release remains draft. | Fix the cause and rerun the same unpublished tag. | -| Push complete, signing incomplete | Tags resolve correctly but trust metadata is incomplete. GitHub Release remains draft. | Rerun; do not advertise or make the package public. | -| Signed, attestation incomplete | Image is signed but does not satisfy the complete contract. GitHub Release remains draft. | Rerun and verify every attestation. | -| Complete, package private | Authenticated pulls work. | Inspect, then perform the one-time public visibility change. | +| `publish-image: false` | None. Artifact and index verification only. | Inspect the rehearsal and enable both publication controls in one reviewed commit. | +| Digest upload incomplete | Untagged blobs or manifests may exist. No release tag has been created or changed. GitHub Release remains draft. | Rerun only failed jobs so the publisher reuses the authoritative artifact from the same workflow run. | +| Signing incomplete | The digest-addressed image may exist, but no release tag has been created or changed. GitHub Release remains draft. | Rerun only failed jobs; do not advertise or make the package public. | +| Attestation incomplete | The digest is signed but does not satisfy the complete contract. No release tag has been created or changed. GitHub Release remains draft. | Rerun only failed jobs and verify every attestation. | +| Tag publication incomplete | The digest has complete trust metadata, but a registry failure may have applied only part of the planned tag set. GitHub Release remains draft. | Rerun only failed jobs; the publisher revalidates the immutable exact tag and converges eligible channels on the expected digest. | +| Complete, package private | Authenticated pulls work and planned tags resolve correctly. | Inspect, then perform the one-time public visibility change. | | Complete, package public | Anonymous digest and tag pulls work. | Monitor and publish only additive corrective releases. | -A rerun is content-addressed and rechecks every published tag. It may add duplicate valid signatures or attestations after a partial success. It must not publish the GitHub Release until the image publisher job succeeds. +The publisher plans tags before its first upload, publishes the OCI layout by digest, signs and attests that digest and both platform manifests, and applies public tags last. A failed-job rerun reuses the same authoritative artifact and may add duplicate valid signatures or attestations after a partial success. The GitHub Release publisher requires the successful digest-pinned OCI output and cannot make the release public until the image publisher job succeeds. ## GHCR visibility diff --git a/examples/go-release/.github/workflows/release.yml b/examples/go-release/.github/workflows/release.yml index b0b5c5c..b30cdff 100644 --- a/examples/go-release/.github/workflows/release.yml +++ b/examples/go-release/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: permissions: contents: read id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 + uses: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 oci-image: name: Build OCI image @@ -26,7 +26,7 @@ jobs: permissions: actions: read contents: read - uses: meigma/release/.github/workflows/go-oci-build.yml@72945990eda349f83c0f7628e85521fb30071fc6 + uses: meigma/release/.github/workflows/go-oci-build.yml@052e8277da00bf6369093ed8736cf5d21195d843 with: artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} @@ -40,7 +40,7 @@ jobs: contents: read id-token: write packages: write - uses: meigma/release/.github/workflows/publish-oci-image.yml@72945990eda349f83c0f7628e85521fb30071fc6 + uses: meigma/release/.github/workflows/publish-oci-image.yml@052e8277da00bf6369093ed8736cf5d21195d843 with: artifact-id: ${{ needs.oci-image.outputs.artifact-id }} artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} @@ -60,11 +60,11 @@ jobs: attestations: write contents: read id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@72945990eda349f83c0f7628e85521fb30071fc6 + uses: meigma/release/.github/workflows/publish-github-release.yml@052e8277da00bf6369093ed8736cf5d21195d843 with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6 + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 require-oci-image: true oci-image-reference: ${{ needs.oci-publish.outputs.image-reference }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} diff --git a/examples/go-release/README.md b/examples/go-release/README.md index 815ba78..67eab96 100644 --- a/examples/go-release/README.md +++ b/examples/go-release/README.md @@ -42,8 +42,8 @@ Replace these project-specific example values: Keep these contract values unchanged: -- all four reusable workflow references at `72945990eda349f83c0f7628e85521fb30071fc6`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@72945990eda349f83c0f7628e85521fb30071fc6`; +- all four reusable workflow references at `052e8277da00bf6369093ed8736cf5d21195d843`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843`; - organization variable `MEIGMA_RELEASE_APP_CLIENT_ID`; - organization secret `MEIGMA_RELEASE_APP_PRIVATE_KEY`; and - the locked Go 1.26.6, GoReleaser 2.17.1, Syft 1.51.0, Cosign 3.1.3, GitHub CLI 2.97.0, Melange 0.59.1, and apko 1.2.37 versions unless the shared workflow contract is deliberately updated. From fb8c8098ff27968fb3070e928c00e925f38c698e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 12:14:08 -0700 Subject: [PATCH 45/46] refactor(release): use GitHub Script for OCI publishing --- .github/workflows/publish-oci-image.yml | 644 ++++++++++++++---------- 1 file changed, 373 insertions(+), 271 deletions(-) diff --git a/.github/workflows/publish-oci-image.yml b/.github/workflows/publish-oci-image.yml index 8f27db0..691f4cd 100644 --- a/.github/workflows/publish-oci-image.yml +++ b/.github/workflows/publish-oci-image.yml @@ -64,13 +64,15 @@ jobs: arm64-sbom-attestation-url: ${{ steps.arm64-sbom.outputs.attestation-url }} steps: - name: Require a stable release tag - shell: bash - run: | - set -euo pipefail - if [[ ! "${GITHUB_REF}" =~ ^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "::error::OCI publication requires a stable vMAJOR.MINOR.PATCH tag; got ${GITHUB_REF}." - exit 1 - fi + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const stableTag = /^refs\/tags\/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/ + if (!stableTag.test(context.ref)) { + throw new Error( + `OCI publication requires a stable vMAJOR.MINOR.PATCH tag; got ${context.ref}.`, + ) + } - name: Set up publisher tools uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 @@ -129,86 +131,127 @@ jobs: - name: Verify OCI image contents id: stage + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: EXPECTED_IMAGE_DIGEST: ${{ inputs.image-digest }} - shell: bash - run: | - set -euo pipefail - - expected=${EXPECTED_IMAGE_DIGEST,,} - if [[ ! "${expected}" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "::error::Invalid image digest: ${EXPECTED_IMAGE_DIGEST}" - exit 1 - fi - - test -f oci-image/layout/oci-layout - test -f oci-image/layout/index.json - test -f oci-image/image-digest.txt - test -f oci-image/sboms/sbom-x86_64.spdx.json - test -f oci-image/sboms/sbom-aarch64.spdx.json - - recorded=$(tr -d '\r\n' < oci-image/image-digest.txt) - computed=sha256:$(sha256sum oci-image/layout/index.json | cut -d' ' -f1) - if [[ "${recorded}" != "${expected}" || "${computed}" != "${expected}" ]]; then - echo "::error::OCI index digest mismatch: expected ${expected}, recorded ${recorded}, computed ${computed}." - exit 1 - fi - - jq -e ' - (.schemaVersion == 2) and - (.mediaType == "application/vnd.oci.image.index.v1+json") and - ((.manifests | length) == 2) and - (([.manifests[].platform.architecture] | sort) == ["amd64", "arm64"]) and - (all(.manifests[]; .platform.os == "linux")) - ' oci-image/layout/index.json >/dev/null - jq -e . oci-image/sboms/sbom-x86_64.spdx.json >/dev/null - jq -e . oci-image/sboms/sbom-aarch64.spdx.json >/dev/null - - amd64_digest=$(jq -er ' - .manifests[] | - select(.platform.os == "linux" and .platform.architecture == "amd64") | - .digest - ' oci-image/layout/index.json) - arm64_digest=$(jq -er ' - .manifests[] | - select(.platform.os == "linux" and .platform.architecture == "arm64") | - .digest - ' oci-image/layout/index.json) - for digest in "${amd64_digest}" "${arm64_digest}"; do - [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]] - test -f "oci-image/layout/blobs/sha256/${digest#sha256:}" - done - - version=${GITHUB_REF_NAME#v} - IFS=. read -r major minor patch <<< "${version}" - test -n "${major}" - test -n "${minor}" - test -n "${patch}" - image_name=ghcr.io/${GITHUB_REPOSITORY,,} - - { - echo "image-name=${image_name}" - echo "version=${version}" - echo "exact-tag=${version}" - echo "minor-tag=${major}.${minor}" - echo "major-tag=${major}" - echo "image-digest=${expected}" - echo "amd64-digest=${amd64_digest}" - echo "arm64-digest=${arm64_digest}" - } >> "${GITHUB_OUTPUT}" + with: + script: | + const crypto = require('crypto') + const fs = require('fs') + + const digestPattern = /^sha256:[0-9a-f]{64}$/ + const expected = String(process.env.EXPECTED_IMAGE_DIGEST ?? '').toLowerCase() + if (!digestPattern.test(expected)) { + throw new Error(`Invalid image digest: ${process.env.EXPECTED_IMAGE_DIGEST}`) + } + + const requireFile = (file) => { + let stat + try { + stat = fs.lstatSync(file) + } catch { + throw new Error(`Required OCI artifact file is missing: ${file}`) + } + if (!stat.isFile()) { + throw new Error(`Required OCI artifact path is not a regular file: ${file}`) + } + } + const readJson = (file) => { + requireFile(file) + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) + } catch (error) { + throw new Error(`Invalid JSON in ${file}: ${error.message}`) + } + } + + requireFile('oci-image/layout/oci-layout') + requireFile('oci-image/layout/index.json') + requireFile('oci-image/image-digest.txt') + const amd64Sbom = 'oci-image/sboms/sbom-x86_64.spdx.json' + const arm64Sbom = 'oci-image/sboms/sbom-aarch64.spdx.json' + readJson(amd64Sbom) + readJson(arm64Sbom) + + const indexBytes = fs.readFileSync('oci-image/layout/index.json') + const recorded = fs.readFileSync('oci-image/image-digest.txt', 'utf8').trim() + const computed = `sha256:${crypto.createHash('sha256').update(indexBytes).digest('hex')}` + if (recorded !== expected || computed !== expected) { + throw new Error( + `OCI index digest mismatch: expected ${expected}, ` + + `recorded ${recorded}, computed ${computed}.`, + ) + } + + const index = JSON.parse(indexBytes.toString('utf8')) + if ( + index.schemaVersion !== 2 || + index.mediaType !== 'application/vnd.oci.image.index.v1+json' || + !Array.isArray(index.manifests) || + index.manifests.length !== 2 + ) { + throw new Error('OCI index must contain exactly two platform manifests.') + } + + const platforms = index.manifests + .map((manifest) => `${manifest.platform?.os}/${manifest.platform?.architecture}`) + .sort() + if (platforms.join(',') !== 'linux/amd64,linux/arm64') { + throw new Error(`Unexpected OCI platforms: ${platforms.join(', ')}`) + } + + const descriptor = (architecture) => index.manifests.find( + (manifest) => + manifest.platform.os === 'linux' && + manifest.platform.architecture === architecture, + ) + const amd64Digest = String(descriptor('amd64').digest ?? '') + const arm64Digest = String(descriptor('arm64').digest ?? '') + for (const digest of [amd64Digest, arm64Digest]) { + if (!digestPattern.test(digest)) { + throw new Error(`Invalid platform manifest digest: ${digest}`) + } + requireFile(`oci-image/layout/blobs/sha256/${digest.slice('sha256:'.length)}`) + } + + const version = String(process.env.GITHUB_REF_NAME ?? '').replace(/^v/, '') + const [major, minor, patch, ...extra] = version.split('.') + if (!major || !minor || !patch || extra.length !== 0) { + throw new Error(`Invalid stable version: ${version}`) + } + const imageName = + `ghcr.io/${context.repo.owner.toLowerCase()}/${context.repo.repo.toLowerCase()}` + + core.setOutput('image-name', imageName) + core.setOutput('version', version) + core.setOutput('exact-tag', version) + core.setOutput('minor-tag', `${major}.${minor}`) + core.setOutput('major-tag', major) + core.setOutput('image-digest', expected) + core.setOutput('amd64-digest', amd64Digest) + core.setOutput('arm64-digest', arm64Digest) - name: Log in to GHCR if: inputs.publish-image + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GHCR_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - printf '%s' "${GHCR_TOKEN}" | - oras login ghcr.io --username "${GITHUB_ACTOR}" --password-stdin + with: + script: | + const token = String(process.env.GHCR_TOKEN ?? '') + if (!token) { + throw new Error('GitHub token is empty.') + } + core.setSecret(token) + await exec.exec( + 'oras', + ['login', 'ghcr.io', '--username', context.actor, '--password-stdin'], + {input: Buffer.from(`${token}\n`)}, + ) - name: Plan OCI image tags if: inputs.publish-image id: tags + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: EXACT_TAG: ${{ steps.stage.outputs.exact-tag }} EXPECTED_DIGEST: ${{ steps.stage.outputs.image-digest }} @@ -216,189 +259,226 @@ jobs: MAJOR_TAG: ${{ steps.stage.outputs.major-tag }} MINOR_TAG: ${{ steps.stage.outputs.minor-tag }} VERSION: ${{ steps.stage.outputs.version }} - shell: bash - run: | - set -euo pipefail - - resolve_optional() { - local reference=$1 - local error_file=${RUNNER_TEMP}/oras-resolve-error - local digest - local message - local status - - if digest=$(oras resolve "${reference}" 2>"${error_file}"); then - printf '%s\n' "${digest}" + with: + script: | + const stableVersion = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/ + const imageName = process.env.IMAGE_NAME + const expectedDigest = process.env.EXPECTED_DIGEST + const version = process.env.VERSION + const tags = [] + + const runOras = async (args, missingIsNull = false) => { + const result = await exec.getExecOutput('oras', args, { + ignoreReturnCode: true, + silent: true, + }) + if (result.exitCode === 0) { + return result.stdout.trim() + } + if ( + missingIsNull && + result.exitCode === 1 && + result.stderr.includes(': not found') + ) { + return null + } + throw new Error( + `oras ${args[0]} failed with exit code ${result.exitCode}: ` + + result.stderr.trim(), + ) + } + const resolve = (reference) => runOras(['resolve', reference], true) + const parseVersion = (value) => { + const match = stableVersion.exec(String(value ?? '')) + if (!match) { + throw new Error(`Invalid stable version annotation: ${value}`) + } + return match.slice(1).map((component) => BigInt(component)) + } + const compareVersions = (left, right) => { + const leftParts = parseVersion(left) + const rightParts = parseVersion(right) + for (let index = 0; index < leftParts.length; index += 1) { + if (leftParts[index] > rightParts[index]) { + return 1 + } + if (leftParts[index] < rightParts[index]) { + return -1 + } + } return 0 - else - status=$? - message=$(<"${error_file}") - if [[ "${status}" -eq 1 && "${message}" == *': not found' ]]; then - return 1 - fi - printf '%s\n' "${message}" >&2 - return "${status}" - fi - } - - component_is_greater() { - local left=$1 - local right=$2 - - if (( ${#left} != ${#right} )); then - (( ${#left} > ${#right} )) - return - fi - [[ "${left}" > "${right}" ]] - } - - version_is_newer() { - local candidate=$1 - local current=$2 - local index - local -a candidate_parts - local -a current_parts - - IFS=. read -ra candidate_parts <<< "${candidate}" - IFS=. read -ra current_parts <<< "${current}" - for index in 0 1 2; do - if component_is_greater "${candidate_parts[index]}" "${current_parts[index]}"; then - return 0 - fi - if component_is_greater "${current_parts[index]}" "${candidate_parts[index]}"; then - return 1 - fi - done - return 1 - } - - stable_version='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' - tags=() - - if current=$(resolve_optional "${IMAGE_NAME}:${EXACT_TAG}"); then - if [[ "${current}" != "${EXPECTED_DIGEST}" ]]; then - echo "::error::Immutable tag ${IMAGE_NAME}:${EXACT_TAG} resolves to ${current}; expected ${EXPECTED_DIGEST}." - exit 1 - fi - echo "Exact-version tag ${EXACT_TAG} already resolves to the authoritative digest." - else - status=$? - if [[ "${status}" -ne 1 ]]; then - exit "${status}" - fi - tags+=("${EXACT_TAG}") - fi - - plan_channel_tag() { - local tag=$1 - local scope=$2 - local current - local current_version - - if current=$(resolve_optional "${IMAGE_NAME}:${tag}"); then - if [[ "${current}" == "${EXPECTED_DIGEST}" ]]; then - echo "Channel tag ${tag} already resolves to the authoritative digest." - return - fi + } - current_version=$( - oras manifest fetch "${IMAGE_NAME}:${tag}" | - jq -er '.annotations["org.opencontainers.image.version"]' + const exactTag = process.env.EXACT_TAG + const exactDigest = await resolve(`${imageName}:${exactTag}`) + if (exactDigest === null) { + tags.push(exactTag) + } else if (exactDigest !== expectedDigest) { + throw new Error( + `Immutable tag ${imageName}:${exactTag} resolves to ${exactDigest}; ` + + `expected ${expectedDigest}.`, + ) + } else { + core.info( + `Exact-version tag ${exactTag} already resolves to the authoritative digest.`, ) - if [[ ! "${current_version}" =~ ${stable_version} ]]; then - echo "::error::Channel tag ${IMAGE_NAME}:${tag} has invalid version annotation ${current_version}." - exit 1 - fi - case "${scope}" in - minor) - if [[ "${current_version}" != "${MINOR_TAG}."* ]]; then - echo "::error::Channel tag ${tag} points outside its minor release line: ${current_version}." - exit 1 - fi - ;; - major) - if [[ "${current_version}" != "${MAJOR_TAG}."* ]]; then - echo "::error::Channel tag ${tag} points outside its major release line: ${current_version}." - exit 1 - fi - ;; - latest) ;; - *) - echo "::error::Unknown channel scope ${scope}." - exit 1 - ;; - esac - - if version_is_newer "${VERSION}" "${current_version}"; then - tags+=("${tag}") - else - echo "Keeping ${IMAGE_NAME}:${tag} at newer release ${current_version}." - fi - else - status=$? - if [[ "${status}" -ne 1 ]]; then - exit "${status}" - fi - tags+=("${tag}") - fi - } - - plan_channel_tag "${MINOR_TAG}" minor - plan_channel_tag "${MAJOR_TAG}" major - plan_channel_tag latest latest - - tag_list=$(IFS=,; printf '%s' "${tags[*]}") - echo "tags=${tag_list}" >> "${GITHUB_OUTPUT}" + } + + const candidateParts = parseVersion(version) + const planChannel = async (tag, scope) => { + const currentDigest = await resolve(`${imageName}:${tag}`) + if (currentDigest === null) { + tags.push(tag) + return + } + if (currentDigest === expectedDigest) { + core.info(`Channel tag ${tag} already resolves to the authoritative digest.`) + return + } + + const manifest = JSON.parse(await runOras([ + 'manifest', + 'fetch', + `${imageName}:${tag}`, + ])) + const currentVersion = manifest.annotations?.['org.opencontainers.image.version'] + const currentParts = parseVersion(currentVersion) + if ( + scope === 'minor' && + ( + currentParts[0] !== candidateParts[0] || + currentParts[1] !== candidateParts[1] + ) + ) { + throw new Error( + `Channel tag ${tag} points outside its minor release line: ${currentVersion}.`, + ) + } + if (scope === 'major' && currentParts[0] !== candidateParts[0]) { + throw new Error( + `Channel tag ${tag} points outside its major release line: ${currentVersion}.`, + ) + } + if (!['minor', 'major', 'latest'].includes(scope)) { + throw new Error(`Unknown channel scope ${scope}.`) + } + + const comparison = compareVersions(version, currentVersion) + if (comparison > 0) { + tags.push(tag) + } else if (comparison < 0) { + core.info( + `Keeping ${imageName}:${tag} at newer release ${currentVersion}.`, + ) + } else { + throw new Error( + `Channel tag ${imageName}:${tag} has version ${currentVersion} ` + + `but resolves to ${currentDigest}; expected ${expectedDigest}.`, + ) + } + } + + await planChannel(process.env.MINOR_TAG, 'minor') + await planChannel(process.env.MAJOR_TAG, 'major') + await planChannel('latest', 'latest') + core.setOutput('tags', tags.join(',')) - name: Publish OCI image by digest if: inputs.publish-image id: push + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: EXPECTED_DIGEST: ${{ steps.stage.outputs.image-digest }} IMAGE_NAME: ${{ steps.stage.outputs.image-name }} - shell: bash - run: | - set -euo pipefail - - while IFS=$'\t' read -r manifest_digest media_type; do - manifest_path="oci-image/layout/blobs/sha256/${manifest_digest#sha256:}" - - while IFS= read -r blob_digest; do - oras blob push \ - --no-tty \ - "${IMAGE_NAME}@${blob_digest}" \ - "oci-image/layout/blobs/sha256/${blob_digest#sha256:}" - done < <(jq -r '.config.digest, .layers[].digest' "${manifest_path}") - - oras manifest push \ - --media-type "${media_type}" \ - "${IMAGE_NAME}@${manifest_digest}" \ - "${manifest_path}" - done < <(jq -r '.manifests[] | [.digest, .mediaType] | @tsv' oci-image/layout/index.json) - - oras manifest push \ - --media-type application/vnd.oci.image.index.v1+json \ - "${IMAGE_NAME}@${EXPECTED_DIGEST}" \ - oci-image/layout/index.json - - actual=$(oras resolve "${IMAGE_NAME}@${EXPECTED_DIGEST}") - if [[ "${actual}" != "${EXPECTED_DIGEST}" ]]; then - echo "::error::Published ${IMAGE_NAME}@${EXPECTED_DIGEST} resolved to ${actual}." - exit 1 - fi - - { - echo "digest=${EXPECTED_DIGEST}" - echo "reference=${IMAGE_NAME}@${EXPECTED_DIGEST}" - } >> "${GITHUB_OUTPUT}" + with: + script: | + const fs = require('fs') + const path = require('path') + const expectedDigest = process.env.EXPECTED_DIGEST + const imageName = process.env.IMAGE_NAME + const indexPath = 'oci-image/layout/index.json' + const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')) + const pushedBlobs = new Set() + + for (const descriptor of index.manifests) { + const manifestPath = path.join( + 'oci-image', + 'layout', + 'blobs', + 'sha256', + descriptor.digest.slice('sha256:'.length), + ) + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + const blobDigests = [ + manifest.config?.digest, + ...(manifest.layers ?? []).map((layer) => layer.digest), + ] + for (const digest of blobDigests) { + if (!digest || pushedBlobs.has(digest)) { + continue + } + await exec.exec('oras', [ + 'blob', + 'push', + '--no-tty', + `${imageName}@${digest}`, + path.join( + 'oci-image', + 'layout', + 'blobs', + 'sha256', + digest.slice('sha256:'.length), + ), + ]) + pushedBlobs.add(digest) + } + await exec.exec('oras', [ + 'manifest', + 'push', + '--media-type', + descriptor.mediaType, + `${imageName}@${descriptor.digest}`, + manifestPath, + ]) + } + + await exec.exec('oras', [ + 'manifest', + 'push', + '--media-type', + 'application/vnd.oci.image.index.v1+json', + `${imageName}@${expectedDigest}`, + indexPath, + ]) + const resolved = await exec.getExecOutput( + 'oras', + ['resolve', `${imageName}@${expectedDigest}`], + {silent: true}, + ) + const actual = resolved.stdout.trim() + if (actual !== expectedDigest) { + throw new Error( + `Published ${imageName}@${expectedDigest} resolved to ${actual}.`, + ) + } + + core.setOutput('digest', expectedDigest) + core.setOutput('reference', `${imageName}@${expectedDigest}`) - name: Sign OCI image and platform manifests if: inputs.publish-image + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: IMAGE_REFERENCE: ${{ steps.push.outputs.reference }} - shell: bash - run: cosign sign --yes --recursive "${IMAGE_REFERENCE}" + with: + script: | + await exec.exec('cosign', [ + 'sign', + '--yes', + '--recursive', + process.env.IMAGE_REFERENCE, + ]) - name: Attest OCI image provenance if: inputs.publish-image @@ -430,38 +510,60 @@ jobs: push-to-registry: true - name: Publish verified OCI image tags if: inputs.publish-image + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: EXACT_TAG: ${{ steps.stage.outputs.exact-tag }} EXPECTED_DIGEST: ${{ steps.stage.outputs.image-digest }} IMAGE_NAME: ${{ steps.stage.outputs.image-name }} IMAGE_TAGS: ${{ steps.tags.outputs.tags }} - shell: bash - run: | - set -euo pipefail - - if [[ -n "${IMAGE_TAGS}" ]]; then - IFS=, read -ra tags <<< "${IMAGE_TAGS}" - oras tag \ - --concurrency 1 \ - "${IMAGE_NAME}@${EXPECTED_DIGEST}" \ - "${tags[@]}" - - for tag in "${tags[@]}"; do - actual=$(oras resolve "${IMAGE_NAME}:${tag}") - if [[ "${actual}" != "${EXPECTED_DIGEST}" ]]; then - echo "::error::Published ${IMAGE_NAME}:${tag} resolved to ${actual}; expected ${EXPECTED_DIGEST}." - exit 1 - fi - done - fi - - exact=$(oras resolve "${IMAGE_NAME}:${EXACT_TAG}") - if [[ "${exact}" != "${EXPECTED_DIGEST}" ]]; then - echo "::error::Exact tag ${IMAGE_NAME}:${EXACT_TAG} resolves to ${exact}; expected ${EXPECTED_DIGEST}." - exit 1 - fi + with: + script: | + const expectedDigest = process.env.EXPECTED_DIGEST + const imageName = process.env.IMAGE_NAME + const tags = String(process.env.IMAGE_TAGS ?? '') + .split(',') + .filter(Boolean) + + if (tags.length > 0) { + await exec.exec('oras', [ + 'tag', + '--concurrency', + '1', + `${imageName}@${expectedDigest}`, + ...tags, + ]) + for (const tag of tags) { + const resolved = await exec.getExecOutput( + 'oras', + ['resolve', `${imageName}:${tag}`], + {silent: true}, + ) + const actual = resolved.stdout.trim() + if (actual !== expectedDigest) { + throw new Error( + `Published ${imageName}:${tag} resolved to ${actual}; ` + + `expected ${expectedDigest}.`, + ) + } + } + } + + const exact = await exec.getExecOutput( + 'oras', + ['resolve', `${imageName}:${process.env.EXACT_TAG}`], + {silent: true}, + ) + const exactDigest = exact.stdout.trim() + if (exactDigest !== expectedDigest) { + throw new Error( + `Exact tag ${imageName}:${process.env.EXACT_TAG} resolves to ` + + `${exactDigest}; expected ${expectedDigest}.`, + ) + } - name: Log out of GHCR if: always() && inputs.publish-image - shell: bash - run: oras logout ghcr.io + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + await exec.exec('oras', ['logout', 'ghcr.io']) From 922dcaab119b43503cf26425040ef6abfe9925d9 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 12:19:54 -0700 Subject: [PATCH 46/46] docs(release): pin GitHub Script OCI publisher --- README.md | 2 +- docs/how-to/configure-github-releases.md | 8 +++---- docs/how-to/configure-oci-images.md | 6 ++--- .../rehearse-and-recover-github-releases.md | 2 +- .../upgrade-github-release-workflows.md | 4 ++-- docs/reference/github-release-contract.md | 22 +++++++++---------- docs/reference/oci-image-contract.md | 10 +++++---- .../go-release/.github/workflows/release.yml | 10 ++++----- examples/go-release/README.md | 4 ++-- 9 files changed, 35 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 171e199..2d5ee9e 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,4 @@ This repository defines the reusable workflows and repository contract that Meig - [OCI image contract reference](docs/reference/oci-image-contract.md) - [Copyable Go release example](examples/go-release/) -Consumer repositories call the reusable workflows at a full commit SHA. The documented revision is `052e8277da00bf6369093ed8736cf5d21195d843`. +Consumer repositories call the reusable workflows at a full commit SHA. The documented revision is `fb8c8098ff27968fb3070e928c00e925f38c698e`. diff --git a/docs/how-to/configure-github-releases.md b/docs/how-to/configure-github-releases.md index f55e7d3..552c70b 100644 --- a/docs/how-to/configure-github-releases.md +++ b/docs/how-to/configure-github-releases.md @@ -100,8 +100,8 @@ In the copied files, replace the example values with values from the consumer re Do not replace these shared contract values: -- reusable workflow revision `052e8277da00bf6369093ed8736cf5d21195d843`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843`; +- reusable workflow revision `fb8c8098ff27968fb3070e928c00e925f38c698e`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e`; - variable name `MEIGMA_RELEASE_APP_CLIENT_ID`; or - secret name `MEIGMA_RELEASE_APP_PRIVATE_KEY`. @@ -227,7 +227,7 @@ Verify that the checksum manifest was signed by the canonical reusable pre-publi ```bash mise exec -- cosign verify-blob \ --bundle checksums.txt.sigstore.json \ - --certificate-identity 'https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843' \ + --certificate-identity 'https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ checksums.txt ``` @@ -243,7 +243,7 @@ while IFS= read -r entry; do mise exec -- gh attestation verify "$asset" \ --repo "$REPOSITORY" \ --signer-workflow meigma/release/.github/workflows/publish-github-release.yml \ - --signer-digest 052e8277da00bf6369093ed8736cf5d21195d843 \ + --signer-digest fb8c8098ff27968fb3070e928c00e925f38c698e \ --source-ref "refs/tags/$TAG" \ --deny-self-hosted-runners done < checksums.txt diff --git a/docs/how-to/configure-oci-images.md b/docs/how-to/configure-oci-images.md index 426c9a7..9649d3c 100644 --- a/docs/how-to/configure-oci-images.md +++ b/docs/how-to/configure-oci-images.md @@ -2,7 +2,7 @@ Use this guide to add signed, multi-architecture GHCR images to a repository that already uses the Meigma Go release workflows. The [OCI image contract](../reference/oci-image-contract.md) defines the reusable workflow interfaces, image contents, tags, signatures, attestations, and recovery behavior. -The documented workflow revision is `052e8277da00bf6369093ed8736cf5d21195d843`. +The documented workflow revision is `fb8c8098ff27968fb3070e928c00e925f38c698e`. ## Prerequisites @@ -20,7 +20,7 @@ Record the consumer repository and immutable workflow revision: ```bash export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -export RELEASE_REVISION=052e8277da00bf6369093ed8736cf5d21195d843 +export RELEASE_REVISION=fb8c8098ff27968fb3070e928c00e925f38c698e export IMAGE="ghcr.io/${REPOSITORY,,}" ``` @@ -99,7 +99,7 @@ permissions: Pin every reusable workflow to the same full revision: ```text -052e8277da00bf6369093ed8736cf5d21195d843 +fb8c8098ff27968fb3070e928c00e925f38c698e ``` Make `github-release` depend on `oci-publish`. That ordering keeps the GitHub Release in draft state when registry publication, signing, or attestation fails. diff --git a/docs/how-to/rehearse-and-recover-github-releases.md b/docs/how-to/rehearse-and-recover-github-releases.md index b860f3b..7a3e015 100644 --- a/docs/how-to/rehearse-and-recover-github-releases.md +++ b/docs/how-to/rehearse-and-recover-github-releases.md @@ -287,7 +287,7 @@ If the repository does not need a correction, rerun the complete top-level workf For the documented current revision, the publisher requires a nonempty `checksums.txt`, the exact closed payload list, matching payload hashes, a regular Cosign bundle file, issuer `https://token.actions.githubusercontent.com`, and this certificate identity: ```text -https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 +https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e ``` For an upgrade rehearsal, replace the current-revision identity with the target value described in [Upgrade GitHub Release workflows](upgrade-github-release-workflows.md). diff --git a/docs/how-to/upgrade-github-release-workflows.md b/docs/how-to/upgrade-github-release-workflows.md index 35c728e..6b3ca2c 100644 --- a/docs/how-to/upgrade-github-release-workflows.md +++ b/docs/how-to/upgrade-github-release-workflows.md @@ -1,6 +1,6 @@ # Upgrade GitHub Release workflows -Use this guide to move a consumer repository from the current workflow revision, `052e8277da00bf6369093ed8736cf5d21195d843`, to a reviewed immutable revision. Do not use a branch or tag as a reusable workflow reference. The [GitHub Release contract](../reference/github-release-contract.md) and [OCI image contract](../reference/oci-image-contract.md) define the current interfaces and publication boundaries. +Use this guide to move a consumer repository from the current workflow revision, `fb8c8098ff27968fb3070e928c00e925f38c698e`, to a reviewed immutable revision. Do not use a branch or tag as a reusable workflow reference. The [GitHub Release contract](../reference/github-release-contract.md) and [OCI image contract](../reference/oci-image-contract.md) define the current interfaces and publication boundaries. ## Prerequisites @@ -16,7 +16,7 @@ Record the consumer, the current baseline, and a local checkout of `meigma/relea ```bash export REPOSITORY="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -export CURRENT_RELEASE_REVISION=052e8277da00bf6369093ed8736cf5d21195d843 +export CURRENT_RELEASE_REVISION=fb8c8098ff27968fb3070e928c00e925f38c698e read -r -p 'Reviewed full meigma/release commit SHA: ' NEW_RELEASE_REVISION export NEW_RELEASE_REVISION read -r -p 'Path to the meigma/release checkout: ' RELEASE_CHECKOUT diff --git a/docs/reference/github-release-contract.md b/docs/reference/github-release-contract.md index 05eed55..0ab0334 100644 --- a/docs/reference/github-release-contract.md +++ b/docs/reference/github-release-contract.md @@ -1,6 +1,6 @@ # GitHub release contract reference -This page defines the cross-repository contract for the reusable Go producer and GitHub Release publisher at revision `052e8277da00bf6369093ed8736cf5d21195d843`. +This page defines the cross-repository contract for the reusable Go producer and GitHub Release publisher at revision `fb8c8098ff27968fb3070e928c00e925f38c698e`. For configuration steps, see [Configure GitHub releases](../how-to/configure-github-releases.md). For draft rehearsals and recovery steps, see [Rehearse and recover GitHub releases](../how-to/rehearse-and-recover-github-releases.md). The [OCI image contract](oci-image-contract.md) defines the image builder and publisher that gate the complete delivery caller. To adopt another immutable revision, see [Upgrade GitHub release workflows](../how-to/upgrade-github-release-workflows.md). A complete consumer repository is available in the [Go release example](../../examples/go-release/). @@ -9,17 +9,17 @@ For configuration steps, see [Configure GitHub releases](../how-to/configure-git The complete caller pins all four reusable workflows to one full revision. The GitHub Release path directly calls the producer and GitHub publisher: ```yaml -uses: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 +uses: meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e ``` ```yaml -uses: meigma/release/.github/workflows/publish-github-release.yml@052e8277da00bf6369093ed8736cf5d21195d843 +uses: meigma/release/.github/workflows/publish-github-release.yml@fb8c8098ff27968fb3070e928c00e925f38c698e ``` The checksum signer identity input must name the same producer workflow revision: ```yaml -checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 +checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e ``` ## Caller contract @@ -47,7 +47,7 @@ jobs: permissions: contents: read id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 + uses: meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e oci-image: name: Build OCI image @@ -55,7 +55,7 @@ jobs: permissions: actions: read contents: read - uses: meigma/release/.github/workflows/go-oci-build.yml@052e8277da00bf6369093ed8736cf5d21195d843 + uses: meigma/release/.github/workflows/go-oci-build.yml@fb8c8098ff27968fb3070e928c00e925f38c698e with: artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} @@ -70,7 +70,7 @@ jobs: contents: read id-token: write packages: write - uses: meigma/release/.github/workflows/publish-oci-image.yml@052e8277da00bf6369093ed8736cf5d21195d843 + uses: meigma/release/.github/workflows/publish-oci-image.yml@fb8c8098ff27968fb3070e928c00e925f38c698e with: artifact-id: ${{ needs.oci-image.outputs.artifact-id }} artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} @@ -89,11 +89,11 @@ jobs: attestations: write contents: read id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@052e8277da00bf6369093ed8736cf5d21195d843 + uses: meigma/release/.github/workflows/publish-github-release.yml@fb8c8098ff27968fb3070e928c00e925f38c698e with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e require-oci-image: true oci-image-reference: ${{ needs.oci-publish.outputs.image-reference }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} @@ -296,14 +296,14 @@ The checksum signature is accepted only when Cosign verifies all of the followin | Field | Required value | | --- | --- | -| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843` | +| Certificate identity | `https://github.com/meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e` | | Certificate OIDC issuer | `https://token.actions.githubusercontent.com` | | Signed blob | `checksums.txt` | | Bundle | `checksums.txt.sigstore.json` | The exact identity comes from `checksum-signing-workflow-ref`; a branch name, tag name, different commit, or different workflow path does not satisfy the documented identity. -The publisher at `meigma/release/.github/workflows/publish-github-release.yml@052e8277da00bf6369093ed8736cf5d21195d843` creates GitHub build-provenance attestations in the consumer repository. Its job token creates attestations but has only `contents: read`. A short-lived Release App installation token with `contents: write` performs draft discovery, asset upload, and the final draft-state change. +The publisher at `meigma/release/.github/workflows/publish-github-release.yml@fb8c8098ff27968fb3070e928c00e925f38c698e` creates GitHub build-provenance attestations in the consumer repository. Its job token creates attestations but has only `contents: read`. A short-lived Release App installation token with `contents: write` performs draft discovery, asset upload, and the final draft-state change. ## Publication states diff --git a/docs/reference/oci-image-contract.md b/docs/reference/oci-image-contract.md index b01d736..f367971 100644 --- a/docs/reference/oci-image-contract.md +++ b/docs/reference/oci-image-contract.md @@ -1,6 +1,6 @@ # OCI image contract -This page defines the cross-repository contract for the reusable Go OCI builder and GHCR publisher at revision `052e8277da00bf6369093ed8736cf5d21195d843`. +This page defines the cross-repository contract for the reusable Go OCI builder and GHCR publisher at revision `fb8c8098ff27968fb3070e928c00e925f38c698e`. For adoption steps, see [Configure OCI image publication](../how-to/configure-oci-images.md). The [GitHub Release contract](github-release-contract.md) defines the upstream GoReleaser producer and GitHub Release publisher. A complete consumer is available in the [Go release example](../../examples/go-release/). @@ -26,11 +26,11 @@ The image builder consumes prebuilt GoReleaser binaries. Melange packages them w Consumers call both workflows at the same immutable revision: ```yaml -uses: meigma/release/.github/workflows/go-oci-build.yml@052e8277da00bf6369093ed8736cf5d21195d843 +uses: meigma/release/.github/workflows/go-oci-build.yml@fb8c8098ff27968fb3070e928c00e925f38c698e ``` ```yaml -uses: meigma/release/.github/workflows/publish-oci-image.yml@052e8277da00bf6369093ed8736cf5d21195d843 +uses: meigma/release/.github/workflows/publish-oci-image.yml@fb8c8098ff27968fb3070e928c00e925f38c698e ``` Moving branches and tags are not supported workflow references. @@ -211,7 +211,7 @@ The publisher signs the index and both platform manifests with Cosign keyless si | Field | Value | | --- | --- | -| Certificate identity | `https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@052e8277da00bf6369093ed8736cf5d21195d843` | +| Certificate identity | `https://github.com/meigma/release/.github/workflows/publish-oci-image.yml@fb8c8098ff27968fb3070e928c00e925f38c698e` | | Certificate OIDC issuer | `https://token.actions.githubusercontent.com` | | Subject | Digest-pinned image or platform manifest. | @@ -254,6 +254,8 @@ The current boundary is deliberately split: - `publish-oci-image.yml` does not check out consumer source and writes only to the caller's GHCR package and attestation store; and - `publish-github-release.yml` waits for image publication but uses a separate short-lived Release App token for release mutation. +The privileged publisher implements validation and orchestration with the pinned `actions/github-script` action. It invokes ORAS and Cosign with explicit argument arrays through `@actions/exec`; release metadata is not interpolated into shell programs. + The workflow artifact is temporary transport, not a public distribution channel. The OCI digest, registry content, Cosign identity, and attestation identities form the public verification boundary. ## Unsupported cases diff --git a/examples/go-release/.github/workflows/release.yml b/examples/go-release/.github/workflows/release.yml index b30cdff..9125f4c 100644 --- a/examples/go-release/.github/workflows/release.yml +++ b/examples/go-release/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: permissions: contents: read id-token: write - uses: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 + uses: meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e oci-image: name: Build OCI image @@ -26,7 +26,7 @@ jobs: permissions: actions: read contents: read - uses: meigma/release/.github/workflows/go-oci-build.yml@052e8277da00bf6369093ed8736cf5d21195d843 + uses: meigma/release/.github/workflows/go-oci-build.yml@fb8c8098ff27968fb3070e928c00e925f38c698e with: artifact-id: ${{ needs.release-assets.outputs.oci-input-artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.oci-input-artifact-digest }} @@ -40,7 +40,7 @@ jobs: contents: read id-token: write packages: write - uses: meigma/release/.github/workflows/publish-oci-image.yml@052e8277da00bf6369093ed8736cf5d21195d843 + uses: meigma/release/.github/workflows/publish-oci-image.yml@fb8c8098ff27968fb3070e928c00e925f38c698e with: artifact-id: ${{ needs.oci-image.outputs.artifact-id }} artifact-digest: ${{ needs.oci-image.outputs.artifact-digest }} @@ -60,11 +60,11 @@ jobs: attestations: write contents: read id-token: write - uses: meigma/release/.github/workflows/publish-github-release.yml@052e8277da00bf6369093ed8736cf5d21195d843 + uses: meigma/release/.github/workflows/publish-github-release.yml@fb8c8098ff27968fb3070e928c00e925f38c698e with: artifact-id: ${{ needs.release-assets.outputs.artifact-id }} artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} - checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843 + checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e require-oci-image: true oci-image-reference: ${{ needs.oci-publish.outputs.image-reference }} release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} diff --git a/examples/go-release/README.md b/examples/go-release/README.md index 67eab96..e464a7c 100644 --- a/examples/go-release/README.md +++ b/examples/go-release/README.md @@ -42,8 +42,8 @@ Replace these project-specific example values: Keep these contract values unchanged: -- all four reusable workflow references at `052e8277da00bf6369093ed8736cf5d21195d843`; -- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@052e8277da00bf6369093ed8736cf5d21195d843`; +- all four reusable workflow references at `fb8c8098ff27968fb3070e928c00e925f38c698e`; +- `checksum-signing-workflow-ref` value `meigma/release/.github/workflows/go-pre-publish.yml@fb8c8098ff27968fb3070e928c00e925f38c698e`; - organization variable `MEIGMA_RELEASE_APP_CLIENT_ID`; - organization secret `MEIGMA_RELEASE_APP_PRIVATE_KEY`; and - the locked Go 1.26.6, GoReleaser 2.17.1, Syft 1.51.0, Cosign 3.1.3, GitHub CLI 2.97.0, Melange 0.59.1, and apko 1.2.37 versions unless the shared workflow contract is deliberately updated.