Skip to content

Commit 388acf9

Browse files
authored
Merge pull request #452 from depot/watts/dep-3838-depo-ci-migrate-secrets-and-variables-should-default-to-repo
feat: default ci migrate secrets and variables to repo scope
2 parents 94f0f23 + 0552c5d commit 388acf9

File tree

2 files changed

+170
-5
lines changed

2 files changed

+170
-5
lines changed

pkg/cmd/ci/migrate.go

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"errors"
66
"fmt"
77
"io"
8+
"net/url"
89
"os"
10+
"os/exec"
911
"path/filepath"
1012
"sort"
1113
"strings"
@@ -22,6 +24,7 @@ import (
2224
type migrateOptions struct {
2325
orgID string
2426
token string
27+
repo string
2528
yes bool
2629
secrets []string
2730
variables []string
@@ -51,6 +54,7 @@ func NewCmdMigrate() *cobra.Command {
5154
flags.BoolVar(&opts.yes, "yes", false, "Run in non-interactive mode")
5255
flags.StringArrayVar(&opts.secrets, "secret", nil, "CI secret assignment in KEY=VALUE format (repeatable)")
5356
flags.StringArrayVar(&opts.variables, "var", nil, "CI variable assignment in KEY=VALUE format (repeatable)")
57+
flags.StringVar(&opts.repo, "repo", "", "Scope secrets and variables to a specific repository (e.g. owner/repo); auto-detected from git remote if not provided")
5458
flags.BoolVar(&opts.overwrite, "overwrite", false, "Overwrite existing .depot/ directory")
5559

5660
return cmd
@@ -99,6 +103,16 @@ func runMigrate(ctx context.Context, opts migrateOptions) error {
99103

100104
fmt.Fprintf(out, "Found %d workflow(s) in .github/workflows\n", len(workflows))
101105

106+
repo := opts.repo
107+
if repo == "" {
108+
repo = detectRepoFromGitRemote(workDir)
109+
}
110+
if repo != "" {
111+
fmt.Fprintf(out, "Secrets and variables will be scoped to repo %s\n", repo)
112+
} else {
113+
fmt.Fprintln(out, "Could not detect repository from git remote; secrets and variables will be org-scoped (use --repo owner/name to override)")
114+
}
115+
102116
selectedWorkflows := workflows
103117
warnings := parseWarnings
104118
for _, workflow := range workflows {
@@ -248,7 +262,7 @@ func runMigrate(ctx context.Context, opts migrateOptions) error {
248262
return err
249263
}
250264

251-
if err := api.CIAddSecret(ctx, token, orgID, name, value); err != nil {
265+
if err := api.CIAddSecretWithDescription(ctx, token, orgID, name, value, "", repo); err != nil {
252266
return fmt.Errorf("failed to configure secret %s: %w", name, err)
253267
}
254268
configuredSecrets = append(configuredSecrets, name)
@@ -279,7 +293,11 @@ func runMigrate(ctx context.Context, opts migrateOptions) error {
279293
}
280294
if len(missingSecrets) > 0 {
281295
sort.Strings(missingSecrets)
282-
warnings = append(warnings, fmt.Sprintf("configure missing secrets with `depot ci secrets add <NAME> --value <VALUE>` (missing: %s)", strings.Join(missingSecrets, ", ")))
296+
hint := "depot ci secrets add <NAME> --value <VALUE>"
297+
if repo != "" {
298+
hint = fmt.Sprintf("depot ci secrets add <NAME> --repo %s --value <VALUE>", repo)
299+
}
300+
warnings = append(warnings, fmt.Sprintf("configure missing secrets with `%s` (missing: %s)", hint, strings.Join(missingSecrets, ", ")))
283301
}
284302
} else if len(detectedSecrets) > 0 {
285303
for _, name := range detectedSecrets {
@@ -320,7 +338,7 @@ func runMigrate(ctx context.Context, opts migrateOptions) error {
320338
return err
321339
}
322340

323-
if err := api.CIAddVariable(ctx, token, orgID, name, value, ""); err != nil {
341+
if err := api.CIAddVariable(ctx, token, orgID, name, value, repo); err != nil {
324342
return fmt.Errorf("failed to configure variable %s: %w", name, err)
325343
}
326344
configuredVariables = append(configuredVariables, name)
@@ -351,7 +369,11 @@ func runMigrate(ctx context.Context, opts migrateOptions) error {
351369
}
352370
if len(missingVariables) > 0 {
353371
sort.Strings(missingVariables)
354-
warnings = append(warnings, fmt.Sprintf("configure missing variables with `depot ci vars add <NAME> --value <VALUE>` (missing: %s)", strings.Join(missingVariables, ", ")))
372+
hint := "depot ci vars add <NAME> --value <VALUE>"
373+
if repo != "" {
374+
hint = fmt.Sprintf("depot ci vars add <NAME> --repo %s --value <VALUE>", repo)
375+
}
376+
warnings = append(warnings, fmt.Sprintf("configure missing variables with `%s` (missing: %s)", hint, strings.Join(missingVariables, ", ")))
355377
}
356378
} else if len(detectedVariables) > 0 {
357379
for _, name := range detectedVariables {
@@ -378,6 +400,11 @@ func runMigrate(ctx context.Context, opts migrateOptions) error {
378400
fmt.Fprintln(out, "Migration summary:")
379401
fmt.Fprintf(out, "- Workflows selected: %d\n", len(selectedWorkflows))
380402
fmt.Fprintf(out, "- Files copied: %d\n", len(copyResult.FilesCopied))
403+
if repo != "" {
404+
fmt.Fprintf(out, "- Secret/variable scope: repo (%s)\n", repo)
405+
} else {
406+
fmt.Fprintln(out, "- Secret/variable scope: org")
407+
}
381408
fmt.Fprintf(out, "- Secrets detected: %d\n", len(detectedSecrets))
382409
fmt.Fprintf(out, "- Secrets configured: %d\n", len(configuredSecrets))
383410
fmt.Fprintf(out, "- Variables detected: %d\n", len(detectedVariables))
@@ -585,6 +612,47 @@ func detectVariablesFromWorkflows(workflows []*migrate.WorkflowFile) ([]string,
585612
return deduped, nil
586613
}
587614

615+
// detectRepoFromGitRemote attempts to extract owner/repo from the origin remote URL.
616+
func detectRepoFromGitRemote(dir string) string {
617+
cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin")
618+
out, err := cmd.Output()
619+
if err != nil {
620+
return ""
621+
}
622+
return parseGitHubRepo(strings.TrimSpace(string(out)))
623+
}
624+
625+
func parseGitHubRepo(remoteURL string) string {
626+
// SSH: git@github.com:owner/repo.git
627+
if strings.HasPrefix(remoteURL, "git@") {
628+
idx := strings.Index(remoteURL, ":")
629+
if idx < 0 {
630+
return ""
631+
}
632+
path := remoteURL[idx+1:]
633+
path = strings.TrimSuffix(path, ".git")
634+
parts := strings.SplitN(path, "/", 3)
635+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
636+
return ""
637+
}
638+
return parts[0] + "/" + parts[1]
639+
}
640+
641+
// HTTPS: https://github.com/owner/repo.git
642+
u, err := url.Parse(remoteURL)
643+
if err != nil {
644+
return ""
645+
}
646+
path := strings.TrimPrefix(u.Path, "/")
647+
path = strings.TrimSuffix(path, ".git")
648+
path = strings.TrimRight(path, "/")
649+
parts := strings.SplitN(path, "/", 3)
650+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
651+
return ""
652+
}
653+
return parts[0] + "/" + parts[1]
654+
}
655+
588656
func resolveMigrationAuth(ctx context.Context, opts migrateOptions) (string, string, error) {
589657
orgID := opts.orgID
590658
if orgID == "" {

pkg/cmd/ci/migrate_test.go

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
func TestNewCmdMigrateFlags(t *testing.T) {
1515
cmd := NewCmdMigrate()
1616

17-
flagNames := []string{"yes", "secret", "var", "org", "token", "overwrite"}
17+
flagNames := []string{"yes", "secret", "var", "org", "token", "repo", "overwrite"}
1818
for _, flagName := range flagNames {
1919
if cmd.Flags().Lookup(flagName) == nil {
2020
t.Fatalf("expected --%s flag to exist", flagName)
@@ -251,6 +251,103 @@ func TestCopySelectedWorkflowFilesCopiesOnlySelected(t *testing.T) {
251251
}
252252
}
253253

254+
func TestParseGitHubRepo(t *testing.T) {
255+
tests := []struct {
256+
name string
257+
input string
258+
expected string
259+
}{
260+
{"ssh", "git@github.com:depot/cli.git", "depot/cli"},
261+
{"ssh no .git", "git@github.com:depot/cli", "depot/cli"},
262+
{"https", "https://github.com/depot/cli.git", "depot/cli"},
263+
{"https no .git", "https://github.com/depot/cli", "depot/cli"},
264+
{"ssh with port", "git@github.com:org/repo-name.git", "org/repo-name"},
265+
{"empty", "", ""},
266+
{"no path", "git@github.com:", ""},
267+
{"too many segments", "git@github.com:a/b/c.git", ""},
268+
{"just host", "https://github.com", ""},
269+
{"only owner", "https://github.com/depot", ""},
270+
{"ssh empty owner", "git@github.com:/repo.git", ""},
271+
{"ssh empty repo", "git@github.com:owner/.git", ""},
272+
{"ssh empty both", "git@github.com:/.git", ""},
273+
{"https empty repo", "https://github.com/owner/", ""},
274+
{"https trailing slash", "https://github.com/owner/repo/", "owner/repo"},
275+
{"https too many segments", "https://github.com/a/b/c.git", ""},
276+
{"https subgroup", "https://gitlab.com/group/subgroup/project.git", ""},
277+
}
278+
279+
for _, tt := range tests {
280+
t.Run(tt.name, func(t *testing.T) {
281+
result := parseGitHubRepo(tt.input)
282+
if result != tt.expected {
283+
t.Errorf("parseGitHubRepo(%q) = %q, want %q", tt.input, result, tt.expected)
284+
}
285+
})
286+
}
287+
}
288+
289+
func TestRunMigrateShowsRepoScope(t *testing.T) {
290+
tmpDir := t.TempDir()
291+
workflowsDir := filepath.Join(tmpDir, ".github", "workflows")
292+
if err := os.MkdirAll(workflowsDir, 0o755); err != nil {
293+
t.Fatalf("failed to create workflows dir: %v", err)
294+
}
295+
if err := os.WriteFile(filepath.Join(workflowsDir, "ci.yml"), []byte("name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n"), 0o644); err != nil {
296+
t.Fatalf("failed to write workflow: %v", err)
297+
}
298+
299+
var stdout bytes.Buffer
300+
err := runMigrate(context.Background(), migrateOptions{
301+
yes: true,
302+
repo: "depot/example",
303+
dir: tmpDir,
304+
stdout: &stdout,
305+
})
306+
if err != nil {
307+
t.Fatalf("runMigrate returned error: %v", err)
308+
}
309+
310+
output := stdout.String()
311+
if !strings.Contains(output, "scoped to repo depot/example") {
312+
t.Fatalf("expected repo scope message, got output: %s", output)
313+
}
314+
if !strings.Contains(output, "Secret/variable scope: repo (depot/example)") {
315+
t.Fatalf("expected scope in summary, got output: %s", output)
316+
}
317+
}
318+
319+
func TestRunMigrateOrgScopeFallback(t *testing.T) {
320+
tmpDir := t.TempDir()
321+
workflowsDir := filepath.Join(tmpDir, ".github", "workflows")
322+
if err := os.MkdirAll(workflowsDir, 0o755); err != nil {
323+
t.Fatalf("failed to create workflows dir: %v", err)
324+
}
325+
if err := os.WriteFile(filepath.Join(workflowsDir, "ci.yml"), []byte("name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n"), 0o644); err != nil {
326+
t.Fatalf("failed to write workflow: %v", err)
327+
}
328+
329+
var stdout bytes.Buffer
330+
err := runMigrate(context.Background(), migrateOptions{
331+
yes: true,
332+
dir: tmpDir,
333+
stdout: &stdout,
334+
})
335+
if err != nil {
336+
t.Fatalf("runMigrate returned error: %v", err)
337+
}
338+
339+
output := stdout.String()
340+
if !strings.Contains(output, "org-scoped") {
341+
t.Fatalf("expected org-scope fallback message, got output: %s", output)
342+
}
343+
if !strings.Contains(output, "--repo owner/name") {
344+
t.Fatalf("expected --repo hint in fallback message, got output: %s", output)
345+
}
346+
if !strings.Contains(output, "Secret/variable scope: org") {
347+
t.Fatalf("expected org scope in summary, got output: %s", output)
348+
}
349+
}
350+
254351
func TestParseWorkflowDirWithWarningsSkipsInvalidFiles(t *testing.T) {
255352
workflowsDir := t.TempDir()
256353
if err := os.WriteFile(filepath.Join(workflowsDir, "ok.yml"), []byte("name: OK\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n"), 0o644); err != nil {

0 commit comments

Comments
 (0)