go rewrite for cross platform

This commit is contained in:
Jason Ross
2026-04-29 22:20:11 -05:00
parent 74b4eca8b4
commit 4e0659cda6
8 changed files with 1753 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
name: release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26.2'
- name: Build & publish precompiled gh extension binaries
uses: cli/gh-extension-precompile@v2
+5
View File
@@ -17,3 +17,8 @@ terraform.tfvars
.DS_Store
.idea/
.vscode/
# Go build output (the gh extension binary)
/gh-repo-bootstrap
/gh-repo-bootstrap.exe
/dist/
+12
View File
@@ -15,10 +15,22 @@ for applying a standard set of guard-rails to a GitHub repository:
gh extension install JMR-dev/gh-repo-bootstrap
```
`gh` will fetch a precompiled binary for your OS/arch from the latest
release (Linux, macOS, Windows; amd64 + arm64).
You also need:
- [`tofu`](https://opentofu.org/docs/intro/install/) on PATH
- `gh` already authenticated (`gh auth login`)
### Build from source
```sh
git clone https://github.com/JMR-dev/gh-repo-bootstrap
cd gh-repo-bootstrap
go build -o gh-repo-bootstrap .
gh extension install .
```
## Use (gh extension)
```sh
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
module github.com/JMR-dev/gh-repo-bootstrap
go 1.21
+805
View File
@@ -0,0 +1,805 @@
// gh-repo-bootstrap: apply standard branch protection + environments to a
// GitHub repository, using the bundled OpenTofu `repo` module.
//
// Installed as a gh extension, invoked as: gh repo-bootstrap <owner/repo> [opts]
package main
import (
"bufio"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
)
//go:embed modules/repo/main.tf modules/repo/variables.tf modules/repo/outputs.tf modules/repo/versions.tf
var moduleFS embed.FS
const moduleSubpath = "modules/repo"
const usageText = `Usage:
gh repo-bootstrap <owner/repo> [options]
Apply a standard branch-protection ruleset and a set of deployment
environments to an existing GitHub repository, via OpenTofu.
Options:
--branch NAME Default branch to protect (default: main)
--reviews N Required PR approving reviews (default: 1)
--signed Require signed commits on the protected branch
--env NAME Add a deployment environment (repeatable)
Default if none given: production
--ruleset NAME Ruleset name (default: default-branch-protection)
--bypass SPEC Add a bypass actor (repeatable). SPEC is
<actor_type>:<actor_id>[:<mode>]
actor_type: RepositoryRole | Team | Integration |
OrganizationAdmin | DeployKey
actor_id: numeric ID (built-in repo roles:
1=read 2=triage 3=write 4=maintain 5=admin)
mode: always | pull_request (default: always)
Shortcut: --solo is equivalent to
--bypass RepositoryRole:5:always
--solo Allow the Admin repo role to bypass the ruleset.
--upload-repo-secrets FILE
Upload repository-level GitHub Actions secrets
sourced from a tfvars-style file. Each non-blank,
non-comment line must be: SECRET_NAME = "value"
Names must match GitHub's rules (alphanumerics +
underscore, no leading digit, no GITHUB_ prefix).
Comments (#, //) and blank lines are allowed.
--upload-env-secrets DIR
Upload environment-level GitHub Actions secrets.
DIR must contain one <env>.tfvars per env, where
<env> matches one of the --env values.
--plan Run ` + "`tofu plan`" + ` instead of ` + "`tofu apply`" + `
--destroy Run ` + "`tofu destroy`" + `
--state-dir DIR Override working/state directory
(default: $XDG_STATE_HOME/gh-repo-bootstrap or
~/.local/state/gh-repo-bootstrap on Unix,
%LOCALAPPDATA%\gh-repo-bootstrap on Windows)
-h, --help Show this help
Authentication:
GITHUB_TOKEN is auto-populated from ` + "`gh auth token`" + ` if not already set.
Secrets & state:
Uploaded secret values are sent to GitHub encrypted, but they are
ALSO stored in plaintext in the OpenTofu state file under the
per-repo state directory. Protect that directory accordingly.
`
func usage() { fmt.Fprint(os.Stderr, usageText) }
func errf(format string, a ...any) {
fmt.Fprintf(os.Stderr, "gh-repo-bootstrap: "+format+"\n", a...)
}
type bypassActor struct {
ActorType string
ActorID int
BypassMode string
}
type options struct {
repo string
owner string
name string
branch string
reviews int
signed bool
ruleset string
envs []string
bypass []bypassActor
action string // apply | plan | destroy
stateDir string
repoSecretsFile string
envSecretsDir string
}
var (
validActorTypes = map[string]bool{"RepositoryRole": true, "Team": true, "Integration": true, "OrganizationAdmin": true, "DeployKey": true}
validBypassModes = map[string]bool{"always": true, "pull_request": true}
secretNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
envNameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
tfvarsLineRe = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)[\t ]*=[\t ]*(.*)$`)
)
var errHelp = errors.New("help requested")
func parseArgs(argv []string) (*options, error) {
opts := &options{
branch: "main",
reviews: 1,
ruleset: "default-branch-protection",
action: "apply",
}
needValue := func(i int, flag string) (string, error) {
if i+1 >= len(argv) {
return "", fmt.Errorf("flag %s requires a value", flag)
}
v := argv[i+1]
if v == "" {
return "", fmt.Errorf("flag %s requires a non-empty value", flag)
}
return v, nil
}
for i := 0; i < len(argv); i++ {
a := argv[i]
switch a {
case "-h", "--help":
return nil, errHelp
case "--branch":
v, err := needValue(i, a)
if err != nil {
return nil, err
}
opts.branch = v
i++
case "--reviews":
v, err := needValue(i, a)
if err != nil {
return nil, err
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return nil, fmt.Errorf("--reviews must be a non-negative integer (got: %s)", v)
}
opts.reviews = n
i++
case "--signed":
opts.signed = true
case "--ruleset":
v, err := needValue(i, a)
if err != nil {
return nil, err
}
opts.ruleset = v
i++
case "--env":
v, err := needValue(i, a)
if err != nil {
return nil, err
}
opts.envs = append(opts.envs, v)
i++
case "--bypass":
v, err := needValue(i, a)
if err != nil {
return nil, err
}
b, err := parseBypassSpec(v)
if err != nil {
return nil, err
}
opts.bypass = append(opts.bypass, b)
i++
case "--solo":
opts.bypass = append(opts.bypass, bypassActor{ActorType: "RepositoryRole", ActorID: 5, BypassMode: "always"})
case "--upload-repo-secrets":
v, err := needValue(i, a)
if err != nil {
return nil, err
}
opts.repoSecretsFile = v
i++
case "--upload-env-secrets":
v, err := needValue(i, a)
if err != nil {
return nil, err
}
opts.envSecretsDir = v
i++
case "--plan":
opts.action = "plan"
case "--destroy":
opts.action = "destroy"
case "--state-dir":
v, err := needValue(i, a)
if err != nil {
return nil, err
}
opts.stateDir = v
i++
case "--":
// remaining args are positional; we only accept one (repo)
for _, rest := range argv[i+1:] {
if opts.repo != "" {
return nil, fmt.Errorf("unexpected positional argument: %s", rest)
}
opts.repo = rest
}
i = len(argv)
default:
if strings.HasPrefix(a, "-") {
return nil, fmt.Errorf("unknown option: %s", a)
}
if opts.repo != "" {
return nil, fmt.Errorf("unexpected positional argument: %s", a)
}
opts.repo = a
}
}
if opts.repo == "" || !strings.Contains(opts.repo, "/") {
return nil, errors.New("first argument must be <owner>/<repo>")
}
parts := strings.SplitN(opts.repo, "/", 2)
opts.owner, opts.name = parts[0], parts[1]
if opts.owner == "" || opts.name == "" {
return nil, fmt.Errorf("invalid <owner>/<repo>: %s", opts.repo)
}
if len(opts.envs) == 0 {
opts.envs = []string{"production"}
}
return opts, nil
}
func parseBypassSpec(spec string) (bypassActor, error) {
parts := strings.Split(spec, ":")
if len(parts) < 2 || len(parts) > 3 {
return bypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (expected <actor_type>:<actor_id>[:<mode>])", spec)
}
bType := parts[0]
bIDStr := parts[1]
bMode := "always"
if len(parts) == 3 && parts[2] != "" {
bMode = parts[2]
}
if bType == "" || bIDStr == "" {
return bypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (expected <actor_type>:<actor_id>[:<mode>])", spec)
}
bID, err := strconv.Atoi(bIDStr)
if err != nil || bID < 0 {
return bypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (actor_id must be numeric)", spec)
}
if !validActorTypes[bType] {
return bypassActor{}, fmt.Errorf("invalid --bypass actor_type %q", bType)
}
if !validBypassModes[bMode] {
return bypassActor{}, fmt.Errorf("invalid --bypass mode %q (must be 'always' or 'pull_request')", bMode)
}
return bypassActor{ActorType: bType, ActorID: bID, BypassMode: bMode}, nil
}
// hclString returns an HCL/JSON-compatible quoted string literal for s.
// HCL accepts JSON-style escaped strings for double-quoted literals, so this
// is safe for any user-supplied input.
func hclString(s string) string {
b, err := json.Marshal(s)
if err != nil {
// json.Marshal of a string never fails; fall back defensively.
return strconv.Quote(s)
}
return string(b)
}
func hclStringList(items []string) string {
if len(items) == 0 {
return "[]"
}
parts := make([]string, len(items))
for i, it := range items {
parts[i] = hclString(it)
}
return "[" + strings.Join(parts, ", ") + "]"
}
func hclBypassList(items []bypassActor) string {
if len(items) == 0 {
return "[]"
}
parts := make([]string, len(items))
for i, b := range items {
parts[i] = fmt.Sprintf(
"{ actor_id = %d, actor_type = %s, bypass_mode = %s }",
b.ActorID, hclString(b.ActorType), hclString(b.BypassMode),
)
}
return "[" + strings.Join(parts, ", ") + "]"
}
// defaultStateDir mirrors $XDG_STATE_HOME on Unix and %LOCALAPPDATA% on Windows.
func defaultStateDir() (string, error) {
if runtime.GOOS == "windows" {
base := os.Getenv("LOCALAPPDATA")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, "AppData", "Local")
}
return filepath.Join(base, "gh-repo-bootstrap"), nil
}
if v := os.Getenv("XDG_STATE_HOME"); v != "" {
return filepath.Join(v, "gh-repo-bootstrap"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "state", "gh-repo-bootstrap"), nil
}
// extractModule writes the embedded module .tf files into destDir, replacing
// any prior contents (so stale files from a previous version don't linger).
func extractModule(destDir string) error {
if err := os.RemoveAll(destDir); err != nil {
return fmt.Errorf("clean module dir: %w", err)
}
if err := os.MkdirAll(destDir, 0o700); err != nil {
return fmt.Errorf("create module dir: %w", err)
}
entries, err := fs.ReadDir(moduleFS, moduleSubpath)
if err != nil {
return fmt.Errorf("read embedded module: %w", err)
}
if len(entries) == 0 {
return errors.New("no embedded module files (build error?)")
}
for _, e := range entries {
if e.IsDir() {
continue
}
data, err := fs.ReadFile(moduleFS, moduleSubpath+"/"+e.Name())
if err != nil {
return fmt.Errorf("read embedded %s: %w", e.Name(), err)
}
if err := os.WriteFile(filepath.Join(destDir, e.Name()), data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", e.Name(), err)
}
}
return nil
}
func writeMainTF(stateDir, modulePath string, opts *options) error {
var sb strings.Builder
sb.WriteString("# Generated by gh repo-bootstrap. Edits will be overwritten.\n")
sb.WriteString(`terraform {
required_version = ">= 1.8.0"
required_providers {
github = {
source = "integrations/github"
version = "~> 6.2"
}
}
}
`)
fmt.Fprintf(&sb, "provider \"github\" {\n owner = %s\n}\n\n", hclString(opts.owner))
fmt.Fprintf(&sb, "module \"repo\" {\n source = %s\n\n", hclString(filepath.ToSlash(modulePath)))
fmt.Fprintf(&sb, " repo_owner = %s\n", hclString(opts.owner))
fmt.Fprintf(&sb, " repo_name = %s\n", hclString(opts.name))
fmt.Fprintf(&sb, " default_branch = %s\n", hclString(opts.branch))
fmt.Fprintf(&sb, " required_reviews = %d\n", opts.reviews)
fmt.Fprintf(&sb, " require_signed_commits = %t\n", opts.signed)
fmt.Fprintf(&sb, " ruleset_name = %s\n", hclString(opts.ruleset))
fmt.Fprintf(&sb, " environments = %s\n", hclStringList(opts.envs))
fmt.Fprintf(&sb, " bypass_actors = %s\n", hclBypassList(opts.bypass))
sb.WriteString("}\n\n")
sb.WriteString(`output "repository_full_name" { value = module.repo.repository_full_name }
output "ruleset_id" { value = module.repo.ruleset_id }
output "environments" { value = module.repo.environments }
`)
return os.WriteFile(filepath.Join(stateDir, "main.tf"), []byte(sb.String()), 0o600)
}
// ----------------------------------------------------------------------
// Secrets handling
// ----------------------------------------------------------------------
func isValidGHSecretName(n string) bool {
if !secretNameRe.MatchString(n) {
return false
}
if strings.HasPrefix(n, "GITHUB_") {
return false
}
return true
}
type secretEntry struct {
Name string
Value string
}
// parseSecretsFile reads a tfvars-style file of `KEY = "value"` lines.
// Comments (# and //) and blank lines are ignored. Values may be double- or
// single-quoted; escape sequences \\ \" \n \r \t are recognized inside
// double-quoted values only.
func parseSecretsFile(path string) ([]secretEntry, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("secrets file not found: %s", path)
}
return nil, fmt.Errorf("secrets file not readable: %s: %w", path, err)
}
defer f.Close()
var out []secretEntry
seen := map[string]bool{}
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineno := 0
for sc.Scan() {
lineno++
line := strings.TrimRight(sc.Text(), "\r")
line = strings.TrimLeft(line, " \t")
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
m := tfvarsLineRe.FindStringSubmatch(line)
if m == nil {
return nil, fmt.Errorf("%s:%d: cannot parse line (expected NAME = \"value\"): %s", path, lineno, line)
}
name := m[1]
rest := strings.TrimRight(m[2], " \t")
var val string
switch {
case len(rest) >= 2 && rest[0] == '"' && rest[len(rest)-1] == '"':
inner := rest[1 : len(rest)-1]
decoded, err := decodeDoubleQuoted(inner)
if err != nil {
return nil, fmt.Errorf("%s:%d: %v", path, lineno, err)
}
val = decoded
case len(rest) >= 2 && rest[0] == '\'' && rest[len(rest)-1] == '\'':
val = rest[1 : len(rest)-1]
default:
return nil, fmt.Errorf("%s:%d: value must be a single quoted string", path, lineno)
}
if !isValidGHSecretName(name) {
return nil, fmt.Errorf("%s:%d: invalid GitHub secret name %q (alphanumerics + underscore, no leading digit, no GITHUB_ prefix)", path, lineno, name)
}
if seen[name] {
return nil, fmt.Errorf("duplicate secret %q in %s", name, path)
}
seen[name] = true
out = append(out, secretEntry{Name: name, Value: val})
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(out) == 0 {
return nil, fmt.Errorf("%s: no secrets found", path)
}
return out, nil
}
// decodeDoubleQuoted decodes the limited set of escapes recognized by the
// original bash parser: \\ \" \n \r \t. Other backslash sequences are left
// as-is (matching bash behavior, which only substituted those five forms).
func decodeDoubleQuoted(s string) (string, error) {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c != '\\' {
b.WriteByte(c)
continue
}
if i+1 >= len(s) {
b.WriteByte('\\')
continue
}
nxt := s[i+1]
switch nxt {
case '\\':
b.WriteByte('\\')
case '"':
b.WriteByte('"')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
default:
b.WriteByte('\\')
b.WriteByte(nxt)
}
i++
}
return b.String(), nil
}
// makeSecretsTmpDir creates a chmod-700 temp dir, preferring /dev/shm on Linux
// so plaintext values never touch persistent disk.
func makeSecretsTmpDir() (string, error) {
if runtime.GOOS == "linux" {
if st, err := os.Stat("/dev/shm"); err == nil && st.IsDir() {
if dir, err := os.MkdirTemp("/dev/shm", "gh-repo-bootstrap.*"); err == nil {
_ = os.Chmod(dir, 0o700)
return dir, nil
}
}
}
dir, err := os.MkdirTemp("", "gh-repo-bootstrap.*")
if err != nil {
return "", err
}
_ = os.Chmod(dir, 0o700)
return dir, nil
}
// generateSecrets writes secrets.tf into stateDir and secrets.auto.tfvars into
// secretsTmpDir. Returns the path to the var-file if any secrets were emitted,
// or "" otherwise.
func generateSecrets(stateDir, secretsTmpDir string, opts *options) (string, error) {
secretsTF := filepath.Join(stateDir, "secrets.tf")
secretsTFVars := filepath.Join(secretsTmpDir, "secrets.auto.tfvars")
// Always remove any prior secrets.tf so stale resources don't persist.
_ = os.Remove(secretsTF)
var tfBuf strings.Builder
var varsBuf strings.Builder
any := false
if opts.repoSecretsFile != "" {
entries, err := parseSecretsFile(opts.repoSecretsFile)
if err != nil {
return "", err
}
tfBuf.WriteString("# Generated by gh repo-bootstrap. Repo-level Actions secrets.\n")
for i, s := range entries {
fmt.Fprintf(&tfBuf, "\nvariable \"rs_%d\" {\n type = string\n sensitive = true\n}\n", i)
fmt.Fprintf(&tfBuf, "resource \"github_actions_secret\" \"rs_%d\" {\n", i)
tfBuf.WriteString(" repository = module.repo.repository_name\n")
fmt.Fprintf(&tfBuf, " secret_name = %s\n", hclString(s.Name))
fmt.Fprintf(&tfBuf, " plaintext_value = var.rs_%d\n}\n", i)
fmt.Fprintf(&varsBuf, "rs_%d = %s\n", i, hclString(s.Value))
}
any = true
}
if opts.envSecretsDir != "" {
st, err := os.Stat(opts.envSecretsDir)
if err != nil || !st.IsDir() {
return "", fmt.Errorf("env-secrets dir not found: %s", opts.envSecretsDir)
}
envSet := map[string]bool{}
for _, e := range opts.envs {
envSet[e] = true
}
matches, err := filepath.Glob(filepath.Join(opts.envSecretsDir, "*.tfvars"))
if err != nil {
return "", fmt.Errorf("scan env-secrets dir: %w", err)
}
if len(matches) == 0 {
return "", fmt.Errorf("no *.tfvars files in %s", opts.envSecretsDir)
}
// filepath.Glob returns lexically sorted results; preserve that order
// so resource indices stay stable across runs.
tfBuf.WriteString("\n# Generated by gh repo-bootstrap. Env-level Actions secrets.\n")
for envIdx, ef := range matches {
base := strings.TrimSuffix(filepath.Base(ef), ".tfvars")
if !envNameRe.MatchString(base) {
return "", fmt.Errorf("invalid env name derived from filename: %s (basename must match [A-Za-z][A-Za-z0-9_-]*)", ef)
}
if !envSet[base] {
return "", fmt.Errorf("env-secrets file %q targets env %q which is not in --env list (%s)", ef, base, strings.Join(opts.envs, " "))
}
entries, err := parseSecretsFile(ef)
if err != nil {
return "", err
}
fmt.Fprintf(&tfBuf, "\n# env: %s (source: %s)\n", base, ef)
for i, s := range entries {
fmt.Fprintf(&tfBuf, "variable \"es_%d_%d\" {\n type = string\n sensitive = true\n}\n", envIdx, i)
fmt.Fprintf(&tfBuf, "resource \"github_actions_environment_secret\" \"es_%d_%d\" {\n", envIdx, i)
tfBuf.WriteString(" repository = module.repo.repository_name\n")
fmt.Fprintf(&tfBuf, " environment = module.repo.environments_by_name[%s].environment\n", hclString(base))
fmt.Fprintf(&tfBuf, " secret_name = %s\n", hclString(s.Name))
fmt.Fprintf(&tfBuf, " plaintext_value = var.es_%d_%d\n}\n", envIdx, i)
fmt.Fprintf(&varsBuf, "es_%d_%d = %s\n", envIdx, i, hclString(s.Value))
}
}
any = true
}
if !any {
return "", nil
}
if err := os.WriteFile(secretsTF, []byte(tfBuf.String()), 0o600); err != nil {
return "", fmt.Errorf("write secrets.tf: %w", err)
}
if err := os.WriteFile(secretsTFVars, []byte(varsBuf.String()), 0o600); err != nil {
return "", fmt.Errorf("write secrets.auto.tfvars: %w", err)
}
return secretsTFVars, nil
}
// ----------------------------------------------------------------------
// Auth + tofu invocation
// ----------------------------------------------------------------------
func ensureGitHubToken() (string, error) {
if v := os.Getenv("GITHUB_TOKEN"); v != "" {
return v, nil
}
out, err := exec.Command("gh", "auth", "token").Output()
if err != nil {
return "", errors.New("no GITHUB_TOKEN set and `gh auth token` failed; run `gh auth login` first")
}
tok := strings.TrimSpace(string(out))
if tok == "" {
return "", errors.New("`gh auth token` returned an empty token; run `gh auth login` first")
}
return tok, nil
}
// runTofu executes tofu in workDir with stdio attached. It forwards SIGINT to
// the child and waits for the child to exit before returning, so callers can
// safely defer cleanup of any temporary files used as -var-file inputs.
func runTofu(ctx context.Context, workDir, token string, args ...string) error {
cmd := exec.Command("tofu", args...)
cmd.Dir = workDir
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Inherit env, ensure GITHUB_TOKEN is set.
env := os.Environ()
env = append(env, "GITHUB_TOKEN="+token)
cmd.Env = env
if err := cmd.Start(); err != nil {
return fmt.Errorf("start tofu: %w", err)
}
// Forward signals to the child; do not exit the parent until child exits.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
defer signal.Stop(sigCh)
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
for {
select {
case sig := <-sigCh:
// Best-effort: forward to child. tofu handles SIGINT cleanly.
_ = cmd.Process.Signal(sig)
case <-ctx.Done():
_ = cmd.Process.Signal(os.Interrupt)
case err := <-done:
return err
}
}
}
func main() {
if err := run(); err != nil {
if errors.Is(err, errHelp) {
usage()
return
}
errf("%v", err)
os.Exit(1)
}
}
func run() error {
opts, err := parseArgs(os.Args[1:])
if err != nil {
if !errors.Is(err, errHelp) {
usage()
}
return err
}
// Tooling check.
if _, err := exec.LookPath("tofu"); err != nil {
return errors.New("OpenTofu (`tofu`) is required but not on PATH. Install: https://opentofu.org/docs/intro/install/")
}
if _, err := exec.LookPath("gh"); err != nil {
return errors.New("the GitHub CLI (`gh`) is required but not on PATH")
}
if opts.stateDir == "" {
base, err := defaultStateDir()
if err != nil {
return fmt.Errorf("compute default state dir: %w", err)
}
opts.stateDir = filepath.Join(base, opts.owner+"__"+opts.name)
}
if err := os.MkdirAll(opts.stateDir, 0o700); err != nil {
return fmt.Errorf("create state dir %s: %w", opts.stateDir, err)
}
// Best-effort tightening on already-existing dirs (no-op on Windows).
_ = os.Chmod(opts.stateDir, 0o700)
// Extract bundled module fresh each run so updates propagate and stale
// files from prior versions are removed.
moduleDir := filepath.Join(opts.stateDir, ".module")
if err := extractModule(moduleDir); err != nil {
return err
}
if err := writeMainTF(opts.stateDir, moduleDir, opts); err != nil {
return fmt.Errorf("write main.tf: %w", err)
}
// Secrets: temp dir first (so we can defer cleanup before any failure
// path that might have written values).
secretsTmpDir, err := makeSecretsTmpDir()
if err != nil {
return fmt.Errorf("create secrets temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(secretsTmpDir) }()
varFile, err := generateSecrets(opts.stateDir, secretsTmpDir, opts)
if err != nil {
return err
}
token, err := ensureGitHubToken()
if err != nil {
return err
}
fmt.Printf(">>> Working directory: %s\n", opts.stateDir)
ctx, cancel := signalContext()
defer cancel()
if err := runTofu(ctx, opts.stateDir, token, "init", "-input=false", "-upgrade"); err != nil {
return fmt.Errorf("tofu init: %w", err)
}
var tofuArgs []string
switch opts.action {
case "apply":
tofuArgs = []string{"apply", "-input=false", "-auto-approve"}
case "plan":
tofuArgs = []string{"plan", "-input=false"}
case "destroy":
tofuArgs = []string{"destroy", "-input=false", "-auto-approve"}
default:
return fmt.Errorf("internal error: unknown action %q", opts.action)
}
if varFile != "" {
tofuArgs = append(tofuArgs, "-var-file="+varFile)
}
if err := runTofu(ctx, opts.stateDir, token, tofuArgs...); err != nil {
return fmt.Errorf("tofu %s: %w", opts.action, err)
}
return nil
}
// signalContext returns a context cancelled on the first os.Interrupt. The
// child-process forwarding in runTofu handles the actual signal propagation;
// this context is mainly here for future use and clean cancellation of any
// non-tofu work.
func signalContext() (context.Context, func()) {
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
go func() {
select {
case <-ch:
cancel()
case <-ctx.Done():
}
signal.Stop(ch)
}()
return ctx, cancel
}
+304
View File
@@ -0,0 +1,304 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestHCLString(t *testing.T) {
cases := map[string]string{
`hello`: `"hello"`,
`with "quote"`: `"with \"quote\""`,
"with\nnewline": `"with\nnewline"`,
`back\slash`: `"back\\slash"`,
`tab here`: `"tab\there"`,
`${interp}`: `"${interp}"`,
`C:\Users\me\dir`: `"C:\\Users\\me\\dir"`,
}
for in, want := range cases {
if got := hclString(in); got != want {
t.Errorf("hclString(%q) = %s, want %s", in, got, want)
}
}
}
func TestHCLStringList(t *testing.T) {
if got := hclStringList(nil); got != "[]" {
t.Errorf("nil list: %s", got)
}
got := hclStringList([]string{"a", "weird\"name", "ok"})
want := `["a", "weird\"name", "ok"]`
if got != want {
t.Errorf("got %s want %s", got, want)
}
}
func TestHCLBypassList(t *testing.T) {
got := hclBypassList([]bypassActor{
{ActorType: "RepositoryRole", ActorID: 5, BypassMode: "always"},
{ActorType: "Team", ActorID: 42, BypassMode: "pull_request"},
})
want := `[{ actor_id = 5, actor_type = "RepositoryRole", bypass_mode = "always" }, { actor_id = 42, actor_type = "Team", bypass_mode = "pull_request" }]`
if got != want {
t.Errorf("got %s want %s", got, want)
}
}
func TestParseBypassSpec(t *testing.T) {
good := []struct {
in string
want bypassActor
}{
{"RepositoryRole:5", bypassActor{"RepositoryRole", 5, "always"}},
{"Team:42:pull_request", bypassActor{"Team", 42, "pull_request"}},
{"Integration:1:always", bypassActor{"Integration", 1, "always"}},
}
for _, c := range good {
got, err := parseBypassSpec(c.in)
if err != nil || got != c.want {
t.Errorf("parseBypassSpec(%q) = %+v, %v; want %+v", c.in, got, err, c.want)
}
}
bad := []string{"", "Team", "Team:abc", "Bogus:1", "Team:1:weird", "Team:-1"}
for _, in := range bad {
if _, err := parseBypassSpec(in); err == nil {
t.Errorf("parseBypassSpec(%q) expected error", in)
}
}
}
func TestIsValidGHSecretName(t *testing.T) {
good := []string{"FOO", "foo_bar", "_X", "A1"}
bad := []string{"", "1FOO", "GITHUB_TOKEN", "with space", "dash-name", "GITHUB_X"}
for _, n := range good {
if !isValidGHSecretName(n) {
t.Errorf("expected %q valid", n)
}
}
for _, n := range bad {
if isValidGHSecretName(n) {
t.Errorf("expected %q invalid", n)
}
}
}
func TestDecodeDoubleQuoted(t *testing.T) {
cases := map[string]string{
``: ``,
`hello`: `hello`,
`a\nb`: "a\nb",
`a\rb\tc`: "a\rb\tc",
`back\\slash`: `back\slash`,
`q\"x`: `q"x`,
`unknown\zescape`: `unknown\zescape`,
`trailing\`: `trailing\`,
}
for in, want := range cases {
got, err := decodeDoubleQuoted(in)
if err != nil || got != want {
t.Errorf("decode(%q) = %q, %v; want %q", in, got, err, want)
}
}
}
func TestParseSecretsFile(t *testing.T) {
dir := t.TempDir()
good := filepath.Join(dir, "good.tfvars")
os.WriteFile(good, []byte(`# header comment
// other style
API_TOKEN = "abc123"
WEBHOOK = "with \"quotes\" and \n newline"
SINGLE = 'no escapes here \n'
`), 0o600)
entries, err := parseSecretsFile(good)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(entries) != 3 {
t.Fatalf("got %d entries: %+v", len(entries), entries)
}
if entries[0] != (secretEntry{"API_TOKEN", "abc123"}) {
t.Errorf("entry0 = %+v", entries[0])
}
if entries[1].Value != "with \"quotes\" and \n newline" {
t.Errorf("entry1 value = %q", entries[1].Value)
}
if entries[2].Value != `no escapes here \n` {
t.Errorf("entry2 value = %q (single quotes don't decode escapes)", entries[2].Value)
}
// Duplicate
dup := filepath.Join(dir, "dup.tfvars")
os.WriteFile(dup, []byte("X=\"a\"\nX=\"b\"\n"), 0o600)
if _, err := parseSecretsFile(dup); err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Errorf("dup err: %v", err)
}
// Bad name
badname := filepath.Join(dir, "bad.tfvars")
os.WriteFile(badname, []byte("GITHUB_TOKEN = \"x\"\n"), 0o600)
if _, err := parseSecretsFile(badname); err == nil {
t.Errorf("expected reserved-name error")
}
// Empty
empty := filepath.Join(dir, "empty.tfvars")
os.WriteFile(empty, []byte("# only comments\n\n"), 0o600)
if _, err := parseSecretsFile(empty); err == nil {
t.Errorf("expected empty-file error")
}
// Unparseable line
junk := filepath.Join(dir, "junk.tfvars")
os.WriteFile(junk, []byte("not a valid line\n"), 0o600)
if _, err := parseSecretsFile(junk); err == nil {
t.Errorf("expected parse error")
}
// Missing file
if _, err := parseSecretsFile(filepath.Join(dir, "nope.tfvars")); err == nil {
t.Errorf("expected not-found error")
}
}
func TestParseArgsBasics(t *testing.T) {
opts, err := parseArgs([]string{"owner/repo"})
if err != nil {
t.Fatal(err)
}
if opts.owner != "owner" || opts.name != "repo" || opts.action != "apply" || len(opts.envs) != 1 || opts.envs[0] != "production" {
t.Errorf("defaults wrong: %+v", opts)
}
opts, err = parseArgs([]string{
"o/r", "--branch", "trunk", "--reviews", "3", "--signed",
"--env", "production", "--env", "staging", "--solo", "--plan",
})
if err != nil {
t.Fatal(err)
}
if opts.branch != "trunk" || opts.reviews != 3 || !opts.signed || opts.action != "plan" {
t.Errorf("opts wrong: %+v", opts)
}
if len(opts.envs) != 2 || opts.envs[1] != "staging" {
t.Errorf("envs wrong: %v", opts.envs)
}
if len(opts.bypass) != 1 || opts.bypass[0].ActorType != "RepositoryRole" || opts.bypass[0].ActorID != 5 {
t.Errorf("solo bypass wrong: %+v", opts.bypass)
}
bad := [][]string{
{},
{"no-slash"},
{"o/r", "--reviews", "abc"},
{"o/r", "--branch"}, // missing value
{"o/r", "--unknown"},
{"o/r", "extra"},
{"o/r", "--bypass", "Bogus:1"},
}
for _, args := range bad {
if _, err := parseArgs(args); err == nil {
t.Errorf("expected error for %v", args)
}
}
}
func TestExtractModuleAndWriteMainTF(t *testing.T) {
dir := t.TempDir()
mod := filepath.Join(dir, ".module")
if err := extractModule(mod); err != nil {
t.Fatalf("extract: %v", err)
}
for _, want := range []string{"main.tf", "variables.tf", "outputs.tf", "versions.tf"} {
if _, err := os.Stat(filepath.Join(mod, want)); err != nil {
t.Errorf("missing embedded file %s: %v", want, err)
}
}
// Stale-file removal: drop a junk file then re-extract.
junk := filepath.Join(mod, "stale.tf")
os.WriteFile(junk, []byte("bogus"), 0o600)
if err := extractModule(mod); err != nil {
t.Fatalf("re-extract: %v", err)
}
if _, err := os.Stat(junk); !os.IsNotExist(err) {
t.Errorf("stale file not removed: %v", err)
}
opts := &options{
owner: "o", name: "r", branch: `weird"branch`, reviews: 2,
signed: true, ruleset: "rs", envs: []string{"production", "staging"},
bypass: []bypassActor{{"RepositoryRole", 5, "always"}},
}
if err := writeMainTF(dir, mod, opts); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(dir, "main.tf"))
if err != nil {
t.Fatal(err)
}
s := string(got)
if !strings.Contains(s, `default_branch = "weird\"branch"`) {
t.Errorf("branch not escaped:\n%s", s)
}
if !strings.Contains(s, `environments = ["production", "staging"]`) {
t.Errorf("envs missing:\n%s", s)
}
if !strings.Contains(s, `require_signed_commits = true`) {
t.Errorf("signed missing")
}
if !strings.Contains(s, `actor_id = 5`) {
t.Errorf("bypass missing")
}
}
func TestGenerateSecrets(t *testing.T) {
tmp := t.TempDir()
state := filepath.Join(tmp, "state")
os.MkdirAll(state, 0o700)
tmpd := filepath.Join(tmp, "tmp")
os.MkdirAll(tmpd, 0o700)
repoSec := filepath.Join(tmp, "repo.tfvars")
os.WriteFile(repoSec, []byte("API = \"abc\"\nTOKEN=\"xyz\"\n"), 0o600)
envDir := filepath.Join(tmp, "envs")
os.MkdirAll(envDir, 0o700)
os.WriteFile(filepath.Join(envDir, "production.tfvars"), []byte("DB = \"prod\"\n"), 0o600)
os.WriteFile(filepath.Join(envDir, "staging.tfvars"), []byte("DB = \"stage\"\n"), 0o600)
opts := &options{
repoSecretsFile: repoSec,
envSecretsDir: envDir,
envs: []string{"production", "staging"},
}
vf, err := generateSecrets(state, tmpd, opts)
if err != nil {
t.Fatal(err)
}
if vf == "" {
t.Fatal("expected var-file path")
}
tfb, _ := os.ReadFile(filepath.Join(state, "secrets.tf"))
tf := string(tfb)
if !strings.Contains(tf, `secret_name = "API"`) || !strings.Contains(tf, `secret_name = "TOKEN"`) {
t.Errorf("repo secrets missing: %s", tf)
}
if !strings.Contains(tf, `module.repo.environments_by_name["production"]`) || !strings.Contains(tf, `module.repo.environments_by_name["staging"]`) {
t.Errorf("env secrets missing: %s", tf)
}
vfb, _ := os.ReadFile(vf)
v := string(vfb)
if !strings.Contains(v, `rs_0 = "abc"`) || !strings.Contains(v, `rs_1 = "xyz"`) {
t.Errorf("repo tfvars missing: %s", v)
}
// Env not in --env list -> error
os.WriteFile(filepath.Join(envDir, "rogue.tfvars"), []byte("X = \"y\"\n"), 0o600)
if _, err := generateSecrets(state, tmpd, opts); err == nil {
t.Errorf("expected rogue-env error")
}
}
+604
View File
@@ -0,0 +1,604 @@
package main
import (
"bytes"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
// captureStderr redirects os.Stderr for the duration of fn and returns what
// was written. Used to verify the side-effecting print helpers without
// asserting against the real terminal.
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
orig := os.Stderr
os.Stderr = w
defer func() { os.Stderr = orig }()
done := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
done <- buf.String()
}()
fn()
w.Close()
return <-done
}
func TestUsageAndErrf(t *testing.T) {
out := captureStderr(t, func() { usage() })
if !strings.Contains(out, "gh repo-bootstrap <owner/repo>") {
t.Errorf("usage output missing header:\n%s", out)
}
out = captureStderr(t, func() { errf("hello %s %d", "world", 42) })
want := "gh-repo-bootstrap: hello world 42\n"
if out != want {
t.Errorf("errf = %q want %q", out, want)
}
}
func TestParseArgsHelp(t *testing.T) {
for _, flag := range []string{"-h", "--help"} {
_, err := parseArgs([]string{flag})
if !errors.Is(err, errHelp) {
t.Errorf("expected errHelp for %s, got %v", flag, err)
}
}
}
func TestParseArgsAllFlags(t *testing.T) {
args := []string{
"o/r",
"--branch", "trunk",
"--reviews", "0",
"--signed",
"--ruleset", "rs",
"--env", "production",
"--env", "staging",
"--bypass", "Team:7:pull_request",
"--upload-repo-secrets", "/tmp/repo.tfvars",
"--upload-env-secrets", "/tmp/envs",
"--state-dir", "/tmp/state",
"--destroy",
}
opts, err := parseArgs(args)
if err != nil {
t.Fatal(err)
}
want := &options{
repo: "o/r", owner: "o", name: "r",
branch: "trunk", reviews: 0, signed: true, ruleset: "rs",
envs: []string{"production", "staging"},
bypass: []bypassActor{{"Team", 7, "pull_request"}},
repoSecretsFile: "/tmp/repo.tfvars",
envSecretsDir: "/tmp/envs",
stateDir: "/tmp/state",
action: "destroy",
}
if !reflect.DeepEqual(opts, want) {
t.Errorf("got %+v\nwant %+v", opts, want)
}
}
func TestParseArgsDoubleDash(t *testing.T) {
// Positional repo after `--`.
opts, err := parseArgs([]string{"--", "o/r"})
if err != nil || opts.repo != "o/r" {
t.Errorf("got %+v err %v", opts, err)
}
// Two positionals after `--` is an error.
if _, err := parseArgs([]string{"--", "o/r", "extra"}); err == nil {
t.Errorf("expected error for two positionals after --")
}
}
func TestParseArgsMissingValue(t *testing.T) {
for _, args := range [][]string{
{"o/r", "--branch"},
{"o/r", "--reviews"},
{"o/r", "--ruleset"},
{"o/r", "--env"},
{"o/r", "--bypass"},
{"o/r", "--upload-repo-secrets"},
{"o/r", "--upload-env-secrets"},
{"o/r", "--state-dir"},
} {
if _, err := parseArgs(args); err == nil {
t.Errorf("expected missing-value error for %v", args)
}
}
}
func TestParseArgsEmptyOwnerName(t *testing.T) {
if _, err := parseArgs([]string{"/repo"}); err == nil {
t.Errorf("expected error for empty owner")
}
if _, err := parseArgs([]string{"owner/"}); err == nil {
t.Errorf("expected error for empty repo")
}
}
func TestDefaultStateDir(t *testing.T) {
t.Setenv("HOME", "/home/test")
if runtime.GOOS == "windows" {
t.Setenv("LOCALAPPDATA", `C:\Users\test\AppData\Local`)
got, err := defaultStateDir()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(`C:\Users\test\AppData\Local`, "gh-repo-bootstrap")
if got != want {
t.Errorf("got %s want %s", got, want)
}
t.Setenv("LOCALAPPDATA", "")
got, err = defaultStateDir()
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(got, filepath.Join("AppData", "Local", "gh-repo-bootstrap")) {
t.Errorf("fallback path wrong: %s", got)
}
return
}
t.Setenv("XDG_STATE_HOME", "/xdg/state")
got, err := defaultStateDir()
if err != nil {
t.Fatal(err)
}
if got != "/xdg/state/gh-repo-bootstrap" {
t.Errorf("XDG path wrong: %s", got)
}
t.Setenv("XDG_STATE_HOME", "")
got, err = defaultStateDir()
if err != nil {
t.Fatal(err)
}
if got != "/home/test/.local/state/gh-repo-bootstrap" {
t.Errorf("home fallback wrong: %s", got)
}
}
func TestExtractModuleErrors(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission semantics differ on Windows")
}
// Target a path under a regular file: MkdirAll will fail.
tmp := t.TempDir()
blocker := filepath.Join(tmp, "block")
if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
target := filepath.Join(blocker, "child")
if err := extractModule(target); err == nil {
t.Errorf("expected mkdir error under regular file")
}
}
func TestMakeSecretsTmpDir(t *testing.T) {
dir, err := makeSecretsTmpDir()
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
st, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if !st.IsDir() {
t.Errorf("expected directory: %s", dir)
}
// The dir must be writable.
probe := filepath.Join(dir, "probe")
if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil {
t.Errorf("tmpdir not writable: %v", err)
}
}
func TestEnsureGitHubTokenFromEnv(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "from-env")
tok, err := ensureGitHubToken()
if err != nil || tok != "from-env" {
t.Errorf("env token: %q %v", tok, err)
}
}
func TestEnsureGitHubTokenFromFakeGh(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("posix shell required for fake gh")
}
bin := t.TempDir()
// Print token *with trailing whitespace* to verify TrimSpace.
gh := "#!/bin/sh\nprintf 'tok-from-gh \\n'\n"
if err := os.WriteFile(filepath.Join(bin, "gh"), []byte(gh), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
t.Setenv("GITHUB_TOKEN", "")
tok, err := ensureGitHubToken()
if err != nil || tok != "tok-from-gh" {
t.Errorf("fake gh token: %q %v", tok, err)
}
}
func TestEnsureGitHubTokenEmptyFromGh(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("posix shell required for fake gh")
}
bin := t.TempDir()
gh := "#!/bin/sh\necho ''\n"
os.WriteFile(filepath.Join(bin, "gh"), []byte(gh), 0o755)
t.Setenv("PATH", bin)
t.Setenv("GITHUB_TOKEN", "")
if _, err := ensureGitHubToken(); err == nil || !strings.Contains(err.Error(), "empty token") {
t.Errorf("expected empty-token error, got %v", err)
}
}
func TestEnsureGitHubTokenGhMissing(t *testing.T) {
bin := t.TempDir() // no gh inside
t.Setenv("PATH", bin)
t.Setenv("GITHUB_TOKEN", "")
if _, err := ensureGitHubToken(); err == nil {
t.Errorf("expected error when gh is absent")
}
}
// fakeBin builds a tempdir containing fake gh and tofu shell scripts and
// returns the directory. The fake tofu logs each invocation's args to
// $tmpdir/tofu.log and exits with status from $tmpdir/tofu.exit (default 0).
func fakeBin(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("posix shell required for fake binaries")
}
bin := t.TempDir()
gh := "#!/bin/sh\necho fake-token-from-gh\n"
tofu := `#!/bin/sh
echo "$@" >> "$BIN/tofu.log"
exit "$(cat "$BIN/tofu.exit" 2>/dev/null || echo 0)"
`
if err := os.WriteFile(filepath.Join(bin, "gh"), []byte(gh), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bin, "tofu"), []byte(tofu), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("BIN", bin)
// Tests that use this helper need /bin and /usr/bin on PATH so the fake
// tofu shell script can resolve `cat`. Tests that specifically want
// missing tools should set their own PATH.
t.Setenv("PATH", bin+":/usr/bin:/bin")
return bin
}
func TestRunTofuSuccessAndFailure(t *testing.T) {
bin := fakeBin(t)
wd := t.TempDir()
if err := runTofu(t.Context(), wd, "tok", "init", "-input=false"); err != nil {
t.Errorf("expected success: %v", err)
}
got, _ := os.ReadFile(filepath.Join(bin, "tofu.log"))
if !strings.Contains(string(got), "init -input=false") {
t.Errorf("tofu not invoked correctly: %s", got)
}
// Force a non-zero exit and verify error propagation.
os.WriteFile(filepath.Join(bin, "tofu.exit"), []byte("3\n"), 0o600)
if err := runTofu(t.Context(), wd, "tok", "plan"); err == nil {
t.Errorf("expected error from non-zero exit")
}
}
func TestRunTofuStartFailure(t *testing.T) {
// Empty PATH so exec.LookPath/Start fails inside runTofu.
t.Setenv("PATH", t.TempDir())
if err := runTofu(t.Context(), t.TempDir(), "tok"); err == nil {
t.Errorf("expected start error with no tofu on PATH")
}
}
func TestSignalContext(t *testing.T) {
ctx, cancel := signalContext()
defer cancel()
select {
case <-ctx.Done():
t.Errorf("ctx should not be done immediately")
default:
}
cancel()
<-ctx.Done() // should return promptly
}
func TestRunEndToEndPlan(t *testing.T) {
bin := fakeBin(t)
state := t.TempDir()
// Create a repo-secrets file so generateSecrets actually runs and the
// resulting -var-file gets passed to fake tofu.
repoSecrets := filepath.Join(t.TempDir(), "repo.tfvars")
os.WriteFile(repoSecrets, []byte("API = \"abc\"\n"), 0o600)
t.Setenv("GITHUB_TOKEN", "") // force gh fallback
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"gh-repo-bootstrap",
"owner/repo",
"--plan",
"--state-dir", state,
"--upload-repo-secrets", repoSecrets,
}
if err := run(); err != nil {
t.Fatalf("run() failed: %v", err)
}
// Expect main.tf and secrets.tf in state dir.
for _, want := range []string{"main.tf", "secrets.tf", filepath.Join(".module", "main.tf")} {
if _, err := os.Stat(filepath.Join(state, want)); err != nil {
t.Errorf("missing %s: %v", want, err)
}
}
// Verify tofu was called for init then plan, with -var-file on plan.
log, _ := os.ReadFile(filepath.Join(bin, "tofu.log"))
ls := string(log)
if !strings.Contains(ls, "init -input=false -upgrade") {
t.Errorf("init not logged: %s", ls)
}
if !strings.Contains(ls, "plan -input=false -var-file=") {
t.Errorf("plan with var-file not logged: %s", ls)
}
}
func TestRunEndToEndApplyAndDestroy(t *testing.T) {
bin := fakeBin(t)
t.Setenv("GITHUB_TOKEN", "preset")
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
for _, action := range []string{"--destroy"} { // apply is the default
state := t.TempDir()
os.Args = []string{"gh-repo-bootstrap", "o/r", action, "--state-dir", state}
if err := run(); err != nil {
t.Fatalf("%s: run failed: %v", action, err)
}
}
// Default apply path.
state := t.TempDir()
os.Args = []string{"gh-repo-bootstrap", "o/r", "--state-dir", state}
if err := run(); err != nil {
t.Fatalf("apply: run failed: %v", err)
}
log, _ := os.ReadFile(filepath.Join(bin, "tofu.log"))
ls := string(log)
for _, want := range []string{
"destroy -input=false -auto-approve",
"apply -input=false -auto-approve",
} {
if !strings.Contains(ls, want) {
t.Errorf("missing %q in log:\n%s", want, ls)
}
}
}
func TestRunBadArgs(t *testing.T) {
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"gh-repo-bootstrap", "no-slash"}
// Capture stderr so test output stays clean.
captureStderr(t, func() {
if err := run(); err == nil {
t.Errorf("expected error for bad args")
}
})
}
func TestRunHelp(t *testing.T) {
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"gh-repo-bootstrap", "--help"}
err := run()
if !errors.Is(err, errHelp) {
t.Errorf("expected errHelp, got %v", err)
}
}
func TestRunMissingTools(t *testing.T) {
// PATH with neither gh nor tofu.
t.Setenv("PATH", t.TempDir())
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"gh-repo-bootstrap", "o/r"}
captureStderr(t, func() {
if err := run(); err == nil {
t.Errorf("expected error when tofu/gh missing")
}
})
}
func TestRunDefaultStateDir(t *testing.T) {
// Without --state-dir, run() resolves XDG_STATE_HOME and creates the dir.
bin := fakeBin(t)
xdg := t.TempDir()
t.Setenv("XDG_STATE_HOME", xdg)
t.Setenv("GITHUB_TOKEN", "preset")
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"gh-repo-bootstrap", "o/r", "--plan"}
if err := run(); err != nil {
t.Fatalf("run: %v", err)
}
want := filepath.Join(xdg, "gh-repo-bootstrap", "o__r", "main.tf")
if _, err := os.Stat(want); err != nil {
t.Errorf("expected default state dir to be used: %v", err)
}
_ = bin
}
func TestRunStateDirCreateFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission semantics differ on Windows")
}
_ = fakeBin(t) // PATH gets gh+tofu
t.Setenv("GITHUB_TOKEN", "preset")
tmp := t.TempDir()
blocker := filepath.Join(tmp, "block")
os.WriteFile(blocker, []byte("x"), 0o600)
bad := filepath.Join(blocker, "child")
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"gh-repo-bootstrap", "o/r", "--plan", "--state-dir", bad}
if err := run(); err == nil {
t.Errorf("expected mkdir error")
}
}
func TestGenerateSecretsErrorPaths(t *testing.T) {
state := t.TempDir()
tmpd := t.TempDir()
// Env-secrets dir does not exist.
opts := &options{
envSecretsDir: filepath.Join(state, "nope"),
envs: []string{"production"},
}
if _, err := generateSecrets(state, tmpd, opts); err == nil {
t.Errorf("expected missing-dir error")
}
// Env-secrets dir is empty (no .tfvars files).
emptyDir := filepath.Join(state, "empty")
os.MkdirAll(emptyDir, 0o700)
opts.envSecretsDir = emptyDir
if _, err := generateSecrets(state, tmpd, opts); err == nil ||
!strings.Contains(err.Error(), "no *.tfvars") {
t.Errorf("expected empty-dir error, got %v", err)
}
// Env-secrets file has an invalid env-name in the basename.
weirdDir := filepath.Join(state, "weird")
os.MkdirAll(weirdDir, 0o700)
os.WriteFile(filepath.Join(weirdDir, "1bad.tfvars"), []byte("X = \"y\"\n"), 0o600)
opts.envSecretsDir = weirdDir
if _, err := generateSecrets(state, tmpd, opts); err == nil ||
!strings.Contains(err.Error(), "invalid env name") {
t.Errorf("expected invalid-env-name error, got %v", err)
}
// Env-secrets parse error propagates.
parseDir := filepath.Join(state, "parse")
os.MkdirAll(parseDir, 0o700)
os.WriteFile(filepath.Join(parseDir, "production.tfvars"),
[]byte("not a valid line\n"), 0o600)
opts.envSecretsDir = parseDir
if _, err := generateSecrets(state, tmpd, opts); err == nil {
t.Errorf("expected parse-error propagation")
}
// Repo-secrets file does not exist.
opts2 := &options{repoSecretsFile: filepath.Join(state, "nope.tfvars")}
if _, err := generateSecrets(state, tmpd, opts2); err == nil {
t.Errorf("expected missing repo-secrets error")
}
}
func TestParseSecretsFileUnreadable(t *testing.T) {
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
t.Skip("chmod-based unreadable check not portable / root bypasses perms")
}
dir := t.TempDir()
p := filepath.Join(dir, "locked.tfvars")
os.WriteFile(p, []byte("X = \"y\"\n"), 0o600)
if err := os.Chmod(p, 0o000); err != nil {
t.Skip("cannot chmod 000")
}
defer os.Chmod(p, 0o600)
if _, err := parseSecretsFile(p); err == nil {
t.Errorf("expected unreadable error")
}
}
func TestParseBypassSpecTooManyParts(t *testing.T) {
if _, err := parseBypassSpec("Team:1:always:extra"); err == nil {
t.Errorf("expected error for 4-part spec")
}
if _, err := parseBypassSpec(":1:always"); err == nil {
t.Errorf("expected error for empty actor_type")
}
}
// TestMainEntryPoint exercises main() in a subprocess so we can observe its
// exit code / stderr handling without disturbing the test runner.
func TestMainEntryPoint(t *testing.T) {
if os.Getenv("GO_TEST_RUN_MAIN") == "1" {
// Strip the testing flags; user-supplied args follow "--".
for i, a := range os.Args {
if a == "--" {
os.Args = append([]string{os.Args[0]}, os.Args[i+1:]...)
break
}
}
main()
return
}
if runtime.GOOS == "windows" {
t.Skip("subprocess args plumbing differs on Windows; main is a 7-line wrapper")
}
cases := []struct {
name string
args []string
wantExit int
wantErr string
}{
{"help", []string{"--help"}, 0, "gh repo-bootstrap"},
{"badArgs", []string{"no-slash"}, 1, "first argument must be"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=TestMainEntryPoint", "--")
cmd.Args = append(cmd.Args, c.args...)
cmd.Env = append(os.Environ(), "GO_TEST_RUN_MAIN=1")
var stderr bytes.Buffer
cmd.Stderr = &stderr
cmd.Stdout = &stderr
err := cmd.Run()
rc := 0
if ee, ok := err.(*exec.ExitError); ok {
rc = ee.ExitCode()
} else if err != nil {
t.Fatalf("subprocess: %v", err)
}
if rc != c.wantExit {
t.Errorf("exit=%d want %d (stderr=%s)", rc, c.wantExit, stderr.String())
}
if !strings.Contains(stderr.String(), c.wantErr) {
t.Errorf("stderr missing %q", c.wantErr)
}
})
}
}