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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
58 changes: 54 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }}
Expand All @@ -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."
2 changes: 1 addition & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
@@ -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{
Expand All @@ -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-<team> from app.yaml)")
rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(registerCmd)
rootCmd.AddCommand(statusCmd)
Expand Down
4 changes: 2 additions & 2 deletions cmd/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 5 additions & 1 deletion internal/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
113 changes: 113 additions & 0 deletions internal/aws/profile.go
Original file line number Diff line number Diff line change
@@ -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-<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 <name>\n" +
"then pass --profile <name> or set AWS_PROFILE=<name>")
}

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-<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 ""
}
48 changes: 48 additions & 0 deletions internal/aws/profile_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading