Skip to content

Commit e2960a7

Browse files
authored
Feat/add deatils version command (#8)
* feat: add cosign release signing and version --verify flag Add binary integrity verification to the Verda CLI: - Release workflow: generate raw binary checksums, sign with cosign (keyless OIDC), and upload signing artifacts to GitHub Releases - Version command: add --verify flag that computes SHA256 of the running binary and compares against published checksums from GitHub Releases - Version command: show verdacloud-sdk-go and verdagostack dependency versions in output * feat: add versioned User-Agent header to all HTTP requests Set User-Agent to "verda-cli/<version>" on both the SDK client and the shared http.Client via a custom RoundTripper, enabling server-side analytics of CLI version distribution. * fix: address gosec and errcheck lint findings in verify
1 parent a533db7 commit e2960a7

5 files changed

Lines changed: 649 additions & 6 deletions

File tree

.github/workflows/release.yml

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010

1111
permissions:
1212
contents: write
13+
id-token: write
1314

1415
jobs:
1516
release:
@@ -60,13 +61,93 @@ jobs:
6061
git tag "${VERSION}"
6162
git push origin "${BRANCH}" --tags
6263
64+
- name: Install cosign
65+
uses: sigstore/cosign-installer@v3
66+
6367
- name: Build and release with goreleaser
6468
uses: goreleaser/goreleaser-action@v6
6569
with:
6670
args: release --clean
6771
env:
6872
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
6973

74+
- name: Generate binary checksums
75+
run: |
76+
VERSION="${{ github.event.inputs.version }}"
77+
# Strip leading 'v' for goreleaser dist paths
78+
VER="${VERSION#v}"
79+
SUMFILE="dist/verda_${VER}_binary_SHA256SUMS"
80+
81+
# Find raw binaries produced by goreleaser and checksum them
82+
for dir in dist/verda_*/; do
83+
# Skip archive directories (contain .tar.gz or .zip artifacts)
84+
[ -d "$dir" ] || continue
85+
BIN=""
86+
if [ -f "${dir}verda" ]; then
87+
BIN="${dir}verda"
88+
elif [ -f "${dir}verda.exe" ]; then
89+
BIN="${dir}verda.exe"
90+
else
91+
continue
92+
fi
93+
# Compute checksum with path relative to dist/
94+
RELPATH="${BIN#dist/}"
95+
HASH=$(sha256sum "$BIN" | awk '{print $1}')
96+
echo "${HASH} ${RELPATH}" >> "$SUMFILE"
97+
done
98+
99+
# Dedup in case build and archive staging dirs both matched
100+
sort -u -o "$SUMFILE" "$SUMFILE"
101+
102+
if [ ! -s "$SUMFILE" ]; then
103+
echo "Error: no binary checksums generated"
104+
exit 1
105+
fi
106+
107+
echo "--- Binary checksums ---"
108+
cat "$SUMFILE"
109+
110+
- name: Sign checksum files with cosign
111+
# Keyless signing via GitHub OIDC. The certificate identity will be:
112+
# https://github.com/verda-cloud/verda-cli/.github/workflows/release.yml@refs/heads/release/<ver>
113+
#
114+
# To verify manually:
115+
# cosign verify-blob \
116+
# --signature verda_<VER>_SHA256SUMS.sig \
117+
# --certificate verda_<VER>_SHA256SUMS.pem \
118+
# --certificate-identity-regexp "^https://github\.com/verda-cloud/verda-cli/\.github/workflows/release\.yml@refs/.*$" \
119+
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
120+
# verda_<VER>_SHA256SUMS
121+
run: |
122+
VERSION="${{ github.event.inputs.version }}"
123+
VER="${VERSION#v}"
124+
125+
# Sign the archive checksums (produced by goreleaser)
126+
cosign sign-blob --yes \
127+
--output-signature "dist/verda_${VER}_SHA256SUMS.sig" \
128+
--output-certificate "dist/verda_${VER}_SHA256SUMS.pem" \
129+
"dist/verda_${VER}_SHA256SUMS"
130+
131+
# Sign the binary checksums
132+
cosign sign-blob --yes \
133+
--output-signature "dist/verda_${VER}_binary_SHA256SUMS.sig" \
134+
--output-certificate "dist/verda_${VER}_binary_SHA256SUMS.pem" \
135+
"dist/verda_${VER}_binary_SHA256SUMS"
136+
137+
- name: Upload signing artifacts to release
138+
env:
139+
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
140+
run: |
141+
VERSION="${{ github.event.inputs.version }}"
142+
VER="${VERSION#v}"
143+
144+
gh release upload "${VERSION}" \
145+
"dist/verda_${VER}_binary_SHA256SUMS" \
146+
"dist/verda_${VER}_binary_SHA256SUMS.sig" \
147+
"dist/verda_${VER}_binary_SHA256SUMS.pem" \
148+
"dist/verda_${VER}_SHA256SUMS.sig" \
149+
"dist/verda_${VER}_SHA256SUMS.pem"
150+
70151
- name: Create PR to merge release into main
71152
env:
72153
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}

internal/verda-cli/cmd/util/factory.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/verda-cloud/verdacloud-sdk-go/pkg/verda"
99
"github.com/verda-cloud/verdagostack/pkg/tui"
1010
_ "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" // registers bubbletea TUI backend
11+
"github.com/verda-cloud/verdagostack/pkg/version"
1112

1213
clioptions "github/verda-cloud/verda-cli/internal/verda-cli/options"
1314
)
@@ -46,11 +47,32 @@ type factoryImpl struct {
4647
verda *verda.Client
4748
}
4849

50+
// userAgentString returns a User-Agent header value like "verda-cli/v1.2.3".
51+
func userAgentString() string {
52+
return "verda-cli/" + version.Get().GitVersion
53+
}
54+
55+
// userAgentTransport wraps an http.RoundTripper to inject a User-Agent header.
56+
type userAgentTransport struct {
57+
base http.RoundTripper
58+
userAgent string
59+
}
60+
61+
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
62+
if req.Header.Get("User-Agent") == "" {
63+
req.Header.Set("User-Agent", t.userAgent)
64+
}
65+
return t.base.RoundTrip(req)
66+
}
67+
4968
// NewFactory creates a Factory from the given Options.
5069
func NewFactory(opts *clioptions.Options) Factory {
5170
return &factoryImpl{
52-
opts: opts,
53-
client: &http.Client{Timeout: opts.Timeout},
71+
opts: opts,
72+
client: &http.Client{
73+
Timeout: opts.Timeout,
74+
Transport: &userAgentTransport{base: http.DefaultTransport, userAgent: userAgentString()},
75+
},
5476
prompter: tui.Default(),
5577
status: tui.DefaultStatus(),
5678
}
@@ -88,7 +110,7 @@ func (f *factoryImpl) VerdaClient() (*verda.Client, error) {
88110
verda.WithClientID(auth.ClientID),
89111
verda.WithClientSecret(auth.ClientSecret),
90112
verda.WithHTTPClient(f.client),
91-
verda.WithUserAgent("verda"),
113+
verda.WithUserAgent(userAgentString()),
92114
}
93115
if auth.BearerToken != "" {
94116
options = append(options, verda.WithAuthBearerToken(auth.BearerToken))
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package version
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"os"
12+
"path/filepath"
13+
"strings"
14+
15+
cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util"
16+
)
17+
18+
// VerifyResult holds the outcome of a binary verification check.
19+
type VerifyResult struct {
20+
Version string `json:"version"`
21+
Platform string `json:"platform"`
22+
Match bool `json:"match"`
23+
ExpectedHash string `json:"expectedHash"`
24+
ActualHash string `json:"actualHash"`
25+
}
26+
27+
// checksumURL returns the GitHub release URL for the checksums file.
28+
func checksumURL(ver string) string {
29+
bare := strings.TrimPrefix(ver, "v")
30+
return fmt.Sprintf(
31+
"https://github.com/verda-cloud/verda-cli/releases/download/%s/verda_%s_binary_SHA256SUMS",
32+
ver, bare,
33+
)
34+
}
35+
36+
// fetchChecksums downloads the checksum file from the given URL.
37+
func fetchChecksums(client *http.Client, url string) (string, error) {
38+
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
39+
if err != nil {
40+
return "", fmt.Errorf("creating request: %w", err)
41+
}
42+
43+
resp, err := client.Do(req)
44+
if err != nil {
45+
return "", fmt.Errorf("fetching checksums: %w", err)
46+
}
47+
defer resp.Body.Close() //nolint:errcheck // best-effort close on read path
48+
49+
if resp.StatusCode != http.StatusOK {
50+
return "", fmt.Errorf("fetching checksums: HTTP %d from %s", resp.StatusCode, url)
51+
}
52+
53+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB max
54+
if err != nil {
55+
return "", fmt.Errorf("reading checksums: %w", err)
56+
}
57+
return string(body), nil
58+
}
59+
60+
// hashFile computes the SHA256 hex digest of the file at path.
61+
func hashFile(path string) (string, error) {
62+
f, err := os.Open(path) //nolint:gosec // path is from os.Executable, not user input
63+
if err != nil {
64+
return "", fmt.Errorf("opening binary: %w", err)
65+
}
66+
defer f.Close() //nolint:errcheck // best-effort close on read path
67+
68+
h := sha256.New()
69+
if _, err := io.Copy(h, f); err != nil {
70+
return "", fmt.Errorf("hashing binary: %w", err)
71+
}
72+
return hex.EncodeToString(h.Sum(nil)), nil
73+
}
74+
75+
// parseChecksumLine parses a line like "<hash> <key>" into its parts.
76+
// Returns false if the line is empty, a comment, or malformed.
77+
func parseChecksumLine(line string) (hexStr, key string, ok bool) {
78+
line = strings.TrimSpace(line)
79+
if line == "" || strings.HasPrefix(line, "#") {
80+
return "", "", false
81+
}
82+
// Format: "<hash> <path>" (two spaces is conventional, but split on any whitespace)
83+
parts := strings.Fields(line)
84+
if len(parts) != 2 {
85+
return "", "", false
86+
}
87+
return parts[0], parts[1], true
88+
}
89+
90+
// findMatchingChecksum finds the expected hash for the given OS/arch from the
91+
// checksum file body.
92+
func findMatchingChecksum(body, goos, goarch string) (string, error) {
93+
// The key format is: verda_<os>_<arch>/verda[.exe]
94+
prefix := fmt.Sprintf("verda_%s_%s/", goos, goarch)
95+
96+
for _, line := range strings.Split(body, "\n") {
97+
hexStr, key, ok := parseChecksumLine(line)
98+
if !ok {
99+
continue
100+
}
101+
if strings.HasPrefix(key, prefix) {
102+
return hexStr, nil
103+
}
104+
}
105+
return "", fmt.Errorf("no checksum entry found for %s/%s", goos, goarch)
106+
}
107+
108+
// verifyBinary fetches the checksums and compares against the binary at binPath.
109+
func verifyBinary(client *http.Client, binPath, url, goos, goarch string) (*VerifyResult, error) {
110+
body, err := fetchChecksums(client, url)
111+
if err != nil {
112+
return nil, err
113+
}
114+
115+
expected, err := findMatchingChecksum(body, goos, goarch)
116+
if err != nil {
117+
return nil, err
118+
}
119+
120+
actual, err := hashFile(binPath)
121+
if err != nil {
122+
return nil, err
123+
}
124+
125+
return &VerifyResult{
126+
Platform: fmt.Sprintf("%s/%s", goos, goarch),
127+
Match: actual == expected,
128+
ExpectedHash: expected,
129+
ActualHash: actual,
130+
}, nil
131+
}
132+
133+
// runVerify is the top-level verify logic, writing output to out and warnings
134+
// to errOut. It uses the provided HTTP client for fetching checksums.
135+
func runVerify(out, errOut io.Writer, outputFormat string, client *http.Client, ver, goos, goarch string) error {
136+
if ver == "v0.0.0-dev" {
137+
_, _ = fmt.Fprintln(errOut, "Warning: cannot verify a development build (v0.0.0-dev)")
138+
return errors.New("cannot verify development build")
139+
}
140+
141+
binPath, err := os.Executable()
142+
if err != nil {
143+
return fmt.Errorf("locating binary: %w", err)
144+
}
145+
binPath, err = filepath.EvalSymlinks(binPath)
146+
if err != nil {
147+
return fmt.Errorf("resolving binary path: %w", err)
148+
}
149+
150+
url := checksumURL(ver)
151+
result, err := verifyBinary(client, binPath, url, goos, goarch)
152+
if err != nil {
153+
return err
154+
}
155+
result.Version = ver
156+
157+
if wrote, werr := cmdutil.WriteStructured(out, outputFormat, result); wrote {
158+
if !result.Match {
159+
if werr != nil {
160+
return werr
161+
}
162+
return errors.New("checksum mismatch")
163+
}
164+
return werr
165+
}
166+
167+
if result.Match {
168+
_, _ = fmt.Fprintf(out, "Verification successful: binary matches release checksum.\n")
169+
_, _ = fmt.Fprintf(out, " Version: %s\n Platform: %s\n SHA256: %s\n",
170+
result.Version, result.Platform, result.ActualHash)
171+
} else {
172+
_, _ = fmt.Fprintf(errOut, "Verification FAILED: binary does NOT match release checksum!\n")
173+
_, _ = fmt.Fprintf(errOut, " Version: %s\n Platform: %s\n Expected: %s\n Actual: %s\n",
174+
result.Version, result.Platform, result.ExpectedHash, result.ActualHash)
175+
return errors.New("checksum mismatch")
176+
}
177+
178+
return nil
179+
}

0 commit comments

Comments
 (0)