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
7 changes: 3 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ The required checks on `main` are `build`, `tidy`, `test`, `lint`, `static-relea

## Releases

There is one release stream for both binaries. `version.txt` contains the major/minor line (`1.2`), and automatic releases create `v1.2.N` tags. A squash merge to `main` whose final commit begins with `feat:` or `fix:` triggers the automatic release decision when it changes Go source, `go.mod`, `go.sum`, `version.txt`, or `.goreleaser.yaml`. GoReleaser configuration changes therefore ship too; documentation, test, CI, and chore-only merges do not cut a release.
There is one release stream for both binaries. `version.txt` contains the major/minor line (`2.0`), and automatic releases create `v2.0.N` tags. A squash merge to `main` whose final commit begins with `feat:` or `fix:` triggers the automatic release decision when it changes Go source, `go.mod`, `go.sum`, `version.txt`, or `.goreleaser.yaml`. GoReleaser configuration changes therefore ship too; documentation, test, CI, and chore-only merges do not cut a release.

Each tag publishes both binaries and their platform archives through the shared release automation. Homebrew, Chocolatey, WinGet, and Linux package publication fan out from that release. Follow the [release](https://github.com/open-cli-collective/cli-common/blob/main/docs/release.md) and [distribution](https://github.com/open-cli-collective/cli-common/blob/main/docs/distribution.md) standards rather than duplicating workflow policy here.

Expand All @@ -81,8 +81,7 @@ and asserts it selects the Keychain backend, which catches a static or mis-tagge
is published.

`version.txt` holds only `MAJOR.MINOR`; the `major_minor_run_patch` scheme appends the workflow
run number, so tags are `v1.2.N` and never collide or need a bump commit. The stream starts at
`1.2` because `gro` had already released `1.1.x`; continuing its line keeps upgrades monotonic for
existing installs.
run number, so tags are `v2.0.N` and never collide or need a bump commit. The stream starts at
`2.0` because replacing the public credential selector is a breaking CLI change.

Use a focused branch, run `make check`, and open a pull request. Keep the pull-request title in conventional-commit form because squash merge makes that title the commit on `main` and therefore the release signal.
13 changes: 13 additions & 0 deletions internal/app/gro/backend_wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
cccredstore "github.com/open-cli-collective/cli-common/credstore"

"github.com/open-cli-collective/google-cli/internal/keychain"
"github.com/open-cli-collective/google-cli/internal/rootutil"
)

const serviceName = "google-readonly"
Expand All @@ -26,16 +27,28 @@ func resetState(t *testing.T) {
t.Helper()
keychain.SetBackendFlagOverride("", false)
keychain.SetCredentialRefOverride("", false)
resetRootFlag(t, cccredstore.BackendFlagName)
resetRootFlag(t, rootutil.ProfileFlagName)
// rootCmd.SetArgs mutates package-level state; if a test panics before
// the next test calls SetArgs, a stale slice could bleed in (notably
// under `go test -shuffle=on`). Clear it on cleanup.
t.Cleanup(func() {
keychain.SetBackendFlagOverride("", false)
keychain.SetCredentialRefOverride("", false)
resetRootFlag(t, cccredstore.BackendFlagName)
resetRootFlag(t, rootutil.ProfileFlagName)
rootCmd.SetArgs(nil)
})
}

func resetRootFlag(t *testing.T, name string) {
t.Helper()
if f := rootCmd.PersistentFlags().Lookup(name); f != nil {
_ = f.Value.Set(f.DefValue)
f.Changed = false
}
}

// newProbeCmd returns a no-op subcommand used to exercise the root's
// PersistentPreRunE through a real Execute() call.
func newProbeCmd(name string) *cobra.Command {
Expand Down
111 changes: 59 additions & 52 deletions internal/app/gro/credref_wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,57 +10,56 @@ import (
"github.com/open-cli-collective/google-cli/internal/rootutil"
)

// TestWireCredentialRefSelection_FlagSet proves a --ref on a real command
// path is recorded in the override the keychain.open resolver reads.
func TestWireCredentialRefSelection_FlagSet(t *testing.T) {
resetState(t)
t.Setenv(keychain.CredentialRefEnvVar(), "")

probe := newProbeCmd("probe-ref-flagset")
probe := newProbeCmd("probe-profile-flagset")
rootCmd.AddCommand(probe)
defer removeChild(t, probe)
rootCmd.SetArgs([]string{"probe-ref-flagset", "--ref", "google-readonly/acct-a"})
rootCmd.SetArgs([]string{"probe-profile-flagset", "--profile", "acct-a"})

if err := rootCmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
v, set := keychain.GetCredentialRefOverride()
if !set {
t.Fatalf("override flagSet = false, want true")
}
if v != "google-readonly/acct-a" {
t.Errorf("override value = %q, want %q", v, "google-readonly/acct-a")
if !set || v != "google-readonly/acct-a" {
t.Errorf("override = (%q, %v), want (google-readonly/acct-a, true)", v, set)
}
}

// TestWireCredentialRefSelection_FlagInvalid asserts a malformed --ref fails
// up front with a clear "--ref" error, before any keyring work.
func TestWireCredentialRefSelection_FlagInvalid(t *testing.T) {
func TestWireCredentialRefSelection_InvalidStopsBeforeLeaf(t *testing.T) {
resetState(t)

probe := newProbeCmd("probe-ref-invalid")
called := false
probe := &cobra.Command{
Use: "probe-profile-sentinel",
RunE: func(*cobra.Command, []string) error {
called = true
return nil
},
}
rootCmd.AddCommand(probe)
defer removeChild(t, probe)
rootCmd.SetArgs([]string{"probe-ref-invalid", "--ref", "no-slash"})
rootCmd.SetArgs([]string{"probe-profile-sentinel", "--profile", "bad.profile"})

err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error, got nil")
if err == nil || !strings.Contains(err.Error(), "--"+rootutil.ProfileFlagName) {
t.Fatalf("expected invalid --profile error, got %v", err)
}
if !strings.Contains(err.Error(), "--"+rootutil.CredentialRefFlagName) {
t.Errorf("error should mention --%s: %v", rootutil.CredentialRefFlagName, err)
if called {
t.Fatal("invalid --profile must stop before the leaf RunE")
}
if _, set := keychain.GetCredentialRefOverride(); set {
t.Fatal("invalid --profile must not record a credential-ref override")
}
}

// TestWireCredentialRefSelection_ShadowingSubcommand regresses the
// cobra-doesn't-chain-PersistentPreRunE bug for --ref, mirroring the
// --backend guard.
func TestWireCredentialRefSelection_ShadowingSubcommand(t *testing.T) {
resetState(t)
t.Setenv(keychain.CredentialRefEnvVar(), "")

shadow := &cobra.Command{
Use: "shadow-ref",
Use: "shadow-profile",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
return WireCredentialRefSelection(cmd)
},
Expand All @@ -70,49 +69,57 @@ func TestWireCredentialRefSelection_ShadowingSubcommand(t *testing.T) {
rootCmd.AddCommand(shadow)
defer removeChild(t, shadow)

rootCmd.SetArgs([]string{"shadow-ref", "leaf", "--ref", "google-readonly/acct-b"})
rootCmd.SetArgs([]string{"shadow-profile", "leaf", "--profile", "acct-b"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Execute through shadowing PreRunE: %v", err)
}
v, set := keychain.GetCredentialRefOverride()
if !set || v != "google-readonly/acct-b" {
t.Errorf("override = (%q, %v); want (\"google-readonly/acct-b\", true) — shadower's PreRunE failed to invoke WireCredentialRefSelection", v, set)
t.Errorf("override = (%q, %v); want (google-readonly/acct-b, true)", v, set)
}
}

// TestCredentialRef_SetCredentialShadowsPersistent documents the intentional
// exception to the inherit-everywhere rule: `set-credential` keeps its own
// local --ref (the write target), so it must resolve to a DIFFERENT *pflag.Flag
// than the root's persistent selector — while a read command inherits the
// canonical persistent one. A regression that dropped set-credential's local
// flag (or that made a read command shadow --ref) would flip these.
func TestCredentialRef_SetCredentialShadowsPersistent(t *testing.T) {
canonical := rootCmd.PersistentFlags().Lookup(rootutil.CredentialRefFlagName)
func TestProfile_InheritsPersistentOnRealCommandTree(t *testing.T) {
canonical := rootCmd.PersistentFlags().Lookup(rootutil.ProfileFlagName)
if canonical == nil {
t.Fatalf("root persistent flag --%s not registered", rootutil.CredentialRefFlagName)
t.Fatalf("root persistent flag --%s not registered", rootutil.ProfileFlagName)
}

var sc *cobra.Command
for _, c := range rootCmd.Commands() {
if c.Name() == "set-credential" {
sc = c
break
var walk func(*cobra.Command)
walk = func(cmd *cobra.Command) {
children := cmd.Commands()
if len(children) == 0 {
if got := cmd.Flag(rootutil.ProfileFlagName); got != canonical {
t.Errorf("%q: --%s = %p, want canonical %p", cmd.CommandPath(), rootutil.ProfileFlagName, got, canonical)
}
return
}
for _, child := range children {
walk(child)
}
}
if sc == nil {
t.Fatal("set-credential command not registered on rootCmd")
}
if got := sc.Flag(rootutil.CredentialRefFlagName); got == nil {
t.Fatalf("set-credential has no --%s", rootutil.CredentialRefFlagName)
} else if got == canonical {
t.Errorf("set-credential --%s resolved to the persistent flag; expected its own local shadow", rootutil.CredentialRefFlagName)
walk(rootCmd)
}

func TestNoRefFlagOnRealCommandTree(t *testing.T) {
resetState(t)

var walk func(*cobra.Command)
walk = func(cmd *cobra.Command) {
if f := cmd.Flag("ref"); f != nil {
t.Errorf("%q exposes removed --ref flag (%s)", cmd.CommandPath(), f.Usage)
}
for _, child := range cmd.Commands() {
walk(child)
}
}
walk(rootCmd)
}

// A read command (no local --ref) must inherit the canonical persistent flag.
me := newProbeCmd("probe-ref-inherit")
rootCmd.AddCommand(me)
defer removeChild(t, me)
if got := me.Flag(rootutil.CredentialRefFlagName); got != canonical {
t.Errorf("read command --%s = %p, want canonical %p (unexpected shadow)", rootutil.CredentialRefFlagName, got, canonical)
func TestRefFlagIsRejectedBySetCredential(t *testing.T) {
resetState(t)
rootCmd.SetArgs([]string{"set-credential", "--ref", "google-readonly/work"})
if err := rootCmd.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("set-credential must reject removed --ref, got %v", err)
}
}
2 changes: 1 addition & 1 deletion internal/app/gro/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func init() {
// Set custom version template to include commit and build date
rootCmd.SetVersionTemplate("gro " + version.Info() + "\n")

// Global flags (verbose, no-color, backend, ref)
// Global flags (verbose, no-color, backend, profile)
rootutil.AddGlobalFlags(rootCmd, &verbose, &noColor)

// Register commands
Expand Down
95 changes: 95 additions & 0 deletions internal/app/grw/profile_wire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package grw

import (
"strings"
"testing"

cccredstore "github.com/open-cli-collective/cli-common/credstore"
"github.com/spf13/cobra"

"github.com/open-cli-collective/google-cli/internal/config"
"github.com/open-cli-collective/google-cli/internal/keychain"
"github.com/open-cli-collective/google-cli/internal/rootutil"
)

func resetProfileTestState(t *testing.T) {
t.Helper()
config.Register(Identity())
keychain.SetCredentialRefOverride("", false)
keychain.SetBackendFlagOverride("", false)
for _, name := range []string{rootutil.ProfileFlagName, cccredstore.BackendFlagName} {
if f := rootCmd.PersistentFlags().Lookup(name); f != nil {
_ = f.Value.Set(f.DefValue)
f.Changed = false
}
}
t.Cleanup(func() {
keychain.SetCredentialRefOverride("", false)
keychain.SetBackendFlagOverride("", false)
rootCmd.SetArgs(nil)
})
}

func TestProfileSelectionQualifiesGrwService(t *testing.T) {
resetProfileTestState(t)

probe := &cobra.Command{Use: "probe-profile", RunE: func(*cobra.Command, []string) error { return nil }}
rootCmd.AddCommand(probe)
t.Cleanup(func() { rootCmd.RemoveCommand(probe) })
rootCmd.SetArgs([]string{"probe-profile", "--profile", "work"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if got, set := keychain.GetCredentialRefOverride(); !set || got != "google-readwrite/work" {
t.Errorf("override = (%q, %v), want (google-readwrite/work, true)", got, set)
}
}

func TestInvalidProfileStopsBeforeLeaf(t *testing.T) {
resetProfileTestState(t)
called := false
probe := &cobra.Command{
Use: "probe-profile-sentinel",
RunE: func(*cobra.Command, []string) error {
called = true
return nil
},
}
rootCmd.AddCommand(probe)
t.Cleanup(func() { rootCmd.RemoveCommand(probe) })
rootCmd.SetArgs([]string{"probe-profile-sentinel", "--profile", "bad.profile"})

err := rootCmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--"+rootutil.ProfileFlagName) {
t.Fatalf("expected invalid --profile error, got %v", err)
}
if called {
t.Fatal("invalid --profile must stop before the leaf RunE")
}
if _, set := keychain.GetCredentialRefOverride(); set {
t.Fatal("invalid --profile must not record a credential-ref override")
}
}

func TestNoRefFlagOnRealCommandTree(t *testing.T) {
resetProfileTestState(t)

var walk func(*cobra.Command)
walk = func(cmd *cobra.Command) {
if f := cmd.Flag("ref"); f != nil {
t.Errorf("%q exposes removed --ref flag (%s)", cmd.CommandPath(), f.Usage)
}
for _, child := range cmd.Commands() {
walk(child)
}
}
walk(rootCmd)
}

func TestRefFlagIsRejectedBySetCredential(t *testing.T) {
resetProfileTestState(t)
rootCmd.SetArgs([]string{"set-credential", "--ref", "google-readwrite/work"})
if err := rootCmd.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("set-credential must reject removed --ref, got %v", err)
}
}
2 changes: 1 addition & 1 deletion internal/app/grw/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func ExecuteContext(ctx context.Context) {
func init() {
rootCmd.SetVersionTemplate("grw " + version.Info() + "\n")

// Global flags (verbose, no-color, backend, ref)
// Global flags (verbose, no-color, backend, profile)
rootutil.AddGlobalFlags(rootCmd, &verbose, &noColor)

// Register commands
Expand Down
Loading
Loading