Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:

permissions:
contents: write
id-token: write

jobs:
release:
Expand Down Expand Up @@ -60,13 +61,93 @@ jobs:
git tag "${VERSION}"
git push origin "${BRANCH}" --tags

- name: Install cosign
uses: sigstore/cosign-installer@v3

- name: Build and release with goreleaser
uses: goreleaser/goreleaser-action@v6
with:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

- name: Generate binary checksums
run: |
VERSION="${{ github.event.inputs.version }}"
# Strip leading 'v' for goreleaser dist paths
VER="${VERSION#v}"
SUMFILE="dist/verda_${VER}_binary_SHA256SUMS"

# Find raw binaries produced by goreleaser and checksum them
for dir in dist/verda_*/; do
# Skip archive directories (contain .tar.gz or .zip artifacts)
[ -d "$dir" ] || continue
BIN=""
if [ -f "${dir}verda" ]; then
BIN="${dir}verda"
elif [ -f "${dir}verda.exe" ]; then
BIN="${dir}verda.exe"
else
continue
fi
# Compute checksum with path relative to dist/
RELPATH="${BIN#dist/}"
HASH=$(sha256sum "$BIN" | awk '{print $1}')
echo "${HASH} ${RELPATH}" >> "$SUMFILE"
done

# Dedup in case build and archive staging dirs both matched
sort -u -o "$SUMFILE" "$SUMFILE"

if [ ! -s "$SUMFILE" ]; then
echo "Error: no binary checksums generated"
exit 1
fi

echo "--- Binary checksums ---"
cat "$SUMFILE"

- name: Sign checksum files with cosign
# Keyless signing via GitHub OIDC. The certificate identity will be:
# https://github.com/verda-cloud/verda-cli/.github/workflows/release.yml@refs/heads/release/<ver>
#
# To verify manually:
# cosign verify-blob \
# --signature verda_<VER>_SHA256SUMS.sig \
# --certificate verda_<VER>_SHA256SUMS.pem \
# --certificate-identity-regexp "^https://github\.com/verda-cloud/verda-cli/\.github/workflows/release\.yml@refs/.*$" \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# verda_<VER>_SHA256SUMS
run: |
VERSION="${{ github.event.inputs.version }}"
VER="${VERSION#v}"

# Sign the archive checksums (produced by goreleaser)
cosign sign-blob --yes \
--output-signature "dist/verda_${VER}_SHA256SUMS.sig" \
--output-certificate "dist/verda_${VER}_SHA256SUMS.pem" \
"dist/verda_${VER}_SHA256SUMS"

# Sign the binary checksums
cosign sign-blob --yes \
--output-signature "dist/verda_${VER}_binary_SHA256SUMS.sig" \
--output-certificate "dist/verda_${VER}_binary_SHA256SUMS.pem" \
"dist/verda_${VER}_binary_SHA256SUMS"

- name: Upload signing artifacts to release
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
VERSION="${{ github.event.inputs.version }}"
VER="${VERSION#v}"

gh release upload "${VERSION}" \
"dist/verda_${VER}_binary_SHA256SUMS" \
"dist/verda_${VER}_binary_SHA256SUMS.sig" \
"dist/verda_${VER}_binary_SHA256SUMS.pem" \
"dist/verda_${VER}_SHA256SUMS.sig" \
"dist/verda_${VER}_SHA256SUMS.pem"

- name: Create PR to merge release into main
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
Expand Down
28 changes: 25 additions & 3 deletions internal/verda-cli/cmd/util/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/verda-cloud/verdacloud-sdk-go/pkg/verda"
"github.com/verda-cloud/verdagostack/pkg/tui"
_ "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" // registers bubbletea TUI backend
"github.com/verda-cloud/verdagostack/pkg/version"

clioptions "github/verda-cloud/verda-cli/internal/verda-cli/options"
)
Expand Down Expand Up @@ -46,11 +47,32 @@ type factoryImpl struct {
verda *verda.Client
}

// userAgentString returns a User-Agent header value like "verda-cli/v1.2.3".
func userAgentString() string {
return "verda-cli/" + version.Get().GitVersion
}

// userAgentTransport wraps an http.RoundTripper to inject a User-Agent header.
type userAgentTransport struct {
base http.RoundTripper
userAgent string
}

func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", t.userAgent)
}
return t.base.RoundTrip(req)
}

// NewFactory creates a Factory from the given Options.
func NewFactory(opts *clioptions.Options) Factory {
return &factoryImpl{
opts: opts,
client: &http.Client{Timeout: opts.Timeout},
opts: opts,
client: &http.Client{
Timeout: opts.Timeout,
Transport: &userAgentTransport{base: http.DefaultTransport, userAgent: userAgentString()},
},
prompter: tui.Default(),
status: tui.DefaultStatus(),
}
Expand Down Expand Up @@ -88,7 +110,7 @@ func (f *factoryImpl) VerdaClient() (*verda.Client, error) {
verda.WithClientID(auth.ClientID),
verda.WithClientSecret(auth.ClientSecret),
verda.WithHTTPClient(f.client),
verda.WithUserAgent("verda"),
verda.WithUserAgent(userAgentString()),
}
if auth.BearerToken != "" {
options = append(options, verda.WithAuthBearerToken(auth.BearerToken))
Expand Down
179 changes: 179 additions & 0 deletions internal/verda-cli/cmd/version/verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package version

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"

cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util"
)

// VerifyResult holds the outcome of a binary verification check.
type VerifyResult struct {
Version string `json:"version"`
Platform string `json:"platform"`
Match bool `json:"match"`
ExpectedHash string `json:"expectedHash"`
ActualHash string `json:"actualHash"`
}

// checksumURL returns the GitHub release URL for the checksums file.
func checksumURL(ver string) string {
bare := strings.TrimPrefix(ver, "v")
return fmt.Sprintf(
"https://github.com/verda-cloud/verda-cli/releases/download/%s/verda_%s_binary_SHA256SUMS",
ver, bare,
)
}

// fetchChecksums downloads the checksum file from the given URL.
func fetchChecksums(client *http.Client, url string) (string, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}

resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("fetching checksums: %w", err)
}
defer resp.Body.Close() //nolint:errcheck // best-effort close on read path

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("fetching checksums: HTTP %d from %s", resp.StatusCode, url)
}

body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB max
if err != nil {
return "", fmt.Errorf("reading checksums: %w", err)
}
return string(body), nil
}

// hashFile computes the SHA256 hex digest of the file at path.
func hashFile(path string) (string, error) {
f, err := os.Open(path) //nolint:gosec // path is from os.Executable, not user input
if err != nil {
return "", fmt.Errorf("opening binary: %w", err)
}
defer f.Close() //nolint:errcheck // best-effort close on read path

h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("hashing binary: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}

// parseChecksumLine parses a line like "<hash> <key>" into its parts.
// Returns false if the line is empty, a comment, or malformed.
func parseChecksumLine(line string) (hexStr, key string, ok bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", "", false
}
// Format: "<hash> <path>" (two spaces is conventional, but split on any whitespace)
parts := strings.Fields(line)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}

// findMatchingChecksum finds the expected hash for the given OS/arch from the
// checksum file body.
func findMatchingChecksum(body, goos, goarch string) (string, error) {
// The key format is: verda_<os>_<arch>/verda[.exe]
prefix := fmt.Sprintf("verda_%s_%s/", goos, goarch)

for _, line := range strings.Split(body, "\n") {
hexStr, key, ok := parseChecksumLine(line)
if !ok {
continue
}
if strings.HasPrefix(key, prefix) {
return hexStr, nil
}
}
return "", fmt.Errorf("no checksum entry found for %s/%s", goos, goarch)
}

// verifyBinary fetches the checksums and compares against the binary at binPath.
func verifyBinary(client *http.Client, binPath, url, goos, goarch string) (*VerifyResult, error) {
body, err := fetchChecksums(client, url)
if err != nil {
return nil, err
}

expected, err := findMatchingChecksum(body, goos, goarch)
if err != nil {
return nil, err
}

actual, err := hashFile(binPath)
if err != nil {
return nil, err
}

return &VerifyResult{
Platform: fmt.Sprintf("%s/%s", goos, goarch),
Match: actual == expected,
ExpectedHash: expected,
ActualHash: actual,
}, nil
}

// runVerify is the top-level verify logic, writing output to out and warnings
// to errOut. It uses the provided HTTP client for fetching checksums.
func runVerify(out, errOut io.Writer, outputFormat string, client *http.Client, ver, goos, goarch string) error {
if ver == "v0.0.0-dev" {
_, _ = fmt.Fprintln(errOut, "Warning: cannot verify a development build (v0.0.0-dev)")
return errors.New("cannot verify development build")
}

binPath, err := os.Executable()
if err != nil {
return fmt.Errorf("locating binary: %w", err)
}
binPath, err = filepath.EvalSymlinks(binPath)
if err != nil {
return fmt.Errorf("resolving binary path: %w", err)
}

url := checksumURL(ver)
result, err := verifyBinary(client, binPath, url, goos, goarch)
if err != nil {
return err
}
result.Version = ver

if wrote, werr := cmdutil.WriteStructured(out, outputFormat, result); wrote {
if !result.Match {
if werr != nil {
return werr
}
return errors.New("checksum mismatch")
}
return werr
}

if result.Match {
_, _ = fmt.Fprintf(out, "Verification successful: binary matches release checksum.\n")
_, _ = fmt.Fprintf(out, " Version: %s\n Platform: %s\n SHA256: %s\n",
result.Version, result.Platform, result.ActualHash)
} else {
_, _ = fmt.Fprintf(errOut, "Verification FAILED: binary does NOT match release checksum!\n")
_, _ = fmt.Fprintf(errOut, " Version: %s\n Platform: %s\n Expected: %s\n Actual: %s\n",
result.Version, result.Platform, result.ExpectedHash, result.ActualHash)
return errors.New("checksum mismatch")
}

return nil
}
Loading
Loading