From a99cb887cd82cc19789d3a92ad2a906103a6ce72 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Sat, 4 Apr 2026 15:53:20 +0530 Subject: [PATCH 1/6] feat(version): authenticate author via GitHub OAuth device flow - Add internal/version/auth.go: GitHub device flow, LoadIdentity/SaveIdentity - version init prompts for GitHub auth; stores verified username in .deepdiffdb/config - version init --skip-auth bypasses prompt for CI/scripted environments - version commit reads identity from config; github: used as author automatically - --author flag still works when no config identity exists (backward compat) - DEEPDIFFDB_GITHUB_CLIENT_ID env var or build-time -ldflags injection for client ID - 5 new unit tests in tests/version/auth_test.go Closes #77 --- cmd/deepdiffdb/main.go | 59 ++++++++- internal/version/auth.go | 256 +++++++++++++++++++++++++++++++++++++ tests/version/auth_test.go | 133 +++++++++++++++++++ 3 files changed, 443 insertions(+), 5 deletions(-) create mode 100644 internal/version/auth.go create mode 100644 tests/version/auth_test.go diff --git a/cmd/deepdiffdb/main.go b/cmd/deepdiffdb/main.go index be707df..88fd8fa 100644 --- a/cmd/deepdiffdb/main.go +++ b/cmd/deepdiffdb/main.go @@ -12,6 +12,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "sync" "time" @@ -1542,10 +1543,13 @@ Subcommands: checkout Switch to a branch tree Show ASCII commit graph for all branches +Flags for init: + --skip-auth Skip GitHub authentication prompt + Flags for commit: --config Path to config file (default: deepdiffdb.config.yaml) --message Commit message (required) - --author Author name (default: current user) + --author Author name (used only when not authenticated via GitHub) Flags for log: --limit Maximum number of commits to show (default: 20) @@ -1563,6 +1567,7 @@ Flags for branch: func runVersionInit(args []string) error { fs := flag.NewFlagSet("version init", flag.ContinueOnError) dir := fs.String("dir", ".", "Directory to initialise (default: current directory)") + skipAuth := fs.Bool("skip-auth", false, "Skip GitHub authentication (use --author flag on commits instead)") if err := fs.Parse(args); err != nil { return err } @@ -1574,6 +1579,36 @@ func runVersionInit(args []string) error { return err } fmt.Printf("Initialised version repository in %s/%s\n", *dir, vcs.RepoDirName) + + if *skipAuth { + fmt.Println("Skipping GitHub authentication. Use --author on version commit to set your name.") + return nil + } + + clientID := vcs.ResolveClientID() + if clientID == "" { + fmt.Println("\nNote: GitHub authentication unavailable (DEEPDIFFDB_GITHUB_CLIENT_ID not set).") + fmt.Println(" Use --author on version commit to identify yourself.") + return nil + } + + fmt.Print("\nAuthenticate with GitHub to verify commit authorship? [Y/n]: ") + var answer string + fmt.Scanln(&answer) //nolint:errcheck + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "" && answer != "y" { + fmt.Println("Skipped. Use --author on version commit to set your name.") + return nil + } + + username, err := vcs.RunGitHubDeviceFlow(clientID) + if err != nil { + return fmt.Errorf("GitHub authentication failed: %w", err) + } + if err := vcs.SaveIdentity(*dir, username); err != nil { + return fmt.Errorf("saving identity: %w", err) + } + fmt.Printf("Authenticated as github:%s — your commits will be signed automatically.\n", username) return nil } @@ -1668,11 +1703,25 @@ func runVersionCommit(args []string) error { return fmt.Errorf("read HEAD: %w", err) } - authorName := *author - if authorName == "" { - authorName = os.Getenv("USER") + // Resolve author: verified GitHub identity takes priority over the --author flag. + // If neither is set, fall back to $USER for convenience. Unauthenticated commits + // are allowed but the author is clearly unverified. + authorName, err := vcs.LoadIdentity(dir) + if err != nil { + return fmt.Errorf("loading identity: %w", err) + } + if authorName != "" { + authorName = "github:" + authorName + if *author != "" { + fmt.Fprintf(os.Stderr, "Warning: --author flag ignored; using verified identity %s\n", authorName) + } + } else { + authorName = *author + if authorName == "" { + authorName = os.Getenv("USER") + } if authorName == "" { - authorName = "unknown" + return fmt.Errorf("no author set: authenticate with 'version init' or pass --author ") } } diff --git a/internal/version/auth.go b/internal/version/auth.go new file mode 100644 index 0000000..ed68fa0 --- /dev/null +++ b/internal/version/auth.go @@ -0,0 +1,256 @@ +package version + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// GitHubClientID is the GitHub OAuth App client ID used for device flow authentication. +// It can be overridden at build time: +// +// go build -ldflags "-X github.com/iamvirul/deepdiff-db/internal/version.GitHubClientID=" +// +// At runtime the DEEPDIFFDB_GITHUB_CLIENT_ID environment variable takes precedence. +// +// To register an OAuth App: https://github.com/settings/applications/new +// - Application name: DeepDiff DB +// - Homepage URL: https://github.com/iamvirul/deepdiff-db +// - Enable: Device Authorization Flow +var GitHubClientID = "" + +const ( + // configFileName is the file inside .deepdiffdb/ that stores verified identity. + configFileName = "config" + + githubDeviceURL = "https://github.com/login/device/code" + githubTokenURL = "https://github.com/login/oauth/access_token" + githubUserURL = "https://api.github.com/user" +) + +// IdentityConfig is the schema for .deepdiffdb/config. +type IdentityConfig struct { + GitHubUser string `json:"github_user"` +} + +// LoadIdentity reads the verified GitHub username from .deepdiffdb/config. +// Returns an empty string (not an error) when the file does not exist — the caller +// treats that as "unauthenticated, fall back to --author flag". +func LoadIdentity(dir string) (string, error) { + path := filepath.Join(dir, RepoDirName, configFileName) + data, err := os.ReadFile(path) // #nosec G304 — path is constructed from a trusted dir + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("reading identity config: %w", err) + } + var cfg IdentityConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return "", fmt.Errorf("parsing identity config: %w", err) + } + return cfg.GitHubUser, nil +} + +// SaveIdentity writes the verified GitHub username to .deepdiffdb/config. +// The file is owner-readable only (0o600). No token is persisted. +func SaveIdentity(dir, username string) error { + cfg := IdentityConfig{GitHubUser: username} + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("encoding identity config: %w", err) + } + path := filepath.Join(dir, RepoDirName, configFileName) + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { // #nosec G306 + return fmt.Errorf("writing identity config: %w", err) + } + return nil +} + +// ResolveClientID returns the GitHub OAuth App client ID to use. The +// DEEPDIFFDB_GITHUB_CLIENT_ID environment variable takes precedence over the +// build-time default. +func ResolveClientID() string { + if v := os.Getenv("DEEPDIFFDB_GITHUB_CLIENT_ID"); v != "" { + return v + } + return GitHubClientID +} + +// RunGitHubDeviceFlow performs the GitHub OAuth device flow and returns the +// authenticated GitHub username. The access token is used only to verify identity +// and is never stored. +func RunGitHubDeviceFlow(clientID string) (string, error) { + dc, err := requestDeviceCode(clientID) + if err != nil { + return "", err + } + + fmt.Printf("\n Open: %s\n", dc.verificationURI) + fmt.Printf(" Code: %s\n\n", dc.userCode) + fmt.Print(" Waiting for authorization") + + token, err := pollForToken(clientID, dc.deviceCode, dc.interval, dc.expiresIn) + if err != nil { + fmt.Println() // newline after dots + return "", err + } + fmt.Println(" ✓") + + return resolveGitHubUsername(token) +} + +// --- internal types ---------------------------------------------------------- + +type deviceCodeResp struct { + deviceCode string + userCode string + verificationURI string + expiresIn int + interval int +} + +type rawDeviceCode struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +type rawTokenResp struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` +} + +type rawGitHubUser struct { + Login string `json:"login"` +} + +// --- private helpers --------------------------------------------------------- + +func requestDeviceCode(clientID string) (*deviceCodeResp, error) { + params := url.Values{} + params.Set("client_id", clientID) + params.Set("scope", "read:user") + + req, err := http.NewRequest(http.MethodPost, githubDeviceURL, strings.NewReader(params.Encode())) + if err != nil { + return nil, fmt.Errorf("building device code request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + c := &http.Client{Timeout: 15 * time.Second} + res, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("requesting device code from GitHub: %w", err) + } + defer res.Body.Close() + + var raw rawDeviceCode + if err := json.NewDecoder(res.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("decoding device code response: %w", err) + } + if raw.DeviceCode == "" { + return nil, fmt.Errorf("GitHub returned empty device code — verify DEEPDIFFDB_GITHUB_CLIENT_ID is set correctly") + } + interval := raw.Interval + if interval < 5 { + interval = 5 + } + return &deviceCodeResp{ + deviceCode: raw.DeviceCode, + userCode: raw.UserCode, + verificationURI: raw.VerificationURI, + expiresIn: raw.ExpiresIn, + interval: interval, + }, nil +} + +func pollForToken(clientID, deviceCode string, interval, expiresIn int) (string, error) { + deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) + c := &http.Client{Timeout: 15 * time.Second} + + for time.Now().Before(deadline) { + time.Sleep(time.Duration(interval) * time.Second) + fmt.Print(".") + + params := url.Values{} + params.Set("client_id", clientID) + params.Set("device_code", deviceCode) + params.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + req, err := http.NewRequest(http.MethodPost, githubTokenURL, strings.NewReader(params.Encode())) + if err != nil { + return "", fmt.Errorf("building token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + res, err := c.Do(req) + if err != nil { + return "", fmt.Errorf("polling GitHub for token: %w", err) + } + + var tr rawTokenResp + decodeErr := json.NewDecoder(res.Body).Decode(&tr) + res.Body.Close() + if decodeErr != nil { + return "", fmt.Errorf("decoding token response: %w", decodeErr) + } + + switch tr.Error { + case "": + if tr.AccessToken != "" { + return tr.AccessToken, nil + } + case "authorization_pending": + continue + case "slow_down": + interval += 5 + case "expired_token": + return "", fmt.Errorf("device code expired — run `version init --auth` again") + case "access_denied": + return "", fmt.Errorf("GitHub authorization was denied") + default: + return "", fmt.Errorf("GitHub auth error: %s", tr.Error) + } + } + return "", fmt.Errorf("timed out waiting for GitHub authorization") +} + +func resolveGitHubUsername(token string) (string, error) { + req, err := http.NewRequest(http.MethodGet, githubUserURL, nil) + if err != nil { + return "", fmt.Errorf("building user request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + c := &http.Client{Timeout: 10 * time.Second} + res, err := c.Do(req) + if err != nil { + return "", fmt.Errorf("fetching GitHub user info: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned HTTP %d when fetching user", res.StatusCode) + } + + var user rawGitHubUser + if err := json.NewDecoder(res.Body).Decode(&user); err != nil { + return "", fmt.Errorf("decoding GitHub user response: %w", err) + } + if user.Login == "" { + return "", fmt.Errorf("GitHub returned an empty username") + } + return user.Login, nil +} diff --git a/tests/version/auth_test.go b/tests/version/auth_test.go new file mode 100644 index 0000000..460de52 --- /dev/null +++ b/tests/version/auth_test.go @@ -0,0 +1,133 @@ +package version_test + +import ( + "os" + "path/filepath" + "testing" + + vcs "github.com/iamvirul/deepdiff-db/internal/version" +) + +// TestSaveAndLoadIdentity verifies the round-trip of SaveIdentity / LoadIdentity. +func TestSaveAndLoadIdentity(t *testing.T) { + dir := t.TempDir() + // Repo must be initialised first (SaveIdentity writes into .deepdiffdb/). + if err := vcs.Init(dir); err != nil { + t.Fatalf("Init: %v", err) + } + + const want = "iamvirul" + if err := vcs.SaveIdentity(dir, want); err != nil { + t.Fatalf("SaveIdentity: %v", err) + } + + got, err := vcs.LoadIdentity(dir) + if err != nil { + t.Fatalf("LoadIdentity: %v", err) + } + if got != want { + t.Errorf("LoadIdentity = %q, want %q", got, want) + } +} + +// TestLoadIdentityMissingFile returns empty string (not an error) when config does not exist. +func TestLoadIdentityMissingFile(t *testing.T) { + dir := t.TempDir() + if err := vcs.Init(dir); err != nil { + t.Fatalf("Init: %v", err) + } + + username, err := vcs.LoadIdentity(dir) + if err != nil { + t.Fatalf("LoadIdentity unexpectedly returned error for missing config: %v", err) + } + if username != "" { + t.Errorf("LoadIdentity = %q, want empty string for missing config", username) + } +} + +// TestSaveIdentityFilePermissions verifies the config file is written with 0600 permissions. +func TestSaveIdentityFilePermissions(t *testing.T) { + dir := t.TempDir() + if err := vcs.Init(dir); err != nil { + t.Fatalf("Init: %v", err) + } + if err := vcs.SaveIdentity(dir, "testuser"); err != nil { + t.Fatalf("SaveIdentity: %v", err) + } + + info, err := os.Stat(filepath.Join(dir, vcs.RepoDirName, "config")) + if err != nil { + t.Fatalf("Stat config: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("config file permissions = %o, want 0600", perm) + } +} + +// TestSaveIdentityOverwrite verifies that calling SaveIdentity twice updates the stored value. +func TestSaveIdentityOverwrite(t *testing.T) { + dir := t.TempDir() + if err := vcs.Init(dir); err != nil { + t.Fatalf("Init: %v", err) + } + + if err := vcs.SaveIdentity(dir, "old-user"); err != nil { + t.Fatalf("SaveIdentity (first): %v", err) + } + if err := vcs.SaveIdentity(dir, "new-user"); err != nil { + t.Fatalf("SaveIdentity (second): %v", err) + } + + got, err := vcs.LoadIdentity(dir) + if err != nil { + t.Fatalf("LoadIdentity: %v", err) + } + if got != "new-user" { + t.Errorf("LoadIdentity = %q, want %q", got, "new-user") + } +} + +// TestLoadIdentityCorruptFile returns an error on malformed JSON. +func TestLoadIdentityCorruptFile(t *testing.T) { + dir := t.TempDir() + if err := vcs.Init(dir); err != nil { + t.Fatalf("Init: %v", err) + } + + configPath := filepath.Join(dir, vcs.RepoDirName, "config") + if err := os.WriteFile(configPath, []byte("not valid json"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, err := vcs.LoadIdentity(dir) + if err == nil { + t.Error("LoadIdentity expected error for corrupt config, got nil") + } +} + +// TestResolveClientID prefers the environment variable over the build-time default. +func TestResolveClientID(t *testing.T) { + const envKey = "DEEPDIFFDB_GITHUB_CLIENT_ID" + + // Save and restore original value + original := os.Getenv(envKey) + defer func() { + if original == "" { + os.Unsetenv(envKey) //nolint:errcheck + } else { + os.Setenv(envKey, original) //nolint:errcheck + } + }() + + // When env var is set it should take precedence + os.Setenv(envKey, "env-client-id") //nolint:errcheck + if got := vcs.ResolveClientID(); got != "env-client-id" { + t.Errorf("ResolveClientID with env = %q, want %q", got, "env-client-id") + } + + // When env var is cleared, falls back to GitHubClientID (may be empty in CI) + os.Unsetenv(envKey) //nolint:errcheck + // We just assert it doesn't panic — the value depends on build-time injection + _ = vcs.ResolveClientID() +} From 38cca3e9fab0afad0dd5f16c9a0d5cde90e8123e Mon Sep 17 00:00:00 2001 From: iamvirul Date: Sat, 4 Apr 2026 16:01:23 +0530 Subject: [PATCH 2/6] docs: update all documentation for GitHub OAuth author verification feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG.md: add [Unreleased] entry for issue #77 - ROADMAP.md: mark GitHub auth as in-progress, strikethrough completed versioning items - README.md: update version workflow — no --author needed when authenticated - website/docs/commands/version.md: add --skip-auth flag, author resolution order, GitHub OAuth setup guide, updated storage layout with config file - website/docs/features/git-versioning.md: add verified authorship section, update repo layout diagram and typical workflow --- CHANGELOG.md | 9 ++ README.md | 16 ++-- ROADMAP.md | 13 ++- website/docs/commands/version.md | 112 ++++++++++++++++++++++-- website/docs/features/git-versioning.md | 21 +++-- 5 files changed, 145 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e49bc9..ddf4add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **GitHub OAuth author verification for `version commit`** (issue #77) + - `version init` now prompts to authenticate via GitHub device flow; verified username stored in `.deepdiffdb/config` (`0o600` permissions, token never persisted) + - `version init --skip-auth` bypasses the prompt for CI/scripted environments + - `version commit` reads verified identity from config and uses `github:` as author automatically; `--author` flag still accepted when no config identity exists (backward compatible) + - Client ID configured via `DEEPDIFFDB_GITHUB_CLIENT_ID` env var or build-time `-ldflags` injection + - New `internal/version/auth.go`: `RunGitHubDeviceFlow`, `LoadIdentity`, `SaveIdentity`, `ResolveClientID` + - 5 new unit tests in `tests/version/auth_test.go` + ## [1.1.0] - 2026-04-01 ### Added diff --git a/README.md b/README.md index f23f927..81b3781 100644 --- a/README.md +++ b/README.md @@ -505,22 +505,24 @@ deepdiffdb version [flags] #### Example Workflow ```bash -# Initialise once per project +# Initialise once per project — authenticate with GitHub for verified authorship deepdiffdb version init +# → GitHub device flow: visit URL, enter code, author stored as github: +# → use --skip-auth in CI or if GitHub auth is not needed -# Commit a baseline snapshot -deepdiffdb version commit --config deepdiffdb.config.yaml --message "V1: baseline" --author "Alice" +# Commit a baseline snapshot (author resolved automatically when authenticated) +deepdiffdb version commit --config deepdiffdb.config.yaml --message "V1: baseline" # Create a feature branch and switch to it deepdiffdb version branch feature deepdiffdb version checkout feature # After applying schema changes to dev, commit on the feature branch -deepdiffdb version commit --config deepdiffdb.config.yaml --message "V2: add reviews table" --author "Bob" +deepdiffdb version commit --config deepdiffdb.config.yaml --message "V2: add reviews table" # Switch back to main and commit a hotfix deepdiffdb version checkout main -deepdiffdb version commit --config deepdiffdb.config.yaml --message "V2: hotfix index" --author "Alice" +deepdiffdb version commit --config deepdiffdb.config.yaml --message "V2: hotfix index" # Browse history with an ASCII branch graph deepdiffdb version tree @@ -532,7 +534,9 @@ deepdiffdb version diff deepdiffdb version rollback --out rollback.sql ``` -**Storage:** Commits are stored as zlib-compressed objects in `.deepdiffdb/objects/<2-char>/<62-char>` (Git-style fanout, content-addressable by SHA-256). Branch tips live in `.deepdiffdb/refs/heads/`; HEAD is a symbolic ref (`ref: refs/heads/main`). Add `.deepdiffdb/` to your `.gitignore` or commit it to share history with your team. +**Storage:** Commits are stored as zlib-compressed objects in `.deepdiffdb/objects/<2-char>/<62-char>` (Git-style fanout, content-addressable by SHA-256). Branch tips live in `.deepdiffdb/refs/heads/`; HEAD is a symbolic ref (`ref: refs/heads/main`). `.deepdiffdb/config` holds the verified GitHub identity (token never stored). Add `.deepdiffdb/config` to `.gitignore` to keep your identity local; commit the rest to share history with your team. + +**Verified authorship:** `version init` authenticates via GitHub device flow. Once authenticated, every commit is attributed to `github:` automatically — no `--author` flag needed. Pass `--author` explicitly when not authenticated. **Rollback SQL:** Each commit stores full schema snapshots, so rollback SQL can be generated at any time without a live database. Destructive operations (DROP TABLE, DROP COLUMN) are commented out by default — identical safety behaviour to `schema-migrate`. diff --git a/ROADMAP.md b/ROADMAP.md index 1d08734..e527ce2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -185,11 +185,18 @@ We release a new version every **Saturday**. Each release includes one or more f - ~~Store diff history~~ → `version commit` — SHA-256 content-addressable commit objects in `.deepdiffdb/objects/` - ~~Diff between any two versions~~ → `version diff

` — schema evolution comparison - ~~Rollback capabilities~~ → `version rollback ` — driver-aware rollback SQL generation - - `version init` — repository initialisation - - `version log` — full commit history with drift markers + - ~~`version init`~~ — repository initialisation + - ~~`version log`~~ — full commit history with drift markers + - ~~`version branch` / `version checkout` / `version tree`~~ — branching and ASCII graph - See [Sample 17](https://github.com/iamvirul/deepdiff-db/tree/main/samples/17-git-like-versioning) for end-to-end demo -2. **CI/CD Integration** +2. **GitHub OAuth Author Verification** 🚧 **In Progress (issue #77)** + - ~~`version init` GitHub device flow authentication~~ → implemented + - ~~`version commit` reads verified `github:` from `.deepdiffdb/config`~~ → implemented + - Build-time client ID injection via `-ldflags` in `release.yml` → pending + - GitHub OAuth App registration and client ID baked into releases → pending + +3. **CI/CD Integration** - GitHub Actions plugin - GitLab CI integration - Jenkins plugin diff --git a/website/docs/commands/version.md b/website/docs/commands/version.md index 017e186..2fb8d69 100644 --- a/website/docs/commands/version.md +++ b/website/docs/commands/version.md @@ -40,12 +40,36 @@ deepdiffdb version init | Flag | Default | Description | |---|---|---| | `--dir` | `.` | Directory to initialise | +| `--skip-auth` | `false` | Skip the GitHub authentication prompt | + +### GitHub Authentication + +After initialising the repo, `version init` prompts you to authenticate with GitHub using the **device flow** (no browser redirect required): + +``` +Authenticate with GitHub to verify commit authorship? [Y/n]: + + Open: https://github.com/login/device + Code: ABCD-1234 + + Waiting for authorization ........ ✓ +Authenticated as github:iamvirul — your commits will be signed automatically. +``` + +The verified username is stored in `.deepdiffdb/config` as `{"github_user": "iamvirul"}`. The access token is used only to confirm identity and is **never written to disk**. + +Use `--skip-auth` to bypass the prompt in CI pipelines or scripted environments — and use `--author` on `version commit` instead. + +:::info Setting up the OAuth App +`version init` requires `DEEPDIFFDB_GITHUB_CLIENT_ID` to be set (or baked into the binary at build time). See the [GitHub OAuth setup guide](#github-oauth-setup) below. +::: ### What it creates ``` .deepdiffdb/ HEAD ← symbolic ref: "ref: refs/heads/main" + config ← verified identity: {"github_user": "..."} (0o600) objects/ ← zlib-compressed commit objects (Git fanout layout) refs/ heads/ @@ -58,6 +82,12 @@ deepdiffdb version init cd my-project deepdiffdb version init # Initialised version repository in ./.deepdiffdb +# Authenticate with GitHub to verify commit authorship? [Y/n]: y +# ... +# Authenticated as github:iamvirul + +# Skip auth (CI) +deepdiffdb version init --skip-auth ``` --- @@ -84,23 +114,39 @@ deepdiffdb version commit --message "describe the change" [flags] |---|---|---| | `--config` | `deepdiffdb.config.yaml` | Path to configuration file | | `--message` | _(required)_ | Commit message | -| `--author` | `$USER` | Author name | +| `--author` | `$USER` | Author name — **ignored** when GitHub identity is stored in `.deepdiffdb/config` | | `--verbose` | `false` | Enable debug-level logging | | `--log-file` | _(none)_ | Write logs to file | | `--log-level` | `info` | Log level: `debug`, `info`, `warn`, `error` | | `--log-format` | `text` | Log format: `text` or `json` | +### Author resolution + +The author is resolved in this order: + +1. **Verified GitHub identity** — if `.deepdiffdb/config` contains a `github_user`, the author is set to `github:` automatically. Passing `--author` alongside a stored identity prints a warning and the flag is ignored. +2. **`--author` flag** — used when no config identity exists. +3. **`$USER` environment variable** — fallback when neither is set. +4. **Error** — if none of the above are available, `version commit` exits with an error asking you to authenticate or pass `--author`. + ### Example ```bash +# With GitHub authentication (author set automatically) deepdiffdb version commit \ --config deepdiffdb.config.yaml \ - --message "V2: add category_id FK and customer_email column" \ - --author "Alice" + --message "V2: add category_id FK and customer_email column" # [e35c16c9] V2: add category_id FK and customer_email column +# Author: github:iamvirul # Schema drift detected. # Data differences detected. + +# Without authentication (explicit author) +deepdiffdb version commit \ + --config deepdiffdb.config.yaml \ + --message "V2: add category_id FK and customer_email column" \ + --author "Alice" ``` ### Commit object location @@ -353,6 +399,7 @@ Each column in the graph represents a branch lane: ``` .deepdiffdb/ HEAD ← symbolic ref: "ref: refs/heads/" + config ← verified identity {"github_user": "..."} (0o600) objects/ <2-hex>/ <62-hex> ← zlib-compressed commit object (Git fanout) @@ -365,16 +412,59 @@ Each column in the graph represents a branch lane: Each commit object is self-contained — it includes all diff results and schema snapshots, so the entire history is portable. Commit the `.deepdiffdb/` directory to share history with your team, or add it to `.gitignore` to keep it local. +:::tip +Add `.deepdiffdb/config` to your `.gitignore` — it contains your personal identity and should not be shared. +::: + +--- + +## GitHub OAuth Setup + +To enable author verification, register a GitHub OAuth App: + +1. Go to [github.com/settings/applications/new](https://github.com/settings/applications/new) +2. Fill in: + - **Application name:** `DeepDiff DB` + - **Homepage URL:** `https://github.com/iamvirul/deepdiff-db` + - **Authorization callback URL:** `http://localhost` _(not used by device flow)_ +3. Tick **Enable Device Flow** +4. Click **Register application** and copy the **Client ID** + +Then set the client ID in one of two ways: + +**Environment variable (personal use):** +```bash +# ~/.zshrc or ~/.bashrc +export DEEPDIFFDB_GITHUB_CLIENT_ID="your_client_id" +``` + +**Baked into released binaries (build-time injection):** +```yaml +# In .github/workflows/release.yml +- name: Build + run: | + go build \ + -ldflags "-X github.com/iamvirul/deepdiff-db/internal/version.GitHubClientID=${{ secrets.DEEPDIFFDB_GITHUB_CLIENT_ID }}" \ + -o deepdiffdb \ + ./cmd/deepdiffdb +``` + +Add `DEEPDIFFDB_GITHUB_CLIENT_ID` as a repository secret in **Settings → Secrets and variables → Actions**. + --- ## Typical Workflow ```bash -# 1. Initialise once +# 1. Initialise once — authenticate with GitHub for verified authorship deepdiffdb version init +# → prompts for GitHub device flow auth +# → stores github:iamvirul in .deepdiffdb/config -# 2. Commit baseline (prod == dev) -deepdiffdb version commit --message "V1: baseline" --author "Alice" +# 2. Commit baseline (prod == dev) — author resolved automatically +deepdiffdb version commit \ + --config deepdiffdb.config.yaml \ + --message "V1: baseline e-commerce schema" # 3. Create a feature branch for experimental schema work deepdiffdb version branch feature @@ -384,11 +474,15 @@ deepdiffdb version checkout feature # (ALTER TABLE, CREATE TABLE, etc.) # 5. Commit the drift on the feature branch -deepdiffdb version commit --message "V2: add reviews table" --author "Bob" +deepdiffdb version commit \ + --config deepdiffdb.config.yaml \ + --message "V2: add reviews table and avg_rating column" # 6. Switch back to main for a production hotfix deepdiffdb version checkout main -deepdiffdb version commit --message "V2: hotfix — drop unused index" --author "Alice" +deepdiffdb version commit \ + --config deepdiffdb.config.yaml \ + --message "V2: hotfix — drop unused index" # 7. Visualise the full branch history deepdiffdb version tree @@ -396,7 +490,7 @@ deepdiffdb version tree # 8. See exactly what changed between two commits deepdiffdb version diff -# 9. If a commit needs to be rolled back, generate the SQL +# 9. Generate rollback SQL for a commit deepdiffdb version rollback --out rollback_v2.sql ``` diff --git a/website/docs/features/git-versioning.md b/website/docs/features/git-versioning.md index c3573f6..ce0b0ff 100644 --- a/website/docs/features/git-versioning.md +++ b/website/docs/features/git-versioning.md @@ -17,6 +17,7 @@ Added in **v1.1.0**. DeepDiff DB ships a full Git-inspired versioning system for ``` .deepdiffdb/ HEAD ← symbolic ref: "ref: refs/heads/main" + config ← verified GitHub identity (0o600, token never stored) objects/ <2-hex>/ <62-hex> ← zlib-compressed commit object (Git fanout layout) @@ -26,7 +27,11 @@ Added in **v1.1.0**. DeepDiff DB ships a full Git-inspired versioning system for ← one file per branch ``` -Each commit object is self-contained: it stores the full `schema.DiffResult`, `content.DataDiff`, both schema snapshots (prod + dev), author, message, timestamp, parent hash, and a SHA-256 content-addressable hash. The entire history is portable — commit `.deepdiffdb/` to share it with your team, or add it to `.gitignore` to keep it local. +Each commit object is self-contained: it stores the full `schema.DiffResult`, `content.DataDiff`, both schema snapshots (prod + dev), author, message, timestamp, parent hash, and a SHA-256 content-addressable hash. The entire history is portable — commit `.deepdiffdb/` to share it with your team, or add `.deepdiffdb/config` to `.gitignore` to keep your identity local. + +### Verified Authorship + +`version init` prompts you to authenticate with GitHub via the device flow. Once authenticated, every `version commit` automatically uses `github:` as the author — no `--author` flag needed. The access token is discarded after identity verification; only the username is stored. ### Commits @@ -55,14 +60,16 @@ HEAD is a symbolic ref that points to the active branch. `version commit` always ## Typical Workflow ```bash -# 1. Initialise once per project +# 1. Initialise once per project — authenticate with GitHub for verified authorship deepdiffdb version init +# → prompts for GitHub device flow (no browser redirect needed) +# → stores github:iamvirul in .deepdiffdb/config; token discarded # 2. Commit baseline snapshot (prod == dev at project start) +# Author resolved automatically from .deepdiffdb/config deepdiffdb version commit \ --config deepdiffdb.config.yaml \ - --message "V1: baseline e-commerce schema" \ - --author "Alice" + --message "V1: baseline e-commerce schema" # 3. Create a feature branch for experimental schema work deepdiffdb version branch feature @@ -74,15 +81,13 @@ deepdiffdb version checkout feature # 5. Commit the drift on the feature branch deepdiffdb version commit \ --config deepdiffdb.config.yaml \ - --message "V2: add reviews table and avg_rating column" \ - --author "Bob" + --message "V2: add reviews table and avg_rating column" # 6. Switch back to main for a production hotfix deepdiffdb version checkout main deepdiffdb version commit \ --config deepdiffdb.config.yaml \ - --message "V2: hotfix — drop unused index" \ - --author "Alice" + --message "V2: hotfix — drop unused index" # 7. Visualise the full branch history deepdiffdb version tree From 121d416c999e4503374912c6fedfe60f06c3f38d Mon Sep 17 00:00:00 2001 From: iamvirul Date: Sat, 4 Apr 2026 16:31:33 +0530 Subject: [PATCH 3/6] docs(cicd): add version commit CI example and --skip-auth guidance --- website/docs/deployment/cicd.md | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/website/docs/deployment/cicd.md b/website/docs/deployment/cicd.md index 2ab2d49..e412a9f 100644 --- a/website/docs/deployment/cicd.md +++ b/website/docs/deployment/cicd.md @@ -98,9 +98,54 @@ repos: pass_filenames: false ``` +## Using `version commit` in CI + +To record a versioned snapshot on every merge to main, add a `version commit` step to your pipeline. Use `--skip-auth` to bypass the GitHub device flow prompt and `--author` to identify the pipeline: + +```yaml +# GitHub Actions — snapshot on merge to main +name: Version Snapshot + +on: + push: + branches: [main] + +jobs: + version-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install DeepDiff DB + run: | + VERSION=$(curl -s https://api.github.com/repos/iamvirul/deepdiff-db/releases/latest \ + | grep '"tag_name"' | cut -d'"' -f4) + curl -fsSL \ + "https://github.com/iamvirul/deepdiff-db/releases/download/${VERSION}/deepdiffdb_${VERSION}_linux_amd64.tar.gz" \ + | tar -xz deepdiffdb + sudo mv deepdiffdb /usr/local/bin/deepdiffdb + + - name: Init version repo (skip auth in CI) + run: deepdiffdb version init --skip-auth + + - name: Commit version snapshot + run: | + deepdiffdb version commit \ + --config deepdiffdb.config.yaml \ + --message "CI snapshot: ${{ github.sha }}" \ + --author "ci/github-actions" +``` + +:::tip +`--skip-auth` must be passed on the first `version init` run. Subsequent runs on the same checkout are idempotent. If `.deepdiffdb/` is committed to the repo, `version init` is only needed once locally. +::: + +--- + ## Tips - **Pin the version** in CI with `DEEPDIFFDB_VERSION: v1.1.0` to avoid unexpected upgrades. - **Cache the binary** between runs using your CI platform's cache action. - **Fail fast on schema changes** — set `--exit-code` (coming in a future release) to fail CI if any drift is detected. - **Use Docker** in CI for a hermetic environment: `docker run ghcr.io/iamvirul/deepdiff-db:latest diff`. +- **Skip GitHub auth in CI** — always pass `--skip-auth` to `version init` in pipelines; use `--author "ci/"` on `version commit` to identify the pipeline as the committer. From d8378fdd7db6fe2b373dd663682e7dc1d90543bf Mon Sep 17 00:00:00 2001 From: iamvirul Date: Sat, 4 Apr 2026 16:38:28 +0530 Subject: [PATCH 4/6] fix(security): suppress gosec G101 false positive on GitHub OAuth token URL constant --- internal/version/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/version/auth.go b/internal/version/auth.go index ed68fa0..ac22662 100644 --- a/internal/version/auth.go +++ b/internal/version/auth.go @@ -29,7 +29,7 @@ const ( configFileName = "config" githubDeviceURL = "https://github.com/login/device/code" - githubTokenURL = "https://github.com/login/oauth/access_token" + githubTokenURL = "https://github.com/login/oauth/access_token" // #nosec G101 -- URL endpoint, not a credential githubUserURL = "https://api.github.com/user" ) From a6218d67895ad0639d935f859f584a86829b4073 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Sat, 4 Apr 2026 16:51:39 +0530 Subject: [PATCH 5/6] fix: address all CodeRabbit review comments on PR #78 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: correct test count from 5 → 6 unit tests - auth.go: add os.Chmod(0o600) after WriteFile so overwrites preserve permissions - main.go: remove $USER fallback in author resolution — explicit author or verified identity required; ambiguous fallback undermined the verification contract - main.go: fix runVersionInit early return — existing repo now continues to auth prompt, enabling users to add GitHub identity to a repo initialised with --skip-auth - auth_test.go: TestResolveClientID now explicitly asserts GitHubClientID fallback value instead of just asserting no panic; TestSaveIdentityOverwrite now verifies 0600 permissions are preserved after an overwrite - git-versioning.md: fix misleading comment — config stores plain username JSON, not "github:" prefix (prefix is added at commit time) - blog: add closing line --- CHANGELOG.md | 2 +- cmd/deepdiffdb/main.go | 16 +++++----- internal/version/auth.go | 4 +++ tests/version/auth_test.go | 29 ++++++++++++++----- .../blog/2025-12-17-building-deepdiffdb.md | 2 ++ website/docs/features/git-versioning.md | 2 +- 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddf4add..f7efe30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `version commit` reads verified identity from config and uses `github:` as author automatically; `--author` flag still accepted when no config identity exists (backward compatible) - Client ID configured via `DEEPDIFFDB_GITHUB_CLIENT_ID` env var or build-time `-ldflags` injection - New `internal/version/auth.go`: `RunGitHubDeviceFlow`, `LoadIdentity`, `SaveIdentity`, `ResolveClientID` - - 5 new unit tests in `tests/version/auth_test.go` + - 6 new unit tests in `tests/version/auth_test.go` ## [1.1.0] - 2026-04-01 diff --git a/cmd/deepdiffdb/main.go b/cmd/deepdiffdb/main.go index 88fd8fa..bebbd5b 100644 --- a/cmd/deepdiffdb/main.go +++ b/cmd/deepdiffdb/main.go @@ -1571,14 +1571,15 @@ func runVersionInit(args []string) error { if err := fs.Parse(args); err != nil { return err } - if vcs.IsInitialized(*dir) { + alreadyInit := vcs.IsInitialized(*dir) + if alreadyInit { fmt.Println("Version repository already initialised.") - return nil - } - if err := vcs.Init(*dir); err != nil { - return err + } else { + if err := vcs.Init(*dir); err != nil { + return err + } + fmt.Printf("Initialised version repository in %s/%s\n", *dir, vcs.RepoDirName) } - fmt.Printf("Initialised version repository in %s/%s\n", *dir, vcs.RepoDirName) if *skipAuth { fmt.Println("Skipping GitHub authentication. Use --author on version commit to set your name.") @@ -1717,9 +1718,6 @@ func runVersionCommit(args []string) error { } } else { authorName = *author - if authorName == "" { - authorName = os.Getenv("USER") - } if authorName == "" { return fmt.Errorf("no author set: authenticate with 'version init' or pass --author ") } diff --git a/internal/version/auth.go b/internal/version/auth.go index ac22662..34c6151 100644 --- a/internal/version/auth.go +++ b/internal/version/auth.go @@ -69,6 +69,10 @@ func SaveIdentity(dir, username string) error { if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { // #nosec G306 return fmt.Errorf("writing identity config: %w", err) } + // WriteFile only applies permissions on creation, not on overwrite — chmod explicitly. + if err := os.Chmod(path, 0o600); err != nil { + return fmt.Errorf("setting identity config permissions: %w", err) + } return nil } diff --git a/tests/version/auth_test.go b/tests/version/auth_test.go index 460de52..26a275f 100644 --- a/tests/version/auth_test.go +++ b/tests/version/auth_test.go @@ -86,6 +86,15 @@ func TestSaveIdentityOverwrite(t *testing.T) { if got != "new-user" { t.Errorf("LoadIdentity = %q, want %q", got, "new-user") } + + // Overwrite must preserve 0600 permissions on the existing file. + info, err := os.Stat(filepath.Join(dir, vcs.RepoDirName, "config")) + if err != nil { + t.Fatalf("Stat config after overwrite: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("config permissions after overwrite = %o, want 0600", perm) + } } // TestLoadIdentityCorruptFile returns an error on malformed JSON. @@ -110,24 +119,28 @@ func TestLoadIdentityCorruptFile(t *testing.T) { func TestResolveClientID(t *testing.T) { const envKey = "DEEPDIFFDB_GITHUB_CLIENT_ID" - // Save and restore original value - original := os.Getenv(envKey) + // Save and restore original env var and GitHubClientID package variable. + originalEnv := os.Getenv(envKey) + originalClientID := vcs.GitHubClientID defer func() { - if original == "" { + vcs.GitHubClientID = originalClientID + if originalEnv == "" { os.Unsetenv(envKey) //nolint:errcheck } else { - os.Setenv(envKey, original) //nolint:errcheck + os.Setenv(envKey, originalEnv) //nolint:errcheck } }() - // When env var is set it should take precedence + // When env var is set it should take precedence over the build-time default. + vcs.GitHubClientID = "build-client-id" os.Setenv(envKey, "env-client-id") //nolint:errcheck if got := vcs.ResolveClientID(); got != "env-client-id" { t.Errorf("ResolveClientID with env = %q, want %q", got, "env-client-id") } - // When env var is cleared, falls back to GitHubClientID (may be empty in CI) + // When env var is cleared, falls back to GitHubClientID. os.Unsetenv(envKey) //nolint:errcheck - // We just assert it doesn't panic — the value depends on build-time injection - _ = vcs.ResolveClientID() + if got := vcs.ResolveClientID(); got != "build-client-id" { + t.Errorf("ResolveClientID fallback = %q, want %q", got, "build-client-id") + } } diff --git a/website/blog/2025-12-17-building-deepdiffdb.md b/website/blog/2025-12-17-building-deepdiffdb.md index d84179c..af85e6b 100644 --- a/website/blog/2025-12-17-building-deepdiffdb.md +++ b/website/blog/2025-12-17-building-deepdiffdb.md @@ -64,3 +64,5 @@ brew install iamvirul/deepdiff-db/deepdiff-db Or grab a binary from the [releases page](https://github.com/iamvirul/deepdiff-db/releases). The code is open source at [github.com/iamvirul/deepdiff-db](https://github.com/iamvirul/deepdiff-db). Issues, PRs, and feedback are all welcome. + +*Happy diffing — and may your prod and dev schemas never drift apart. 🚀* diff --git a/website/docs/features/git-versioning.md b/website/docs/features/git-versioning.md index ce0b0ff..67c8846 100644 --- a/website/docs/features/git-versioning.md +++ b/website/docs/features/git-versioning.md @@ -63,7 +63,7 @@ HEAD is a symbolic ref that points to the active branch. `version commit` always # 1. Initialise once per project — authenticate with GitHub for verified authorship deepdiffdb version init # → prompts for GitHub device flow (no browser redirect needed) -# → stores github:iamvirul in .deepdiffdb/config; token discarded +# → stores {"github_user":"iamvirul"} in .deepdiffdb/config; token discarded # 2. Commit baseline snapshot (prod == dev at project start) # Author resolved automatically from .deepdiffdb/config From f10905441b2773abc677fad04612bdf56a1c1503 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Sat, 4 Apr 2026 16:53:19 +0530 Subject: [PATCH 6/6] docs: mark GitHub OAuth author verification as done in ROADMAP --- ROADMAP.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e527ce2..c617503 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,11 +190,11 @@ We release a new version every **Saturday**. Each release includes one or more f - ~~`version branch` / `version checkout` / `version tree`~~ — branching and ASCII graph - See [Sample 17](https://github.com/iamvirul/deepdiff-db/tree/main/samples/17-git-like-versioning) for end-to-end demo -2. **GitHub OAuth Author Verification** 🚧 **In Progress (issue #77)** - - ~~`version init` GitHub device flow authentication~~ → implemented - - ~~`version commit` reads verified `github:` from `.deepdiffdb/config`~~ → implemented - - Build-time client ID injection via `-ldflags` in `release.yml` → pending - - GitHub OAuth App registration and client ID baked into releases → pending +2. **GitHub OAuth Author Verification** ✅ **Done (issue #77)** + - ~~`version init` GitHub device flow authentication~~ + - ~~`version commit` reads verified `github:` from `.deepdiffdb/config`~~ + - ~~Build-time client ID injection via `-ldflags` in `release.yml`~~ + - ~~`DEEPDIFFDB_GITHUB_CLIENT_ID` env var override for local use~~ 3. **CI/CD Integration** - GitHub Actions plugin