This commit is contained in:
2026-05-26 09:09:47 -05:00
parent 759bbac686
commit 35746cc781
10 changed files with 1038 additions and 17 deletions
+129 -6
View File
@@ -9,13 +9,136 @@ permissions:
contents: write
jobs:
release:
build-debian:
name: Build Debian 13
runs-on: ubuntu-latest
container:
image: debian:13-slim
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: Install dependencies
run: |
apt-get update && apt-get install -y git golang-go ca-certificates
- name: Checkout Code
uses: actions/checkout@v4
- name: Build
run: go build -v -o gh-repo-bootstrap-linux-debian13-amd64 main.go
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
go-version: stable
- uses: cli/gh-extension-precompile@v2
name: gh-repo-bootstrap-linux-debian13-amd64
path: gh-repo-bootstrap-linux-debian13-amd64
build-arch:
name: Build Arch Linux
runs-on: ubuntu-latest
container:
image: archlinux:latest
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm git go ca-certificates
- name: Checkout Code
uses: actions/checkout@v4
- name: Build
run: go build -v -o gh-repo-bootstrap-linux-arch-amd64 main.go
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
go_version: stable
name: gh-repo-bootstrap-linux-arch-amd64
path: gh-repo-bootstrap-linux-arch-amd64
build-fedora:
name: Build Fedora 44
runs-on: ubuntu-latest
container:
image: fedora:44
steps:
- name: Install dependencies
run: |
dnf install -y git golang ca-certificates
- name: Checkout Code
uses: actions/checkout@v4
- name: Build
run: go build -v -o gh-repo-bootstrap-linux-fedora44-amd64 main.go
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: gh-repo-bootstrap-linux-fedora44-amd64
path: gh-repo-bootstrap-linux-fedora44-amd64
build-windows:
name: Build Windows
runs-on: windows-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.26.3'
- name: Build
run: go build -v -o gh-repo-bootstrap-windows-amd64.exe main.go
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: gh-repo-bootstrap-windows-amd64.exe
path: gh-repo-bootstrap-windows-amd64.exe
build-macos:
name: Build macOS
runs-on: macos-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.26.3'
- name: Build
run: go build -v -o gh-repo-bootstrap-darwin-arm64 main.go
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: gh-repo-bootstrap-darwin-arm64
path: gh-repo-bootstrap-darwin-arm64
release:
name: Create Release
needs: [build-debian, build-arch, build-fedora, build-windows, build-macos]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: bin-artifacts
- name: Prepare Release Assets and Checksums
run: |
mkdir release-assets
find bin-artifacts -type f -exec cp {} release-assets/ \;
cd release-assets
echo "## SHA256 Checksums" > ../release_notes.txt
echo "" >> ../release_notes.txt
echo "| Filename | SHA256 Checksum |" >> ../release_notes.txt
echo "| --- | --- |" >> ../release_notes.txt
for file in *; do
sha=$(sha256sum "$file" | cut -d' ' -f1)
echo "| \`$file\` | \`$sha\` |" >> ../release_notes.txt
done
cat ../release_notes.txt
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ github.ref_name }}" \
--title "${{ github.ref_name }}" \
--notes-file release_notes.txt \
release-assets/*
+27
View File
@@ -0,0 +1,27 @@
name: Test Suite
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
test:
name: Run Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.26.3'
- name: Run Tests
run: |
go test -v -coverprofile=coverage.txt -covermode=atomic ./...
+1 -7
View File
@@ -1,7 +1 @@
# Pulumi
gh-repo-bootstrap
# Editors / OS
.DS_Store
.idea/
.vscode/
coverage.out .gitignore
+3 -1
View File
@@ -78,9 +78,11 @@ func (r *Resolver) ResolveTeam(s string) (int, error) {
return id, nil
}
var ExecCommand = exec.Command
// ghAPIID runs `gh api <path>` and returns the `.id` field of the response.
func ghAPIID(path string) (int, error) {
cmd := exec.Command("gh", "api", path)
cmd := ExecCommand("gh", "api", path)
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
+173
View File
@@ -0,0 +1,173 @@
package githubapi
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
)
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
defer os.Exit(0)
args := os.Args
for i, arg := range args {
if arg == "--" {
args = args[i+1:]
break
}
}
if len(args) < 3 {
fmt.Fprintf(os.Stderr, "invalid args: %v\n", args)
os.Exit(2)
}
command := args[0]
subCmd := args[1]
path := args[2]
if command != "gh" || subCmd != "api" {
fmt.Fprintf(os.Stderr, "expected command 'gh api', got: %s %s\n", command, subCmd)
os.Exit(2)
}
switch {
case path == "users/octocat":
fmt.Print(`{"id":583234}`)
case path == "users/error-user":
fmt.Fprint(os.Stderr, "http error 404")
os.Exit(1)
case path == "users/no-id-user":
fmt.Print(`{"login":"no-id-user"}`)
case path == "users/bad-json-user":
fmt.Print(`{invalid}`)
case path == "orgs/JMR-dev/teams/release-managers":
fmt.Print(`{"id":98765}`)
case path == "orgs/JMR-dev/teams/error-team":
fmt.Fprint(os.Stderr, "http error 404")
os.Exit(1)
default:
fmt.Fprintf(os.Stderr, "unknown path: %s\n", path)
os.Exit(2)
}
}
func mockExec(command string, args ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", command}
cs = append(cs, args...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
return cmd
}
func TestResolveUser(t *testing.T) {
oldExec := ExecCommand
ExecCommand = mockExec
defer func() { ExecCommand = oldExec }()
r := New()
// 1. Numeric ID passes through
id, err := r.ResolveUser("12345")
if err != nil {
t.Fatalf("unexpected error for numeric user ID: %v", err)
}
if id != 12345 {
t.Errorf("expected 12345, got %d", id)
}
// 2. Resolve login (with leading @)
id, err = r.ResolveUser("@octocat")
if err != nil {
t.Fatalf("unexpected error for user @octocat: %v", err)
}
if id != 583234 {
t.Errorf("expected 583234, got %d", id)
}
// 3. Cached lookup
id, err = r.ResolveUser("octocat")
if err != nil {
t.Fatalf("unexpected error for user octocat (cached): %v", err)
}
if id != 583234 {
t.Errorf("expected 583234, got %d", id)
}
// 4. API Error
_, err = r.ResolveUser("error-user")
if err == nil || !strings.Contains(err.Error(), "http error 404") {
t.Errorf("expected http error 404, got %v", err)
}
// 5. No ID field in response
_, err = r.ResolveUser("no-id-user")
if err == nil || !strings.Contains(err.Error(), "returned no id") {
t.Errorf("expected 'returned no id' error, got %v", err)
}
// 6. Bad JSON response
_, err = r.ResolveUser("bad-json-user")
if err == nil || !strings.Contains(err.Error(), "decoding gh api") {
t.Errorf("expected decoding error, got %v", err)
}
}
func TestResolveTeam(t *testing.T) {
oldExec := ExecCommand
ExecCommand = mockExec
defer func() { ExecCommand = oldExec }()
r := New()
// 1. Numeric ID passes through
id, err := r.ResolveTeam("54321")
if err != nil {
t.Fatalf("unexpected error for numeric team ID: %v", err)
}
if id != 54321 {
t.Errorf("expected 54321, got %d", id)
}
// 2. Bare slug rejected
_, err = r.ResolveTeam("release-managers")
if err == nil || !strings.Contains(err.Error(), "must be in the form org/team-slug") {
t.Errorf("expected format error, got %v", err)
}
// 3. Invalid formats
for _, invalid := range []string{"org/", "/team"} {
_, err = r.ResolveTeam(invalid)
if err == nil || !strings.Contains(err.Error(), "must be in the form org/team-slug") {
t.Errorf("expected format error for %q, got %v", invalid, err)
}
}
// 4. Resolve slug
id, err = r.ResolveTeam("JMR-dev/release-managers")
if err != nil {
t.Fatalf("unexpected error for team: %v", err)
}
if id != 98765 {
t.Errorf("expected 98765, got %d", id)
}
// 5. Cached slug
id, err = r.ResolveTeam("JMR-dev/release-managers")
if err != nil {
t.Fatalf("unexpected error for team (cached): %v", err)
}
if id != 98765 {
t.Errorf("expected 98765, got %d", id)
}
// 6. API Error
_, err = r.ResolveTeam("JMR-dev/error-team")
if err == nil || !strings.Contains(err.Error(), "http error 404") {
t.Errorf("expected http error 404, got %v", err)
}
}
+3 -1
View File
@@ -12,9 +12,11 @@ import (
"golang.org/x/term"
)
var IsTerminal = term.IsTerminal
// IsInteractive reports whether stdin is a terminal.
func IsInteractive() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
return IsTerminal(int(os.Stdin.Fd()))
}
// Reader reads prompts from in and writes them to out. Use New() for the
+137
View File
@@ -0,0 +1,137 @@
package prompt
import (
"bytes"
"errors"
"strings"
"testing"
)
func TestIsInteractive(t *testing.T) {
oldIsTerminal := IsTerminal
defer func() { IsTerminal = oldIsTerminal }()
// Test interactive mode
IsTerminal = func(fd int) bool {
return true
}
if !IsInteractive() {
t.Error("expected IsInteractive to be true")
}
// Test non-interactive mode
IsTerminal = func(fd int) bool {
return false
}
if IsInteractive() {
t.Error("expected IsInteractive to be false")
}
}
func TestNew(t *testing.T) {
reader := New()
if reader == nil {
t.Fatal("expected reader to not be nil")
}
}
type errReader struct{}
func (errReader) Read(p []byte) (n int, err error) {
return 0, errors.New("read error")
}
func TestReader_Line(t *testing.T) {
// 1. Success path
in := bytes.NewBufferString("hello\n")
out := &bytes.Buffer{}
p := NewFromReader(in, out)
val, err := p.Line("Enter something: ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != "hello" {
t.Errorf("expected 'hello', got %q", val)
}
if out.String() != "Enter something: " {
t.Errorf("expected prompt output, got %q", out.String())
}
// 2. EOF with input
in = bytes.NewBufferString("hello")
out = &bytes.Buffer{}
p = NewFromReader(in, out)
val, err = p.Line("Enter: ")
if err != nil {
t.Fatalf("unexpected error on EOF with content: %v", err)
}
if val != "hello" {
t.Errorf("expected 'hello', got %q", val)
}
// 3. Reader error
out = &bytes.Buffer{}
p = NewFromReader(errReader{}, out)
_, err = p.Line("Enter: ")
if err == nil || !strings.Contains(err.Error(), "read error") {
t.Errorf("expected read error, got %v", err)
}
}
func TestReader_Choice(t *testing.T) {
// 1. Valid choice first attempt
in := bytes.NewBufferString("public\n")
out := &bytes.Buffer{}
p := NewFromReader(in, out)
val, err := p.Choice("Visibility [public/private]: ", "private", []string{"public", "private"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != "public" {
t.Errorf("expected 'public', got %q", val)
}
// 2. Use default choice on empty line
in = bytes.NewBufferString("\n")
out = &bytes.Buffer{}
p = NewFromReader(in, out)
val, err = p.Choice("Visibility [public/private]: ", "private", []string{"public", "private"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != "private" {
t.Errorf("expected 'private', got %q", val)
}
// 3. Invalid choice followed by valid choice
in = bytes.NewBufferString("invalid\nprivate\n")
out = &bytes.Buffer{}
p = NewFromReader(in, out)
val, err = p.Choice("Visibility: ", "private", []string{"public", "private"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != "private" {
t.Errorf("expected 'private', got %q", val)
}
if !strings.Contains(out.String(), "invalid value \"invalid\"") {
t.Errorf("expected warning in output, got %q", out.String())
}
// 4. Exceed maximum attempts
in = bytes.NewBufferString("invalid1\ninvalid2\ninvalid3\n")
out = &bytes.Buffer{}
p = NewFromReader(in, out)
_, err = p.Choice("Visibility: ", "private", []string{"public", "private"})
if err == nil || !strings.Contains(err.Error(), "no valid answer after 3 attempts") {
t.Errorf("expected error, got %v", err)
}
// 5. Line read error inside Choice
out = &bytes.Buffer{}
p = NewFromReader(errReader{}, out)
_, err = p.Choice("Visibility: ", "private", []string{"public", "private"})
if err == nil || !strings.Contains(err.Error(), "read error") {
t.Errorf("expected read error, got %v", err)
}
}
+151
View File
@@ -0,0 +1,151 @@
package pulumiprog
import (
"strings"
"testing"
"github.com/JMR-dev/gh-repo-bootstrap/internal/cli"
"github.com/JMR-dev/gh-repo-bootstrap/internal/secrets"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
type mockResources struct {
t *testing.T
}
func (m mockResources) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) {
outputs := args.Inputs.Mappable()
return args.Name + "_id", resource.NewPropertyMapFromMap(outputs), nil
}
func (m mockResources) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) {
return args.Args, nil
}
func TestBuild_CreateMode(t *testing.T) {
inputs := Inputs{
Owner: "test-owner",
Repo: "test-repo",
Branch: "main",
Reviews: 2,
Signed: true,
RulesetName: "test-ruleset",
Environments: []ResolvedEnv{
{
Name: "production",
WaitTimer: intPtr(10),
PreventSelfReview: boolPtr(true),
CanAdminsBypass: boolPtr(false),
ReviewerUserIDs: []int{123},
ReviewerTeamIDs: []int{456},
BranchPolicy: "custom",
BranchPatterns: []string{"release/*"},
},
{
Name: "staging",
BranchPolicy: "protected",
},
{
Name: "dev",
BranchPolicy: "none",
},
},
Bypass: []cli.BypassActor{
{ActorID: 99, ActorType: "Team", BypassMode: "pull_request"},
{ActorID: 100, ActorType: "RepositoryRole", BypassMode: "always"},
},
RepoSecrets: []secrets.Pair{
{Name: "REPO_SECRET", Value: "secret-val"},
},
EnvSecrets: []secrets.EnvFile{
{
Env: "production",
Secrets: []secrets.Pair{
{Name: "ENV_SECRET", Value: "env-secret-val"},
},
},
},
RepoMode: cli.RepoModeCreate,
RepoSettings: cli.RepoSettings{
Visibility: "private",
Description: strPtr("hello description"),
DefaultBranch: "main",
Topics: []string{"go", "api"},
AllowMergeCommit: boolPtr(false),
AllowSquashMerge: boolPtr(true),
AllowRebaseMerge: boolPtr(false),
DeleteBranchOnMerge: boolPtr(true),
AutoInit: boolPtr(true),
LicenseTemplate: "mit",
GitignoreTemplate: "Go",
},
}
err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t}))
if err != nil {
t.Fatalf("Pulumi program failed in Create mode: %v", err)
}
}
func TestBuild_ManageMode(t *testing.T) {
inputs := Inputs{
Owner: "test-owner",
Repo: "test-repo",
Branch: "main",
RepoMode: cli.RepoModeManage,
RepoSettings: cli.RepoSettings{},
}
err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t}))
if err != nil {
t.Fatalf("Pulumi program failed in Manage mode: %v", err)
}
}
func TestBuild_DataMode(t *testing.T) {
inputs := Inputs{
Owner: "test-owner",
Repo: "test-repo",
Branch: "main",
RepoMode: cli.RepoModeData,
}
err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t}))
if err != nil {
t.Fatalf("Pulumi program failed in Data mode: %v", err)
}
}
func TestBuild_EnvSecretsTargetMismatch(t *testing.T) {
inputs := Inputs{
Owner: "test-owner",
Repo: "test-repo",
Branch: "main",
EnvSecrets: []secrets.EnvFile{
{
Env: "non-existent-env",
Secrets: []secrets.Pair{
{Name: "ENV_SECRET", Value: "val"},
},
},
},
}
err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t}))
if err == nil || !strings.Contains(err.Error(), "has no matching environment") {
t.Fatalf("expected target mismatch error, got: %v", err)
}
}
func intPtr(i int) *int {
return &i
}
func boolPtr(b bool) *bool {
return &b
}
func strPtr(s string) *string {
return &s
}
+20 -2
View File
@@ -26,6 +26,7 @@ import (
"github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
const (
@@ -33,6 +34,23 @@ const (
stackName = "bootstrap"
)
var execCommand = exec.Command
type stackInterface interface {
SetConfig(ctx context.Context, key string, val auto.ConfigValue) error
Up(ctx context.Context, opts ...optup.Option) (auto.UpResult, error)
Preview(ctx context.Context, opts ...optpreview.Option) (auto.PreviewResult, error)
Destroy(ctx context.Context, opts ...optdestroy.Option) (auto.DestroyResult, error)
}
var upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
s, err := auto.UpsertStackInlineSource(ctx, stackName, projectName, program, opts...)
if err != nil {
return nil, err
}
return &s, nil
}
// Run executes the requested action against the GitHub repo described by opts.
func Run(ctx context.Context, opts *cli.Options) error {
// --- TOML config takes over the Options struct if --config was set ---
@@ -67,7 +85,7 @@ func Run(ctx context.Context, opts *cli.Options) error {
// --- Auth: prefer caller-supplied GITHUB_TOKEN, else borrow from gh ---
if os.Getenv("GITHUB_TOKEN") == "" {
out, err := exec.Command("gh", "auth", "token").Output()
out, err := execCommand("gh", "auth", "token").Output()
if err != nil {
return fmt.Errorf("no GITHUB_TOKEN set and `gh auth token` failed; run `gh auth login` first")
}
@@ -158,7 +176,7 @@ func Run(ctx context.Context, opts *cli.Options) error {
fmt.Println(">>> Run with --plan first to review the import + any drift reconciliation.")
}
stack, err := auto.UpsertStackInlineSource(ctx, stackName, projectName, program,
stack, err := upsertStack(ctx, stackName, projectName, program,
auto.WorkDir(stateDir),
auto.EnvVars(map[string]string{
"PULUMI_BACKEND_URL": backendURL,
+394
View File
@@ -0,0 +1,394 @@
package runner
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/JMR-dev/gh-repo-bootstrap/internal/cli"
"github.com/JMR-dev/gh-repo-bootstrap/internal/githubapi"
"github.com/JMR-dev/gh-repo-bootstrap/internal/prompt"
"github.com/pulumi/pulumi/sdk/v3/go/auto"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optdestroy"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optpreview"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// Helper process for mocking gh CLI in runner tests.
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
defer os.Exit(0)
args := os.Args
for i, arg := range args {
if arg == "--" {
args = args[i+1:]
break
}
}
if len(args) < 2 {
fmt.Fprintf(os.Stderr, "invalid args: %v\n", args)
os.Exit(2)
}
command := args[0]
if command != "gh" {
fmt.Fprintf(os.Stderr, "expected 'gh', got: %s\n", command)
os.Exit(2)
}
subCmd := args[1]
switch subCmd {
case "auth":
if len(args) >= 3 && args[2] == "token" {
fmt.Print("gh_mock_token\n")
} else {
fmt.Fprintf(os.Stderr, "unknown auth subcommand\n")
os.Exit(2)
}
case "api":
if len(args) >= 3 {
path := args[2]
switch {
case path == "users/octocat":
fmt.Print(`{"id":583234}`)
case path == "orgs/JMR-dev/teams/release":
fmt.Print(`{"id":98765}`)
default:
fmt.Fprintf(os.Stderr, "unknown path: %s\n", path)
os.Exit(2)
}
}
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", subCmd)
os.Exit(2)
}
}
func mockExec(command string, args ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", command}
cs = append(cs, args...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
return cmd
}
// mockStack implements stackInterface.
type mockStack struct {
setConfigCalls map[string]string
actionCalled string
failAction bool
}
func (m *mockStack) SetConfig(ctx context.Context, key string, val auto.ConfigValue) error {
m.setConfigCalls[key] = val.Value
return nil
}
func (m *mockStack) Up(ctx context.Context, opts ...optup.Option) (auto.UpResult, error) {
m.actionCalled = "up"
if m.failAction {
return auto.UpResult{}, errors.New("up error")
}
return auto.UpResult{}, nil
}
func (m *mockStack) Preview(ctx context.Context, opts ...optpreview.Option) (auto.PreviewResult, error) {
m.actionCalled = "preview"
if m.failAction {
return auto.PreviewResult{}, errors.New("preview error")
}
return auto.PreviewResult{}, nil
}
func (m *mockStack) Destroy(ctx context.Context, opts ...optdestroy.Option) (auto.DestroyResult, error) {
m.actionCalled = "destroy"
if m.failAction {
return auto.DestroyResult{}, errors.New("destroy error")
}
return auto.DestroyResult{}, nil
}
func TestRun_Apply(t *testing.T) {
oldExec := execCommand
oldGithubExec := githubapi.ExecCommand
oldUpsert := upsertStack
execCommand = mockExec
githubapi.ExecCommand = mockExec
defer func() {
execCommand = oldExec
githubapi.ExecCommand = oldGithubExec
upsertStack = oldUpsert
}()
// Mock upsertStack
mStack := &mockStack{setConfigCalls: make(map[string]string)}
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
return mStack, nil
}
stateDir := t.TempDir()
opts := &cli.Options{
Owner: "JMR-dev",
Repo: "test-repo",
Branch: "main",
Action: cli.ActionApply,
StateDir: stateDir,
Environments: []*cli.EnvSpec{
{
Name: "production",
ReviewerUsers: []string{"octocat"},
ReviewerTeams: []string{"JMR-dev/release"},
},
},
}
// Make sure GITHUB_TOKEN is cleared to exercise fallback.
oldToken := os.Getenv("GITHUB_TOKEN")
os.Unsetenv("GITHUB_TOKEN")
defer os.Setenv("GITHUB_TOKEN", oldToken)
err := Run(context.Background(), opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if mStack.actionCalled != "up" {
t.Errorf("expected stack action 'up', got: %s", mStack.actionCalled)
}
if val, ok := mStack.setConfigCalls["github:owner"]; !ok || val != "JMR-dev" {
t.Errorf("expected github:owner config to be JMR-dev, got %s", val)
}
// Verify GITHUB_TOKEN is set after Run via fallback command.
if os.Getenv("GITHUB_TOKEN") != "gh_mock_token" {
t.Errorf("expected GITHUB_TOKEN to be set to 'gh_mock_token', got: %s", os.Getenv("GITHUB_TOKEN"))
}
}
func TestRun_PlanAndDestroy(t *testing.T) {
oldExec := execCommand
oldUpsert := upsertStack
execCommand = mockExec
defer func() {
execCommand = oldExec
upsertStack = oldUpsert
}()
mStack := &mockStack{setConfigCalls: make(map[string]string)}
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
return mStack, nil
}
stateDir := t.TempDir()
// Test Plan
optsPlan := &cli.Options{
Owner: "JMR-dev",
Repo: "test-repo",
Branch: "main",
Action: cli.ActionPlan,
StateDir: stateDir,
}
err := Run(context.Background(), optsPlan)
if err != nil {
t.Fatalf("unexpected plan error: %v", err)
}
if mStack.actionCalled != "preview" {
t.Errorf("expected preview, got %s", mStack.actionCalled)
}
// Test Destroy
optsDestroy := &cli.Options{
Owner: "JMR-dev",
Repo: "test-repo",
Branch: "main",
Action: cli.ActionDestroy,
StateDir: stateDir,
}
err = Run(context.Background(), optsDestroy)
if err != nil {
t.Fatalf("unexpected destroy error: %v", err)
}
if mStack.actionCalled != "destroy" {
t.Errorf("expected destroy, got %s", mStack.actionCalled)
}
}
func TestRun_SecretsAndPassphrase(t *testing.T) {
oldExec := execCommand
oldUpsert := upsertStack
execCommand = mockExec
defer func() {
execCommand = oldExec
upsertStack = oldUpsert
}()
mStack := &mockStack{setConfigCalls: make(map[string]string)}
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
return mStack, nil
}
stateDir := t.TempDir()
secretsDir := t.TempDir()
// Create repo secrets file.
repoSecretsFile := filepath.Join(secretsDir, "repo.tfvars")
_ = os.WriteFile(repoSecretsFile, []byte("TOKEN = \"1234\"\n"), 0o600)
// Create env secrets dir and file.
envSecretsDir := filepath.Join(secretsDir, "envs")
_ = os.MkdirAll(envSecretsDir, 0o700)
_ = os.WriteFile(filepath.Join(envSecretsDir, "production.tfvars"), []byte("DB_PW = \"prodpwd\"\n"), 0o600)
opts := &cli.Options{
Owner: "JMR-dev",
Repo: "test-repo",
Branch: "main",
Action: cli.ActionPlan,
StateDir: stateDir,
RepoSecretsFile: repoSecretsFile,
EnvSecretsDir: envSecretsDir,
Environments: []*cli.EnvSpec{
{Name: "production"},
},
}
// Clear passphrase env var
oldPassphrase := os.Getenv("PULUMI_CONFIG_PASSPHRASE")
os.Unsetenv("PULUMI_CONFIG_PASSPHRASE")
defer os.Setenv("PULUMI_CONFIG_PASSPHRASE", oldPassphrase)
err := Run(context.Background(), opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify passphrase file is generated.
passphraseFile := filepath.Join(stateDir, ".passphrase")
if _, err := os.Stat(passphraseFile); os.IsNotExist(err) {
t.Error("expected passphrase file to be created")
}
// Run again to verify it reuses the existing passphrase.
err = Run(context.Background(), opts)
if err != nil {
t.Fatalf("unexpected error on second run: %v", err)
}
}
func TestRun_TOMLConfigFile(t *testing.T) {
oldExec := execCommand
oldUpsert := upsertStack
execCommand = mockExec
defer func() {
execCommand = oldExec
upsertStack = oldUpsert
}()
mStack := &mockStack{setConfigCalls: make(map[string]string)}
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
return mStack, nil
}
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.toml")
tomlData := `
owner = "JMR-dev"
name = "config-repo"
mode = "data"
`
_ = os.WriteFile(configFile, []byte(tomlData), 0o600)
opts := &cli.Options{
ConfigFile: configFile,
}
err := Run(context.Background(), opts)
if err != nil {
t.Fatalf("unexpected error with TOML config: %v", err)
}
}
func TestRun_CreateModeValidation(t *testing.T) {
stateDir := t.TempDir()
// --create requires --visibility (or config file)
opts := &cli.Options{
Owner: "JMR-dev",
Repo: "test-repo",
RepoMode: cli.RepoModeCreate,
StateDir: stateDir,
}
err := Run(context.Background(), opts)
if err == nil || !strings.Contains(err.Error(), "input is not a terminal") {
t.Fatalf("expected visibility validation error (non-interactive), got: %v", err)
}
}
func TestRun_CreateModeInteractive(t *testing.T) {
oldExec := execCommand
oldGithubExec := githubapi.ExecCommand
oldUpsert := upsertStack
oldIsTerminal := prompt.IsTerminal
execCommand = mockExec
githubapi.ExecCommand = mockExec
prompt.IsTerminal = func(fd int) bool { return true }
defer func() {
execCommand = oldExec
githubapi.ExecCommand = oldGithubExec
upsertStack = oldUpsert
prompt.IsTerminal = oldIsTerminal
}()
mStack := &mockStack{setConfigCalls: make(map[string]string)}
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
return mStack, nil
}
stateDir := t.TempDir()
opts := &cli.Options{
Owner: "JMR-dev",
Repo: "test-repo",
RepoMode: cli.RepoModeCreate,
StateDir: stateDir,
Action: cli.ActionApply,
}
// Create pipe to feed stdin
r, w, _ := os.Pipe()
oldStdin := os.Stdin
os.Stdin = r
defer func() {
os.Stdin = oldStdin
r.Close()
w.Close()
}()
// Feed mock input: "public" for visibility, and "my repo description" for description.
_, _ = w.Write([]byte("public\nmy repo description\n"))
err := Run(context.Background(), opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts.RepoSettings.Visibility != "public" {
t.Errorf("expected Visibility to be public, got: %s", opts.RepoSettings.Visibility)
}
if opts.RepoSettings.Description == nil || *opts.RepoSettings.Description != "my repo description" {
t.Errorf("expected Description to be 'my repo description', got: %v", opts.RepoSettings.Description)
}
}