diff --git a/codejob_auth.go b/codejob_auth.go
index ae9ec21..5b2c4b6 100644
--- a/codejob_auth.go
+++ b/codejob_auth.go
@@ -8,6 +8,7 @@ import (
"golang.org/x/term"
)
+// Deprecated: use SecretJulesAPIKey with SecretStore
const julesAPIKeyKey = "jules_api_key"
const julesAPIKeyURL = "https://jules.google.com/settings/api"
@@ -19,19 +20,15 @@ func termLink(text, url string) string {
// JulesAuth manages the Jules API key via the system keyring.
// On first use it prompts the user to enter the key and stores it securely.
type JulesAuth struct {
- kr *Keyring
- log func(...any)
+ store *SecretStore
+ log func(...any)
}
// NewJulesAuth creates a JulesAuth with an initialized keyring.
func NewJulesAuth() (*JulesAuth, error) {
- kr, err := NewKeyring()
- if err != nil {
- return nil, err
- }
return &JulesAuth{
- kr: kr,
- log: func(...any) {},
+ store: NewSecretStore(),
+ log: func(...any) {},
}, nil
}
@@ -39,23 +36,28 @@ func NewJulesAuth() (*JulesAuth, error) {
func (a *JulesAuth) SetLog(fn func(...any)) {
if fn != nil {
a.log = fn
+ a.store.SetLog(fn)
}
}
// HasKey returns true if the Jules API key is already stored in the keyring.
func (a *JulesAuth) HasKey() bool {
- key, err := a.kr.Get(julesAPIKeyKey)
- return err == nil && key != ""
+ _, _, err := a.store.Get(SecretJulesAPIKey)
+ return err == nil
}
// EnsureAPIKey returns the Jules API key from the keyring.
// If absent, prompts the user for it once and persists it.
func (a *JulesAuth) EnsureAPIKey() (string, error) {
- key, err := a.kr.Get(julesAPIKeyKey)
+ key, _, err := a.store.Get(SecretJulesAPIKey)
if err == nil && key != "" {
return key, nil
}
+ if !IsInteractive() {
+ return "", fmt.Errorf("Jules API key not found; set JULES_API_KEY")
+ }
+
fmt.Fprintf(os.Stderr, "Jules API Key not found. Get yours at %s\nEnter it now: ",
termLink(julesAPIKeyURL, julesAPIKeyURL))
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
@@ -69,7 +71,7 @@ func (a *JulesAuth) EnsureAPIKey() (string, error) {
return "", fmt.Errorf("API key cannot be empty")
}
- if err := a.kr.Set(julesAPIKeyKey, key); err != nil {
+ if err := a.store.Set(SecretJulesAPIKey, key); err != nil {
a.log(fmt.Sprintf("warning: could not save API key to keyring: %v", err))
}
diff --git a/docs/CODEJOB.md b/docs/CODEJOB.md
index c483b8b..0d3fc2b 100644
--- a/docs/CODEJOB.md
+++ b/docs/CODEJOB.md
@@ -34,6 +34,7 @@ Dispatching a task automatically runs the setup wizard if the Jules API key is m
Jules API key is managed via the system keyring (`github.com/zalando/go-keyring`):
- **After first run**: reads silently from keyring — no env vars required in local use
+- **CI/CD / Headless**: use `JULES_API_KEY` and `GH_TOKEN` environment variables.
GitHub repo and branch are auto-detected when not provided:
- `SourceID` — via `gh repo view --json owner,name`
diff --git a/docs/GORELEASE.md b/docs/GORELEASE.md
index a5df65c..ad93577 100644
--- a/docs/GORELEASE.md
+++ b/docs/GORELEASE.md
@@ -75,6 +75,20 @@ gorelease
gorelease v1.2.3
```
+## CI/CD Usage
+
+`gorelease` can be run in CI/CD environments (headless) by providing a GitHub token via environment variables.
+
+```yaml
+# GitHub Actions example
+- name: Run gorelease
+ run: go run github.com/tinywasm/devflow/cmd/gorelease@latest
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Or a PAT with 'repo' scope
+```
+
+`gorelease` prioritizes `GH_TOKEN`, then `GITHUB_TOKEN`, and falls back to the system keyring only if neither is present.
+
## Integration with `codejob`
The `-release` flag in `codejob` automatically triggers a release after `gopush`:
diff --git a/docs/github/diagrams/GITHUB_AUTH_FLOW.md b/docs/github/diagrams/GITHUB_AUTH_FLOW.md
index e196123..212ce07 100644
--- a/docs/github/diagrams/GITHUB_AUTH_FLOW.md
+++ b/docs/github/diagrams/GITHUB_AUTH_FLOW.md
@@ -5,7 +5,9 @@ This diagram illustrates how the `devflow` authentication system affects Git ope
```mermaid
flowchart TD
%% Initialization
- A[User executes push / gopush] --> B["devflow/github_auth.go
(Requests Device Code)"]
+ A[User executes push / gopush] --> SS{"SecretStore.Get('github_token')"}
+ SS -->|Env GH_TOKEN/GITHUB_TOKEN| H
+ SS -->|Not in env| B["devflow/github_auth.go
(Requests Device Code)"]
B --> C["Client ID: Ov23lij...
(GitHub API)"]
C --> D["Returns User Code
(User opens browser)"]
diff --git a/github_auth.go b/github_auth.go
index 9186a13..277167f 100644
--- a/github_auth.go
+++ b/github_auth.go
@@ -25,17 +25,20 @@ import (
const DevflowOAuthClientID = "Ov23lijHU2vxBCpShn1Q"
// GitHub token key for keyring storage
+// Deprecated: use SecretGitHubToken with SecretStore
const githubTokenKey = "github_token"
// GitHubAuth handles GitHub authentication and token management
type GitHubAuth struct {
- log func(...any)
+ log func(...any)
+ store *SecretStore
}
// NewGitHubAuth creates a new GitHub authentication handler
func NewGitHubAuth() *GitHubAuth {
return &GitHubAuth{
- log: func(...any) {},
+ log: func(...any) {},
+ store: NewSecretStore(),
}
}
@@ -48,6 +51,7 @@ func (a *GitHubAuth) Name() string {
func (a *GitHubAuth) SetLog(fn func(...any)) {
if fn != nil {
a.log = fn
+ a.store.SetLog(fn)
}
}
@@ -71,15 +75,7 @@ type tokenResponse struct {
// EnsureGitHubAuth checks if GitHub is authenticated via keyring, and if not, initiates Device Flow
func (a *GitHubAuth) EnsureGitHubAuth() error {
- // Initialize keyring (auto-installs if needed)
- kr, err := NewKeyring()
- if err != nil {
- return err
- }
- kr.SetLog(a.log)
-
- // Try to load saved token from keyring
- token, err := kr.Get(githubTokenKey)
+ token, src, err := a.store.Get(SecretGitHubToken)
if err == nil && token != "" {
// Verify the token works by configuring gh
if a.configureGhWithToken(token) == nil {
@@ -87,12 +83,23 @@ func (a *GitHubAuth) EnsureGitHubAuth() error {
return nil
}
}
- // Token is invalid, remove it
- kr.Delete(githubTokenKey)
+
+ // Token is invalid
+ if src == SourceEnv {
+ return fmt.Errorf("invalid GitHub token in environment variable (GH_TOKEN or GITHUB_TOKEN)")
+ }
+
+ // If it was from keyring, delete it and try to re-authenticate
+ a.store.Delete(SecretGitHubToken)
+ }
+
+ // Not authenticated - check if interactive
+ if !IsInteractive() {
+ return fmt.Errorf("no GitHub token found; set GH_TOKEN or GITHUB_TOKEN, or run locally to authenticate")
}
- // Not authenticated - initiate Device Flow
- token, err = a.DeviceFlowAuth(kr)
+ // Initiate Device Flow
+ token, err = a.DeviceFlowAuth()
if err != nil {
return err
}
@@ -102,7 +109,7 @@ func (a *GitHubAuth) EnsureGitHubAuth() error {
}
// DeviceFlowAuth initiates GitHub OAuth Device Flow and returns an access token
-func (a *GitHubAuth) DeviceFlowAuth(kr *Keyring) (string, error) {
+func (a *GitHubAuth) DeviceFlowAuth() (string, error) {
// Step 1: Request device and user codes
codeResp, err := a.requestDeviceCode()
if err != nil {
@@ -129,7 +136,7 @@ func (a *GitHubAuth) DeviceFlowAuth(kr *Keyring) (string, error) {
}
// Step 4: Save token to keyring
- if err := kr.Set(githubTokenKey, token); err != nil {
+ if err := a.store.Set(SecretGitHubToken, token); err != nil {
a.log(fmt.Sprintf("Warning: could not save token: %v", err))
}
diff --git a/secret_store.go b/secret_store.go
new file mode 100644
index 0000000..086725c
--- /dev/null
+++ b/secret_store.go
@@ -0,0 +1,149 @@
+package devflow
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+
+ "golang.org/x/term"
+)
+
+// SecretSource indicates where a secret was resolved from.
+type SecretSource int
+
+const (
+ SourceNone SecretSource = iota // not found
+ SourceEnv // environment variable
+ SourceKeyring // system keyring
+)
+
+// secretSpec describes how to resolve a logical secret.
+type secretSpec struct {
+ keyringKey string // key in the keyring (compatibility with existing stored values)
+ envKeys []string // environment variables to try, in priority order
+}
+
+// Logical names of managed secrets.
+const (
+ SecretGitHubToken = "github_token"
+ SecretJulesAPIKey = "jules_api_key"
+)
+
+// secretRegistry maps each logical secret to its spec. It is the SINGLE source of truth
+// for env var names and keyring keys. Do not duplicate these literals in other files.
+var secretRegistry = map[string]secretSpec{
+ SecretGitHubToken: {keyringKey: "github_token", envKeys: []string{"GH_TOKEN", "GITHUB_TOKEN"}},
+ SecretJulesAPIKey: {keyringKey: "jules_api_key", envKeys: []string{"JULES_API_KEY"}},
+}
+
+// ErrSecretNotFound is returned when a known secret is not found in any backend.
+var ErrSecretNotFound = errors.New("secret not found in environment or keyring")
+
+// SecretStore resolves credentials with environment → keyring precedence.
+// The keyring is initialized lazily: it is NEVER touched if the credential
+// is available via environment variable (key for CI/CD).
+type SecretStore struct {
+ log func(...any)
+ kr *Keyring
+ mu sync.Mutex
+ once sync.Once
+}
+
+// NewSecretStore creates the handler. It does NOT initialize the keyring (no cost or side effects).
+func NewSecretStore() *SecretStore {
+ return &SecretStore{
+ log: func(...any) {},
+ }
+}
+
+// SetLog assigns the logger (propagates to Keyring when initialized).
+func (s *SecretStore) SetLog(fn func(...any)) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.log = fn
+ if s.kr != nil {
+ s.kr.SetLog(fn)
+ }
+}
+
+func (s *SecretStore) keyring() (*Keyring, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.kr == nil {
+ kr, err := NewKeyring()
+ if err != nil {
+ return nil, err
+ }
+ s.kr = kr
+ s.kr.SetLog(s.log)
+ }
+ return s.kr, nil
+}
+
+// Get resolves the value of secret `name`. Returns value, its source and error.
+// - If name is not in secretRegistry → error (programming).
+// - If found in env → (value, SourceEnv, nil) WITHOUT touching keyring.
+// - If not in env but in keyring → (value, SourceKeyring, nil).
+// - If not in any backend → ("", SourceNone, ErrSecretNotFound).
+func (s *SecretStore) Get(name string) (string, SecretSource, error) {
+ spec, ok := secretRegistry[name]
+ if !ok {
+ return "", SourceNone, fmt.Errorf("unknown secret %q", name)
+ }
+
+ for _, e := range spec.envKeys {
+ if v := strings.TrimSpace(os.Getenv(e)); v != "" {
+ return v, SourceEnv, nil
+ }
+ }
+
+ kr, err := s.keyring()
+ if err != nil {
+ return "", SourceNone, err
+ }
+
+ v, err := kr.Get(spec.keyringKey)
+ if err == nil && v != "" {
+ return v, SourceKeyring, nil
+ }
+
+ return "", SourceNone, ErrSecretNotFound
+}
+
+// Set persists the value in the keyring (only writable backend).
+// Used by interactive acquisition after obtaining a new secret.
+func (s *SecretStore) Set(name, value string) error {
+ spec, ok := secretRegistry[name]
+ if !ok {
+ return fmt.Errorf("unknown secret %q", name)
+ }
+
+ kr, err := s.keyring()
+ if err != nil {
+ return err
+ }
+
+ return kr.Set(spec.keyringKey, value)
+}
+
+// Delete removes the value from the keyring (e.g. invalid token).
+func (s *SecretStore) Delete(name string) error {
+ spec, ok := secretRegistry[name]
+ if !ok {
+ return fmt.Errorf("unknown secret %q", name)
+ }
+
+ kr, err := s.keyring()
+ if err != nil {
+ return err
+ }
+
+ return kr.Delete(spec.keyringKey)
+}
+
+// IsInteractive indicates if there is a TTY where credentials can be requested from the user.
+func IsInteractive() bool {
+ return term.IsTerminal(int(os.Stdin.Fd()))
+}
diff --git a/test/secret_store_test.go b/test/secret_store_test.go
new file mode 100644
index 0000000..7c550fc
--- /dev/null
+++ b/test/secret_store_test.go
@@ -0,0 +1,75 @@
+package devflow_test
+
+import (
+ "os"
+ "testing"
+
+ "github.com/tinywasm/devflow"
+)
+
+func TestSecretStore_Precedence(t *testing.T) {
+ // Clean env
+ os.Unsetenv("GH_TOKEN")
+ os.Unsetenv("GITHUB_TOKEN")
+ defer os.Unsetenv("GH_TOKEN")
+ defer os.Unsetenv("GITHUB_TOKEN")
+
+ store := devflow.NewSecretStore()
+
+ // 1. None
+ _, _, err := store.Get(devflow.SecretGitHubToken)
+ if err == nil {
+ t.Error("expected error when no secret is present")
+ }
+
+ // 2. GITHUB_TOKEN
+ os.Setenv("GITHUB_TOKEN", "token2")
+ val, src, err := store.Get(devflow.SecretGitHubToken)
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if val != "token2" {
+ t.Errorf("expected token2, got %s", val)
+ }
+ if src != devflow.SourceEnv {
+ t.Error("expected SourceEnv")
+ }
+
+ // 3. GH_TOKEN (precedence over GITHUB_TOKEN)
+ os.Setenv("GH_TOKEN", "token1")
+ val, src, err = store.Get(devflow.SecretGitHubToken)
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if val != "token1" {
+ t.Errorf("expected token1, got %s", val)
+ }
+}
+
+func TestSecretStore_Trim(t *testing.T) {
+ os.Setenv("JULES_API_KEY", " key-with-spaces \n")
+ defer os.Unsetenv("JULES_API_KEY")
+
+ store := devflow.NewSecretStore()
+ val, _, err := store.Get(devflow.SecretJulesAPIKey)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != "key-with-spaces" {
+ t.Errorf("expected trimmed key, got %q", val)
+ }
+
+ os.Setenv("JULES_API_KEY", " ")
+ _, _, err = store.Get(devflow.SecretJulesAPIKey)
+ if err == nil {
+ t.Error("expected error for empty-space env var")
+ }
+}
+
+func TestSecretStore_UnknownSecret(t *testing.T) {
+ store := devflow.NewSecretStore()
+ _, _, err := store.Get("non-existent")
+ if err == nil {
+ t.Error("expected error for unknown secret")
+ }
+}