Skip to content
Open
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
26 changes: 14 additions & 12 deletions codejob_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -19,43 +20,44 @@ 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
}

// SetLog sets the logging function.
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()))
Expand All @@ -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))
}

Expand Down
1 change: 1 addition & 0 deletions docs/CODEJOB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
14 changes: 14 additions & 0 deletions docs/GORELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
4 changes: 3 additions & 1 deletion docs/github/diagrams/GITHUB_AUTH_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <br/> (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 <br/> (Requests Device Code)"]
B --> C["Client ID: Ov23lij... <br/> (GitHub API)"]
C --> D["Returns User Code <br/> (User opens browser)"]

Expand Down
41 changes: 24 additions & 17 deletions github_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand All @@ -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)
}
}

Expand All @@ -71,28 +75,31 @@ 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 {
if _, err := RunCommandSilent("gh", "auth", "status"); err == nil {
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
}
Expand All @@ -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 {
Expand All @@ -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))
}

Expand Down
149 changes: 149 additions & 0 deletions secret_store.go
Original file line number Diff line number Diff line change
@@ -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()))
}
Loading