diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..584eb41 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: gofmt + run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; } + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: Release version rules + run: sh scripts/next-version_test.sh + + # Catches .goreleaser.yaml mistakes before they break a release + - uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: build --snapshot --clean --single-target diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 488da7e..1d6734f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,27 @@ name: Release +# Every push to main that contains a feat/fix/perf (or breaking) commit is +# released: the next version is computed from Conventional Commits since the +# last tag (scripts/next-version.sh), tagged, and published by GoReleaser to +# GitHub Releases and the javaBin/homebrew-tap formula, so `brew upgrade +# javabin` and `go install ...@latest` pick it up. A pushed v* tag releases +# that tag as before. + on: push: - tags: - - "v*" + branches: [main] + tags: ["v*"] + workflow_dispatch: permissions: contents: write +concurrency: + group: release + cancel-in-progress: false + jobs: - goreleaser: + release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -20,7 +32,40 @@ jobs: with: go-version-file: go.mod + - name: Test + run: go vet ./... && go test ./... + + - name: Decide version + id: version + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + run: | + if [ "$REF_TYPE" = "tag" ]; then + echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT" + exit 0 + fi + # A re-run after a failed publish: HEAD is already tagged + EXISTING=$(git tag --points-at HEAD 'v*' | head -1) + if [ -n "$EXISTING" ]; then + echo "tag=$EXISTING" >> "$GITHUB_OUTPUT" + echo "existing=true" >> "$GITHUB_OUTPUT" + else + echo "tag=$(sh scripts/next-version.sh)" >> "$GITHUB_OUTPUT" + fi + + # Tag pushes made with GITHUB_TOKEN don't start workflows, so the + # release runs in this same job rather than on the new tag. + - name: Tag release + if: steps.version.outputs.tag != '' && steps.version.outputs.existing != 'true' && github.ref == 'refs/heads/main' + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + git tag "$TAG" + git push origin "$TAG" + - uses: actions/create-github-app-token@v1 + if: steps.version.outputs.tag != '' && (github.ref == 'refs/heads/main' || github.ref_type == 'tag') id: app-token with: app-id: ${{ secrets.RELEASER_APP_ID }} @@ -29,9 +74,14 @@ jobs: repositories: homebrew-tap - uses: goreleaser/goreleaser-action@v6 + if: steps.version.outputs.tag != '' && (github.ref == 'refs/heads/main' || github.ref_type == 'tag') with: - version: latest + version: "~> v2" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Nothing to release + if: steps.version.outputs.tag == '' + run: echo "No feat/fix/perf commits since the last tag — no release." diff --git a/.goreleaser.yml b/.goreleaser.yml index d1535f3..4b047c8 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -12,7 +12,7 @@ builds: - amd64 - arm64 ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} + - -s -w -X github.com/javaBin/javabin-cli/cmd.version={{.Version}} -X github.com/javaBin/javabin-cli/cmd.commit={{.Commit}} archives: - format: tar.gz diff --git a/cmd/root.go b/cmd/root.go index b632999..9935bbc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,7 +1,20 @@ package cmd import ( + "fmt" + "runtime/debug" + "strings" + "github.com/spf13/cobra" + + jbaws "github.com/javaBin/javabin-cli/internal/aws" + "github.com/javaBin/javabin-cli/internal/update" +) + +// Set at build time by GoReleaser (-X .../cmd.version, cmd.commit). +var ( + version = "dev" + commit = "none" ) var rootCmd = &cobra.Command{ @@ -11,10 +24,21 @@ var rootCmd = &cobra.Command{ } func Execute() error { - return rootCmd.Execute() + // go install builds carry no ldflags; use the module version they were built from + if version == "dev" { + if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + version = strings.TrimPrefix(bi.Main.Version, "v") + } + } + rootCmd.Version = version + rootCmd.SetVersionTemplate(fmt.Sprintf("javabin {{.Version}} (%s)\n", commit)) + err := rootCmd.Execute() + update.PrintIfOutdated(version) + return err } func init() { + rootCmd.PersistentFlags().StringVar(&jbaws.Profile, "profile", "", "AWS profile (default: AWS_PROFILE, or the SSO profile for javabin-team- from app.yaml)") rootCmd.AddCommand(initCmd) rootCmd.AddCommand(registerCmd) rootCmd.AddCommand(statusCmd) diff --git a/cmd/secret.go b/cmd/secret.go index 19e6f46..235ee6b 100644 --- a/cmd/secret.go +++ b/cmd/secret.go @@ -96,7 +96,7 @@ var secretListCmd = &cobra.Command{ return err } ctx := context.Background() - cfg, err := jbaws.LoadConfig(ctx) + cfg, err := jbaws.LoadConfigForTeam(ctx, app.Team) if err != nil { return err } @@ -150,7 +150,7 @@ var secretSetCmd = &cobra.Command{ } ctx := context.Background() - cfg, err := jbaws.LoadConfig(ctx) + cfg, err := jbaws.LoadConfigForTeam(ctx, app.Team) if err != nil { return err } diff --git a/internal/aws/aws.go b/internal/aws/aws.go index 2da64be..3d96db3 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -24,7 +24,11 @@ const ( ) func LoadConfig(ctx context.Context) (aws.Config, error) { - return awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(defaultRegion)) + opts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(defaultRegion)} + if Profile != "" { + opts = append(opts, awsconfig.WithSharedConfigProfile(Profile)) + } + return awsconfig.LoadDefaultConfig(ctx, opts...) } // CallerIdentity holds STS identity info. diff --git a/internal/aws/profile.go b/internal/aws/profile.go new file mode 100644 index 0000000..cfa343e --- /dev/null +++ b/internal/aws/profile.go @@ -0,0 +1,113 @@ +package aws + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" +) + +// AccountID is the javaBin platform account. +const AccountID = "553637109631" + +// Profile is set from the global --profile flag. +var Profile string + +// LoadConfigForTeam loads AWS config for working on a team's resources. +// Profile resolution: --profile, then AWS_PROFILE, then the ~/.aws/config +// SSO profile for this account whose role is javabin-team-. Without +// any of those the SDK would fall through to EC2 instance metadata and fail +// with a confusing timeout, so we say what to do instead. +func LoadConfigForTeam(ctx context.Context, team string) (aws.Config, error) { + profile := Profile + if profile == "" && os.Getenv("AWS_PROFILE") == "" && os.Getenv("AWS_ACCESS_KEY_ID") == "" && team != "" { + profile = FindTeamProfile(awsConfigPath(), team) + } + opts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(defaultRegion)} + if profile != "" { + opts = append(opts, awsconfig.WithSharedConfigProfile(profile)) + } + cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) + if err != nil { + return cfg, err + } + if _, err := cfg.Credentials.Retrieve(ctx); err != nil { + if profile == "" { + profile = os.Getenv("AWS_PROFILE") + } + return cfg, credentialsHelp(err, profile, team) + } + return cfg, nil +} + +func credentialsHelp(err error, profile, team string) error { + role := "javabin-team-" + team + if profile != "" { + return fmt.Errorf("no valid AWS credentials for profile %q — run: aws sso login --profile %s\n(%w)", profile, profile, err) + } + return errors.New("no AWS credentials found. Sign in with your team's SSO role (" + role + "):\n" + + " aws configure sso # once: account " + AccountID + ", role " + role + "\n" + + " aws sso login --profile \n" + + "then pass --profile or set AWS_PROFILE=") +} + +func awsConfigPath() string { + if p := os.Getenv("AWS_CONFIG_FILE"); p != "" { + return p + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".aws", "config") +} + +// FindTeamProfile returns the profile in an AWS config file whose SSO role +// is javabin-team- in the platform account, or "" (also when several +// match, to avoid guessing). +func FindTeamProfile(path, team string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + + want := "javabin-team-" + team + var matches []string + var name string + vals := map[string]string{} + flush := func() { + if name != "" && vals["sso_role_name"] == want && (vals["sso_account_id"] == "" || vals["sso_account_id"] == AccountID) { + matches = append(matches, name) + } + } + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + flush() + section := strings.TrimSpace(line[1 : len(line)-1]) + name, vals = "", map[string]string{} + if section == "default" { + name = "default" + } else if p, ok := strings.CutPrefix(section, "profile "); ok { + name = strings.TrimSpace(p) + } + continue + } + if k, v, ok := strings.Cut(line, "="); ok { + if i := strings.IndexAny(v, "#;"); i >= 0 { + v = v[:i] // inline comment + } + vals[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + flush() + if len(matches) == 1 { + return matches[0] + } + return "" +} diff --git a/internal/aws/profile_test.go b/internal/aws/profile_test.go new file mode 100644 index 0000000..99b5c8e --- /dev/null +++ b/internal/aws/profile_test.go @@ -0,0 +1,48 @@ +package aws + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindTeamProfile(t *testing.T) { + cfg := `[default] +region = eu-central-1 + +[profile javabin] +sso_account_id = 553637109631 +sso_role_name = javabin-admin + +[profile cupcake] +sso_session = javabin +sso_account_id = 553637109631 +sso_role_name = javabin-team-cupcake # team role + +[profile other-account] +sso_account_id = 111111111111 +sso_role_name = javabin-team-jzwatch +` + p := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(p, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + if got := FindTeamProfile(p, "cupcake"); got != "cupcake" { + t.Errorf("cupcake: got %q", got) + } + if got := FindTeamProfile(p, "jzwatch"); got != "" { + t.Errorf("jzwatch in another account must not match, got %q", got) + } + if got := FindTeamProfile(filepath.Join(t.TempDir(), "missing"), "cupcake"); got != "" { + t.Errorf("missing file: got %q", got) + } +} + +func TestFindTeamProfileAmbiguous(t *testing.T) { + cfg := "[profile a]\nsso_role_name = javabin-team-cupcake\n[profile b]\nsso_role_name = javabin-team-cupcake\n" + p := filepath.Join(t.TempDir(), "config") + _ = os.WriteFile(p, []byte(cfg), 0o600) + if got := FindTeamProfile(p, "cupcake"); got != "" { + t.Errorf("two matches must not guess, got %q", got) + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..57178ee --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,135 @@ +// Package update tells users when a newer javabin release exists. +// +// At most once a day it asks GitHub for the latest release (cached in +// ~/.javabin/update-check.json) and prints a one-line notice on stderr. +// It never blocks for long, never fails a command, and stays quiet for dev +// builds, non-terminal output and JAVABIN_NO_UPDATE_CHECK=1. +package update + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "golang.org/x/term" + + "github.com/javaBin/javabin-cli/internal/config" +) + +const ( + latestURL = "https://api.github.com/repos/javaBin/javabin-cli/releases/latest" + checkEvery = 24 * time.Hour + httpTimeout = 2 * time.Second + cacheFile = "update-check.json" +) + +type cache struct { + CheckedAt time.Time `json:"checked_at"` + Latest string `json:"latest"` +} + +// Notice returns the message to show, or "" when current is up to date (or +// the check is skipped or fails). +func Notice(current string, fetch func() (string, error), cachePath string, now time.Time) string { + if current == "" || current == "dev" { + return "" + } + c := readCache(cachePath) + if now.Sub(c.CheckedAt) > checkEvery { + // Record the attempt even when it fails, so offline users don't + // wait for the timeout on every command; keep the last known latest. + if latest, err := fetch(); err == nil && latest != "" { + c.Latest = latest + } + c.CheckedAt = now + writeCache(cachePath, c) + } + if c.Latest == "" || !Newer(c.Latest, current) { + return "" + } + return fmt.Sprintf("javabin %s is available (you have %s). Update: brew upgrade javabin (or: go install github.com/javaBin/javabin-cli@latest)", + strings.TrimPrefix(c.Latest, "v"), strings.TrimPrefix(current, "v")) +} + +// PrintIfOutdated prints the notice on stderr when it applies. +func PrintIfOutdated(current string) { + if os.Getenv("JAVABIN_NO_UPDATE_CHECK") != "" || !isTerminal(os.Stderr) { + return + } + dir, err := config.Dir() + if err != nil { + return + } + if msg := Notice(current, fetchLatest, filepath.Join(dir, cacheFile), time.Now()); msg != "" { + fmt.Fprintln(os.Stderr, "\n"+msg) + } +} + +func fetchLatest() (string, error) { + client := http.Client{Timeout: httpTimeout} + req, _ := http.NewRequest("GET", latestURL, nil) + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + var body struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { + return "", err + } + return body.TagName, nil +} + +// Newer reports whether version a is newer than b ("v1.2.3" or "1.2.3"). +func Newer(a, b string) bool { + pa, pb := parse(a), parse(b) + for i := range pa { + if pa[i] != pb[i] { + return pa[i] > pb[i] + } + } + return false +} + +func parse(v string) [3]int { + var out [3]int + v = strings.TrimPrefix(v, "v") + v, _, _ = strings.Cut(v, "-") // ignore pre-release suffixes + for i, p := range strings.SplitN(v, ".", 3) { + out[i], _ = strconv.Atoi(p) + } + return out +} + +func readCache(path string) cache { + var c cache + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &c) + } + return c +} + +func writeCache(path string, c cache) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return + } + if data, err := json.Marshal(c); err == nil { + _ = os.WriteFile(path, data, 0o600) + } +} + +func isTerminal(f *os.File) bool { + return term.IsTerminal(int(f.Fd())) +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..2f77740 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,74 @@ +package update + +import ( + "errors" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNewer(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"v0.3.0", "0.2.0", true}, + {"v0.2.1", "v0.2.0", true}, + {"v1.0.0", "v0.9.9", true}, + {"v0.2.0", "v0.2.0", false}, + {"v0.2.0", "v0.10.0", false}, + {"v0.3.0", "0.3.0-rc1", false}, + } + for _, c := range cases { + if got := Newer(c.a, c.b); got != c.want { + t.Errorf("Newer(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} + +func TestNoticeChecksAtMostDaily(t *testing.T) { + path := filepath.Join(t.TempDir(), "update-check.json") + calls := 0 + fetch := func() (string, error) { calls++; return "v0.3.0", nil } + now := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC) + + msg := Notice("0.2.0", fetch, path, now) + if !strings.Contains(msg, "0.3.0 is available") || !strings.Contains(msg, "brew upgrade javabin") { + t.Fatalf("unexpected notice %q", msg) + } + Notice("0.2.0", fetch, path, now.Add(time.Hour)) // cached + if calls != 1 { + t.Errorf("fetched %d times within a day, want 1", calls) + } + Notice("0.2.0", fetch, path, now.Add(25*time.Hour)) + if calls != 2 { + t.Errorf("fetched %d times after a day, want 2", calls) + } +} + +func TestNoticeQuietWhenCurrentDevOrFailing(t *testing.T) { + path := filepath.Join(t.TempDir(), "c.json") + now := time.Now() + if msg := Notice("0.3.0", func() (string, error) { return "v0.3.0", nil }, path, now); msg != "" { + t.Errorf("up to date but got %q", msg) + } + if msg := Notice("dev", func() (string, error) { return "v9.9.9", nil }, path, now); msg != "" { + t.Errorf("dev build but got %q", msg) + } + if msg := Notice("0.1.0", func() (string, error) { return "", errors.New("offline") }, filepath.Join(t.TempDir(), "x.json"), now); msg != "" { + t.Errorf("fetch failed but got %q", msg) + } +} + +func TestFailedCheckIsNotRetriedOnEveryCommand(t *testing.T) { + path := filepath.Join(t.TempDir(), "c.json") + calls := 0 + fetch := func() (string, error) { calls++; return "", errors.New("offline") } + now := time.Now() + Notice("0.2.0", fetch, path, now) + Notice("0.2.0", fetch, path, now.Add(time.Minute)) + if calls != 1 { + t.Errorf("fetched %d times while offline within a day, want 1", calls) + } +} diff --git a/scripts/next-version.sh b/scripts/next-version.sh new file mode 100755 index 0000000..a31eb01 --- /dev/null +++ b/scripts/next-version.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Print the next release version from Conventional Commits since the last +# v* tag, or nothing if no commit warrants a release. +# +# feat -> minor +# fix, perf -> patch +# type!: / BREAKING CHANGE -> major (minor while < 1.0.0) +# anything else (docs, ci, chore, test, ...) -> no release +# +# Usage: next-version.sh (in a full clone: fetch-depth 0 + tags) + +set -eu + +LAST=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo "v0.0.0") +RANGE="${LAST}..HEAD" +[ "$LAST" = "v0.0.0" ] && RANGE="HEAD" + +SUBJECTS=$(git log --format=%s "$RANGE") +BODIES=$(git log --format=%b "$RANGE") + +BUMP="" +if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]*\))?!:' || echo "$BODIES" | grep -q '^BREAKING CHANGE'; then + BUMP="major" +elif echo "$SUBJECTS" | grep -qE '^feat(\([^)]*\))?:'; then + BUMP="minor" +elif echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]*\))?:'; then + BUMP="patch" +fi +[ -z "$BUMP" ] && exit 0 + +V=${LAST#v} +MAJOR=${V%%.*}; REST=${V#*.}; MINOR=${REST%%.*}; PATCH=${REST#*.} +case "$BUMP" in + major) if [ "$MAJOR" -eq 0 ]; then MINOR=$((MINOR + 1)); PATCH=0; else MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0; fi ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; +esac +echo "v${MAJOR}.${MINOR}.${PATCH}" diff --git a/scripts/next-version_test.sh b/scripts/next-version_test.sh new file mode 100644 index 0000000..cb4b01f --- /dev/null +++ b/scripts/next-version_test.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Tests for next-version.sh in throwaway repos. Run: sh scripts/next-version_test.sh +set -eu +SCRIPT="$(cd "$(dirname "$0")" && pwd)/next-version.sh" +FAIL=0 + +check() { # [body] + D=$(mktemp -d); ( + cd "$D" && git init -q && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m "chore: init" + [ "$1" != "-" ] && git tag "$1" + git -c user.email=t@t -c user.name=t commit -q --allow-empty -m "$3" -m "${4:-}" + GOT=$(sh "$SCRIPT"); WANT=$2; [ "$WANT" = "-" ] && WANT="" + if [ "$GOT" != "$WANT" ]; then echo "FAIL: tag=$1 '$3' -> '$GOT', want '$WANT'"; exit 1; fi + ) || FAIL=1 + rm -rf "$D" +} + +check v0.2.0 v0.3.0 "feat(secret): add command" +check v0.2.0 v0.2.1 "fix: handle empty value" +check v0.2.0 v0.2.1 "perf(status): cache" +check v0.2.0 - "docs: readme" +check v0.2.0 - "ci: workflow" +check v0.2.0 v0.3.0 "feat!: drop flag" +check v0.2.0 v0.3.0 "fix: x" "BREAKING CHANGE: flag removed" +check v1.4.2 v2.0.0 "refactor(api)!: new shape" +check v1.4.2 v1.5.0 "feat: y" +check - v0.1.0 "feat: first" +check v0.2.0 v0.3.0 "feat(secret)!: rename flag" +check v0.2.0 - 'Revert "feat: x"' + +[ "$FAIL" = 0 ] && echo "next-version: all cases pass" +exit "$FAIL"