Author SHA1 Message Date
Jason Ross 0e230791b4 Merge pull request #8 from JMR-dev/fix-git-remote-link-bug
ensures repo is pushed to origin
2026-05-26 17:20:04 -05:00
JMR-dev 61eacd00a4 ensures repo is pushed to origin 2026-05-26 17:19:04 -05:00
Jason Ross 62016d9189 Merge pull request #7 from JMR-dev/fix-bug-on-repo-state
fix state path bug
2026-05-26 17:04:04 -05:00
JMR-dev 47f0290652 fix state path bug 2026-05-26 17:02:47 -05:00
Jason Ross 1e41904491 Merge pull request #6 from JMR-dev/fix-documentation-check-update
updated docs and the release workflow
2026-05-26 14:29:11 -05:00
JMR-dev b983e601e8 updated docs and the release workflow 2026-05-26 14:27:40 -05:00
Jason Ross 04847ebebc Merge pull request #5 from JMR-dev/fix-add-tag-creation-to-release
added release version tag param
2026-05-26 10:03:32 -05:00
JMR-dev b60295a7f1 added release version tag param 2026-05-26 09:57:53 -05:00
Jason Ross 53e4146886 Merge pull request #4 from JMR-dev/fix-add-workflow-dispatch-to-release
add workflow dispatch trigger
2026-05-26 09:28:24 -05:00
JMR-dev 5d5cc2a0b0 add workflow dispatch trigger 2026-05-26 09:27:48 -05:00
Jason Ross 43ab4fcf34 Merge pull request #3 from JMR-dev/feat-release-workflow-and-tests
feat-release-workflow-and-tests
2026-05-26 09:17:19 -05:00
JMR-dev de186b6264 update Github actions to Node 26 2026-05-26 09:15:58 -05:00
JMR-dev 35746cc781 added 2026-05-26 09:09:47 -05:00
Jason Ross 759bbac686 Merge pull request #2 from JMR-dev/feat-switch-to-pulimi
switch to pulimi plus full feature management
2026-05-26 08:38:01 -05:00
JMR-dev 5187788f87 switch to pulimi plus full feature management 2026-05-25 20:08:24 -05:00
32 changed files with 4210 additions and 2055 deletions
+133 -8
View File
@@ -1,20 +1,145 @@
name: release
name: Release
on:
push:
tags:
- 'v*'
- "v*"
workflow_dispatch:
inputs:
tag_name:
description: 'Tag name for the release (e.g., v1.0.0)'
required: true
type: string
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE26: 'true'
RELEASE_TAG: ${{ github.event.inputs.tag_name || github.ref_name }}
jobs:
release:
build-linux-amd64:
name: Build Linux amd64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.26.2'
- name: Build & publish precompiled gh extension binaries
uses: cli/gh-extension-precompile@v2
go-version: '1.26.3'
- name: Build
env:
CGO_ENABLED: '0'
GOOS: linux
GOARCH: amd64
run: go build -v -o gh-repo-bootstrap-linux-amd64 main.go
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: gh-repo-bootstrap-linux-amd64
path: gh-repo-bootstrap-linux-amd64
build-linux-arm64:
name: Build Linux arm64
runs-on: ubuntu-24.04-arm
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
env:
CGO_ENABLED: '0'
GOOS: linux
GOARCH: arm64
run: go build -v -o gh-repo-bootstrap-linux-arm64 main.go
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: gh-repo-bootstrap-linux-arm64
path: gh-repo-bootstrap-linux-arm64
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-linux-amd64
- build-linux-arm64
- 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 "${{ env.RELEASE_TAG }}" \
--title "${{ env.RELEASE_TAG }}" \
--notes-file release_notes.txt \
release-assets/*
+30
View File
@@ -0,0 +1,30 @@
name: Test Suite
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE26: 'true'
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 -24
View File
@@ -1,24 +1 @@
# OpenTofu / Terraform
*.tfstate
*.tfstate.*
*.tfstate.backup
.terraform/
.terraform.lock.hcl.bak
crash.log
crash.*.log
# User-specific configs (may contain secrets)
backend.hcl
*.auto.tfvars
terraform.tfvars
!terraform.tfvars.example
# Editors / OS
.DS_Store
.idea/
.vscode/
# Go build output (the gh extension binary)
/gh-repo-bootstrap
/gh-repo-bootstrap.exe
/dist/
coverage.out .gitignore
+182 -61
View File
@@ -1,37 +1,39 @@
# gh-repo-bootstrap
A reusable [OpenTofu](https://opentofu.org/) module **and** a
[`gh` CLI extension](https://docs.github.com/en/github-cli/github-cli/using-github-cli-extensions)
for applying a standard set of guard-rails to a GitHub repository:
A [`gh` CLI extension](https://docs.github.com/en/github-cli/github-cli/using-github-cli-extensions)
for applying a standard set of guard-rails to a GitHub repository, powered by
[Pulumi](https://www.pulumi.com/):
- A branch protection ruleset on the default branch
(no force-push, no deletion, required PRs with N approvals,
resolved review threads, optional signed commits)
- A configurable set of deployment environments
- Optional **repository creation** (`--create`) or **adoption of an
existing repo** (`--manage-repo`) so the tool also manages
repo-level settings: visibility, description, default branch,
topics, merge buttons, delete-branch-on-merge, etc.
- A configurable set of deployment environments with optional
**protection rules** (required reviewers, wait timer,
prevent-self-review, admin bypass, deployment branch policies
including custom branch/tag patterns)
- Optional repository- and environment-level GitHub Actions secrets,
sourced from `KEY = "value"` files
- A single TOML file (`--config FILE`) can describe everything above
## Install (gh extension)
## Install
```sh
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).
`gh` will fetch the precompiled binary for your OS/arch from the latest
release. You also need:
You also need:
- [`tofu`](https://opentofu.org/docs/intro/install/) on PATH
- `gh` already authenticated (`gh auth login`)
- [`pulumi`](https://www.pulumi.com/docs/iac/download-install/) on `PATH`
- `gh` already authenticated (`gh auth login`), or a `GITHUB_TOKEN`
exported in the environment — the extension uses `GITHUB_TOKEN`
when it is set and otherwise falls back to `gh auth token`
### 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)
## Use
```sh
# Apply defaults (1 review, production env) to a repo:
@@ -46,17 +48,128 @@ gh repo-bootstrap JMR-dev/api \
# so you can merge your own PRs without a second approver:
gh repo-bootstrap JMR-dev/solo-project --solo
# Plan only:
# Preview without applying:
gh repo-bootstrap JMR-dev/my-app --plan
# Tear down what this tool manages:
gh repo-bootstrap JMR-dev/my-app --destroy
```
### Creating a new repo
`--create` registers the repo as a Pulumi resource. It prompts for
visibility and description if those flags are not supplied; everything
else uses defaults or flag/config values:
```sh
gh repo-bootstrap JMR-dev/my-new-app --create \
--visibility private \
--description "Service for X" \
--topic go --topic service \
--no-allow-merge-commit --allow-squash-merge \
--delete-branch-on-merge \
--auto-init
```
### Managing an existing repo's settings
`--manage-repo` imports the existing GitHub repository into Pulumi
state on the first apply and manages it from then on. **Always run
`--plan` first** — the first apply imports the repo *and* reconciles
any drift between your flags/config and the live settings in a single
operation:
```sh
gh repo-bootstrap JMR-dev/api --manage-repo \
--visibility private \
--description "API service" \
--default-repo-branch main \
--no-allow-merge-commit --allow-squash-merge \
--plan
```
### Environment protection rules
```sh
gh repo-bootstrap JMR-dev/api \
--env production \
--env-reviewer production:user:octocat \
--env-reviewer production:team:JMR-dev/release-managers \
--env-wait-timer production:5 \
--env-prevent-self-review production \
--env-no-admin-bypass production \
--env-branch-policy production:custom \
--env-branch-pattern production:'release/*' \
--env-branch-pattern production:'hotfix/*'
```
Reviewer specs accept numeric IDs *or* string identifiers
(`user:octocat`, `team:JMR-dev/release-managers`). Strings are
resolved to numeric IDs via `gh api` before Pulumi runs. Team specs
must include the org (`org/team-slug`).
### TOML configuration
A single `--config FILE` can describe everything. When `--config`
is used, **no other flags are allowed**:
```toml
owner = "JMR-dev"
name = "my-new-app"
mode = "create" # or "manage", or "data" (default)
action = "apply" # or "plan", or "destroy" (default: apply)
state_dir = "./state" # optional; overrides the default per-repo state dir
[repo]
visibility = "private"
description = "Service for X"
default_branch = "main"
topics = ["go", "service"]
allow_merge_commit = false
allow_squash_merge = true
allow_rebase_merge = false
delete_branch_on_merge = true
auto_init = true
[ruleset]
name = "default-branch-protection"
branch = "main"
required_reviews = 1
require_signed_commits = false
[[ruleset.bypass]]
actor_type = "RepositoryRole"
actor_id = 5
mode = "always"
[[environments]]
name = "production"
wait_timer = 5
prevent_self_review = true
can_admins_bypass = false
reviewers_users = ["octocat", 12345]
reviewers_teams = ["JMR-dev/release-managers"]
branch_policy = "custom"
branch_patterns = ["release/*", "hotfix/*"]
[[environments]]
name = "staging"
[secrets]
repo_file = "./repo.secrets.tfvars"
env_dir = "./env-secrets"
```
When `mode = "create"` all `[repo]` keys listed above are required —
the loader errors with a single line naming the missing field. When
`mode = "manage"`, the same keys are required except `auto_init` /
`license_template` / `gitignore_template`, which apply only at
creation time.
### Uploading GitHub Actions secrets
The extension can also upload Actions secrets — both repository-level
and per-environment — sourced from tfvars-style files:
and per-environment — sourced from `KEY = "value"` files:
```sh
# Repo-level:
@@ -79,56 +192,64 @@ gh repo-bootstrap JMR-dev/my-app \
--upload-env-secrets ./env-secrets
```
Each line in a tfvars file must be `NAME = "value"`. Names follow
Each line in a secrets file must be `NAME = "value"`. Names follow
GitHub's rules (alphanumerics + underscore, no leading digit, no
`GITHUB_` prefix). `#` and `//` comments are supported.
`GITHUB_` prefix). `#` and `//` comments are supported. Values may be
double-quoted (with `\\ \" \n \r \t` escapes) or single-quoted (raw).
Each parsed secret is materialized as its own
`variable "..." { sensitive = true }` plus matching
`github_actions_secret` / `github_actions_environment_secret`
resource in the generated wrapper, so values stay sensitive
throughout the plan and never appear in plan output. The
intermediate tfvars file the script feeds to OpenTofu is written
to a `chmod 700` directory under `/dev/shm` (when available) and
deleted on exit via a trap.
Secret values are wrapped in Pulumi secret outputs, so they are
encrypted at rest in the state file and elided from `--plan` output.
> **State warning.** GitHub stores secrets encrypted, but the
> OpenTofu state file written to `--state-dir` contains the
> plaintext values. Protect that directory and consider a remote
> backend with state encryption for anything beyond local use.
### State and secret encryption
State is kept per-repo under `$XDG_STATE_HOME/gh-repo-bootstrap/<owner>__<repo>/`
State is kept per-repo under
`$XDG_STATE_HOME/gh-repo-bootstrap/<owner>__<repo>/`
(default `~/.local/state/gh-repo-bootstrap/...`). Override with `--state-dir`.
## Use (OpenTofu module directly)
Each per-repo directory contains:
```hcl
provider "github" {
owner = "JMR-dev"
}
- A Pulumi project (`Pulumi.yaml`, `Pulumi.bootstrap.yaml`)
- The local-backend stack state (encrypted JSON)
- `.passphrase` — a `chmod 600` file holding an auto-generated
passphrase used to encrypt secrets in the state file
module "repo" {
source = "git::https://github.com/JMR-dev/gh-repo-bootstrap.git//modules/repo?ref=main"
> **Back up the whole state directory, not just the state JSON.** If
> `.passphrase` is lost, the stack's encrypted secrets cannot be
> decrypted and the stack will be unusable. You can also override the
> passphrase by exporting `PULUMI_CONFIG_PASSPHRASE` before running the
> command.
repo_owner = "JMR-dev"
repo_name = "my-new-project"
required_reviews = 1
environments = ["production", "staging"]
}
## Migrating from the OpenTofu-based versions
Previous versions of this extension used OpenTofu. There is no
automatic migration: if you previously ran `gh repo-bootstrap` against
a repo, the GitHub ruleset / environments / secrets already exist on
GitHub and Pulumi will try to **create** them again on first run,
which can fail or conflict.
To migrate a repo:
1. Either tear down the previously-managed resources (e.g. delete the
ruleset and environments via the GitHub UI or
`gh api -X DELETE ...`) and let Pulumi re-create them, or
2. Use `pulumi import` against the local stack to adopt the existing
resources without recreating them.
The old OpenTofu state directory
(`$XDG_STATE_HOME/gh-repo-bootstrap/<owner>__<repo>/terraform.tfstate`)
is safe to delete once the Pulumi stack is in place.
## Hacking
```sh
go build ./...
go test ./...
```
See [`modules/repo/README.md`](modules/repo/README.md) for full input docs
and [`examples/basic/`](examples/basic/) for a runnable example.
## What it does **not** do
- Create the repository (point it at an existing one).
- Manage repo-level settings (merge buttons, default branch, topics, etc.).
Use `gh api` for those, or import `github_repository` into your own root
config if you want them under OpenTofu control.
- Manage environment protection rules (required reviewers, wait timer,
deployment branch policies). The environments are created empty; add
protection separately if needed.
The CLI is a single Go binary that uses the Pulumi
[Automation API](https://www.pulumi.com/docs/iac/automation-api/) to
run an inline program against the
[`pulumi-github`](https://www.pulumi.com/registry/packages/github/) provider.
## License
-33
View File
@@ -1,33 +0,0 @@
terraform {
required_version = ">= 1.8.0"
required_providers {
github = {
source = "integrations/github"
version = "~> 6.2"
}
}
}
provider "github" {
owner = var.owner
}
variable "owner" {
type = string
default = "JMR-dev"
}
variable "repo" {
type = string
}
module "repo" {
source = "../../modules/repo"
repo_owner = var.owner
repo_name = var.repo
default_branch = "main"
required_reviews = 1
require_signed_commits = false
environments = ["production", "staging"]
}
BIN
View File
Binary file not shown.
+123 -1
View File
@@ -1,3 +1,125 @@
module github.com/JMR-dev/gh-repo-bootstrap
go 1.21
go 1.26.3
require (
github.com/BurntSushi/toml v1.6.0
github.com/pulumi/pulumi-github/sdk/v6 v6.14.0
github.com/pulumi/pulumi/sdk/v3 v3.243.0
golang.org/x/term v0.42.0
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/cheggaaa/pb v1.0.29 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/djherbis/times v1.5.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-git/go-git/v5 v5.19.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.5 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/hcl/v2 v2.22.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/iwdgo/sigintwindows v0.2.2 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nxadm/tail v1.4.11 // indirect
github.com/opentracing/basictracer-go v1.1.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pgavlin/fx v0.1.6 // indirect
github.com/pgavlin/fx/v2 v2.0.12 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/term v1.1.0 // indirect
github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 // indirect
github.com/pulumi/esc v0.24.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/texttheater/golang-levenshtein v1.0.1 // indirect
github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
github.com/uber/jaeger-lib v2.4.1+incompatible // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zclconf/go-cty v1.13.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/collector/featuregate v1.53.0 // indirect
go.opentelemetry.io/collector/pdata v1.53.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/frand v1.4.2 // indirect
)
+379
View File
@@ -0,0 +1,379 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM=
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bazelbuild/buildtools v0.0.0-20260211083412-859bfffeef82 h1:PmoVmwzAnGb0iCjulb7Mgsaqw2Wj36LQJ8VyYaFe/ak=
github.com/bazelbuild/buildtools v0.0.0-20260211083412-859bfffeef82/go.mod h1:PLNUetjLa77TCCziPsz0EI8a6CUxgC+1jgmWv0H25tg=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo=
github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU=
github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/git-pkgs/manifests v0.4.1 h1:CWml+TrRXVzrfNJ2pTNKLqyi+9y/BFiQP/BX3pL4pPQ=
github.com/git-pkgs/manifests v0.4.1/go.mod h1:7SPFwU9diUG1Az682/p4ZupHJkfpbWKwRvNPwCcOeVs=
github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6Jt5ak7M=
github.com/git-pkgs/packageurl-go v0.3.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4=
github.com/git-pkgs/purl v0.1.10 h1:NMjeF10nzFn3tdQlz6rbmHB+i+YkyrFQxho3e33ePTQ=
github.com/git-pkgs/purl v0.1.10/go.mod h1:C5Vp/kyZ/wGckCLexx4wPVfUxEiToRkdsOPh5Z7ig/I=
github.com/git-pkgs/vers v0.2.4 h1:Zr3jR/Xf1i/6cvBaJKPxhCwjzqz7uvYHE0Fhid/GPBk=
github.com/git-pkgs/vers v0.2.4/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU=
github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M=
github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/iwdgo/sigintwindows v0.2.2 h1:P6oWzpvV7MrEAmhUgs+zmarrWkyL77ycZz4v7+1gYAE=
github.com/iwdgo/sigintwindows v0.2.2/go.mod h1:70wPb8oz8OnxPvsj2QMUjgIVhb8hMu5TUgX8KfFl7QY=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/opentracing/basictracer-go v1.1.0 h1:Oa1fTSBvAl8pa3U+IJYqrKm0NALwH9OsgwOqDv4xJW0=
github.com/opentracing/basictracer-go v1.1.0/go.mod h1:V2HZueSJEp879yv285Aap1BS69fQMD+MNP1mRs6mBQc=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pgavlin/fx v0.1.6 h1:r9jEg69DhNoCd3Xh0+5mIbdbS3PqWrVWujkY76MFRTU=
github.com/pgavlin/fx v0.1.6/go.mod h1:KWZJ6fqBBSh8GxHYqwYCf3rYE7Gp2p0N8tJp8xv9u9M=
github.com/pgavlin/fx/v2 v2.0.12 h1:SjjaJ68Dt8Z4zHwOpY/RPijd7lShs6xYupJbF9ra00M=
github.com/pgavlin/fx/v2 v2.0.12/go.mod h1:M/nF/ooAOy+NUBooYYXl2REARzJ/giPJxfMs8fINfKc=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk=
github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 h1:vkHw5I/plNdTr435cARxCW6q9gc0S/Yxz7Mkd38pOb0=
github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231/go.mod h1:murToZ2N9hNJzewjHBgfFdXhZKjY3z5cYC1VXk+lbFE=
github.com/pulumi/esc v0.24.0 h1:sCtiB0qbyrlU1ZNzJn4dTLYiChl8xeCBFbHWl1YoXJg=
github.com/pulumi/esc v0.24.0/go.mod h1:eCOOkcDJS6eooGwdE4/E0+pOsvUWG254+KBmPCFwJpA=
github.com/pulumi/pulumi-github/sdk/v6 v6.14.0 h1:49TS7PctMDGQpOAD6vu0On+k6L3t0Rtrzmwd/fDcbVk=
github.com/pulumi/pulumi-github/sdk/v6 v6.14.0/go.mod h1:aSCgSWErJwJujq1CKHe71wArYAKjWKHs+REB5GcM0es=
github.com/pulumi/pulumi/sdk/v3 v3.243.0 h1:pZaMx58nXrdh4XB0cgTlHnL3EMy3/JQwuin3aDuWyRM=
github.com/pulumi/pulumi/sdk/v3 v3.243.0/go.mod h1:BPWWuYPXcPH5YbXGoyy9Rrfa+evrh6IdM51AjDhcDpM=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 h1:TToq11gyfNlrMFZiYujSekIsPd9AmsA2Bj/iv+s4JHE=
github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U=
github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8=
github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0=
github.com/zclconf/go-cty v1.13.2/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/collector/featuregate v1.53.0 h1:cgjXdtl7jezWxq6V0eohe/JqjY4PBotZGb5+bTR2OJw=
go.opentelemetry.io/collector/featuregate v1.53.0/go.mod h1:PS7zY/zaCb28EqciePVwRHVhc3oKortTFXsi3I6ee4g=
go.opentelemetry.io/collector/internal/testutil v0.147.0 h1:DFlRxBRp23/sZnpTITK25yqe0d56yNvK+63IaWc6OsU=
go.opentelemetry.io/collector/internal/testutil v0.147.0/go.mod h1:Jkjs6rkqs973LqgZ0Fe3zrokQRKULYXPIf4HuqStiEE=
go.opentelemetry.io/collector/pdata v1.53.0 h1:DlYDbRwammEZaxDZHINx5v0n8SEOVNniPbi6FRTlVkA=
go.opentelemetry.io/collector/pdata v1.53.0/go.mod h1:LRSYGNjKXaUrZEwZv3Yl+8/zV2HmRGKXW62zB2bysms=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE=
go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI=
go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8=
go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0/go.mod h1:Gyb6Xe7FTi/6xBHwMmngGoHqL0w29Y4eW8TGFzpefGA=
go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0 h1:EiUYvtwu6PMrMHVjcPfnsG3v+ajPkbUeH+IL93+QYyk=
go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0/go.mod h1:mUUHKFiN2SST3AhJ8XhJxEoeVW12oqfXog0Bo8W3Ec4=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/frand v1.4.2 h1:RzFIpOvkMXuPMBb9maa4ND4wjBn71E1Jpf8BzJHMaVw=
lukechampine.com/frand v1.4.2/go.mod h1:4S/TM2ZgrKejMcKMbeLjISpJMO+/eZ1zu3vYX9dtj3s=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+568
View File
@@ -0,0 +1,568 @@
// Package cli parses command-line arguments for gh-repo-bootstrap.
package cli
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
// Action selects which Pulumi operation to perform.
type Action string
const (
ActionApply Action = "apply"
ActionPlan Action = "plan"
ActionDestroy Action = "destroy"
)
// BypassActor is a parsed --bypass spec.
type BypassActor struct {
ActorID int
ActorType string
BypassMode string
}
// Options holds the parsed CLI state.
type Options struct {
Owner string
Repo string
Branch string
Reviews int
Signed bool
Ruleset string
// Environments preserves insertion order. Lookups go through findEnv.
Environments []*EnvSpec
Bypass []BypassActor
Action Action
StateDir string
RepoSecretsFile string
EnvSecretsDir string
// Repo-level management.
RepoMode RepoMode // "" defaults to RepoModeData
RepoSettings RepoSettings
// ConfigFile, if set, means all other flags came from a TOML file.
ConfigFile string
}
const usage = `Usage:
gh repo-bootstrap <owner/repo> [options]
gh repo-bootstrap --config FILE
Apply a standard branch-protection ruleset, deployment environments,
optional environment protection rules, and optionally repo-level
settings to a GitHub repository via Pulumi.
Options:
--config FILE Load every setting from a TOML file. Exclusive
with every other flag (including <owner/repo>).
--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>]
--solo Shortcut for --bypass RepositoryRole:5:always
Repo-level (use with --create or --manage-repo):
--create Create the repo as a Pulumi resource. Runs minimal
interactive prompts for visibility + description
when those flags are not supplied.
--manage-repo Import an existing repo into Pulumi state and
manage its settings from now on. Run --plan first.
--visibility V Repo visibility: public | private
--description TEXT Repo description
--topic NAME Add a repository topic (repeatable)
--default-repo-branch NAME Default branch on the repo itself
(distinct from --branch which is the ruleset
target; on --create they default to the same).
--allow-merge-commit / --no-allow-merge-commit
--allow-squash-merge / --no-allow-squash-merge
--allow-rebase-merge / --no-allow-rebase-merge
--delete-branch-on-merge / --no-delete-branch-on-merge
--auto-init / --no-auto-init (--create only)
--license-template SLUG (--create only)
--gitignore-template NAME (--create only)
Environment protection:
--env-reviewer ENV:ACTOR (repeatable)
ACTOR is user:<id-or-login> or team:<id-or-slug>
where slug is org/team-slug
--env-wait-timer ENV:MINUTES
--env-prevent-self-review ENV
--env-no-admin-bypass ENV
--env-branch-policy ENV:MODE MODE = protected | custom | none
--env-branch-pattern ENV:PATTERN (repeatable)
Secrets:
--upload-repo-secrets FILE
--upload-env-secrets DIR
Run control:
--plan pulumi preview
--destroy pulumi destroy
--state-dir DIR Override working/state directory
(default: $XDG_STATE_HOME/gh-repo-bootstrap or
~/.local/state/gh-repo-bootstrap)
-h, --help Show this help
Authentication:
GITHUB_TOKEN is auto-populated from ` + "`gh auth token`" + ` if not already set.
`
// PrintUsage writes the usage text to stdout.
func PrintUsage() { fmt.Print(usage) }
// findEnv returns the EnvSpec with name n, creating it if necessary so order
// of first appearance is preserved.
func (o *Options) findEnv(n string) *EnvSpec {
for _, e := range o.Environments {
if e.Name == n {
return e
}
}
e := &EnvSpec{Name: n}
o.Environments = append(o.Environments, e)
return e
}
// Parse parses argv (excluding program name) into Options.
// Returns (nil, nil) when the user requested --help.
func Parse(argv []string) (*Options, error) {
// --- --config exclusivity check ---------------------------------------
if configFile, ok := findConfigFlag(argv); ok {
if len(argv) != 2 {
return nil, errors.New("No other flags allowed with config file.")
}
return &Options{ConfigFile: configFile}, nil
}
o := &Options{
Branch: "main",
Reviews: 1,
Ruleset: "default-branch-protection",
Action: ActionApply,
}
need := func(i int, flag string) (string, error) {
if i+1 >= len(argv) {
return "", fmt.Errorf("%s requires a value", flag)
}
return argv[i+1], nil
}
bptr := func(b bool) *bool { return &b }
i := 0
for i < len(argv) {
a := argv[i]
switch a {
case "-h", "--help":
PrintUsage()
return nil, nil
case "--branch":
v, err := need(i, a)
if err != nil {
return nil, err
}
o.Branch = v
i += 2
case "--reviews":
v, err := need(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)
}
o.Reviews = n
i += 2
case "--signed":
o.Signed = true
i++
case "--ruleset":
v, err := need(i, a)
if err != nil {
return nil, err
}
o.Ruleset = v
i += 2
case "--env":
v, err := need(i, a)
if err != nil {
return nil, err
}
_ = o.findEnv(v)
i += 2
case "--bypass":
v, err := need(i, a)
if err != nil {
return nil, err
}
b, err := parseBypass(v)
if err != nil {
return nil, err
}
o.Bypass = append(o.Bypass, b)
i += 2
case "--solo":
o.Bypass = append(o.Bypass, BypassActor{ActorID: 5, ActorType: "RepositoryRole", BypassMode: "always"})
i++
case "--upload-repo-secrets":
v, err := need(i, a)
if err != nil {
return nil, err
}
o.RepoSecretsFile = v
i += 2
case "--upload-env-secrets":
v, err := need(i, a)
if err != nil {
return nil, err
}
o.EnvSecretsDir = v
i += 2
case "--plan":
o.Action = ActionPlan
i++
case "--destroy":
o.Action = ActionDestroy
i++
case "--state-dir":
v, err := need(i, a)
if err != nil {
return nil, err
}
o.StateDir = v
i += 2
// --- Repo-level management ---------------------------------------
case "--create":
if o.RepoMode == RepoModeManage {
return nil, errors.New("--create and --manage-repo are mutually exclusive")
}
o.RepoMode = RepoModeCreate
i++
case "--manage-repo":
if o.RepoMode == RepoModeCreate {
return nil, errors.New("--create and --manage-repo are mutually exclusive")
}
o.RepoMode = RepoModeManage
i++
case "--visibility":
v, err := need(i, a)
if err != nil {
return nil, err
}
if v != "public" && v != "private" {
return nil, fmt.Errorf("--visibility must be public or private (got: %s)", v)
}
o.RepoSettings.Visibility = v
i += 2
case "--description":
v, err := need(i, a)
if err != nil {
return nil, err
}
s := v
o.RepoSettings.Description = &s
i += 2
case "--topic":
v, err := need(i, a)
if err != nil {
return nil, err
}
o.RepoSettings.Topics = append(o.RepoSettings.Topics, v)
i += 2
case "--default-repo-branch":
v, err := need(i, a)
if err != nil {
return nil, err
}
o.RepoSettings.DefaultBranch = v
i += 2
case "--allow-merge-commit":
o.RepoSettings.AllowMergeCommit = bptr(true)
i++
case "--no-allow-merge-commit":
o.RepoSettings.AllowMergeCommit = bptr(false)
i++
case "--allow-squash-merge":
o.RepoSettings.AllowSquashMerge = bptr(true)
i++
case "--no-allow-squash-merge":
o.RepoSettings.AllowSquashMerge = bptr(false)
i++
case "--allow-rebase-merge":
o.RepoSettings.AllowRebaseMerge = bptr(true)
i++
case "--no-allow-rebase-merge":
o.RepoSettings.AllowRebaseMerge = bptr(false)
i++
case "--delete-branch-on-merge":
o.RepoSettings.DeleteBranchOnMerge = bptr(true)
i++
case "--no-delete-branch-on-merge":
o.RepoSettings.DeleteBranchOnMerge = bptr(false)
i++
case "--auto-init":
o.RepoSettings.AutoInit = bptr(true)
i++
case "--no-auto-init":
o.RepoSettings.AutoInit = bptr(false)
i++
case "--license-template":
v, err := need(i, a)
if err != nil {
return nil, err
}
o.RepoSettings.LicenseTemplate = v
i += 2
case "--gitignore-template":
v, err := need(i, a)
if err != nil {
return nil, err
}
o.RepoSettings.GitignoreTemplate = v
i += 2
// --- Environment protection --------------------------------------
case "--env-reviewer":
v, err := need(i, a)
if err != nil {
return nil, err
}
if err := parseEnvReviewer(o, v); err != nil {
return nil, err
}
i += 2
case "--env-wait-timer":
v, err := need(i, a)
if err != nil {
return nil, err
}
envName, rest, ok := splitColon(v)
if !ok {
return nil, fmt.Errorf("--env-wait-timer expects ENV:MINUTES (got: %s)", v)
}
n, err := strconv.Atoi(rest)
if err != nil || n < 0 {
return nil, fmt.Errorf("--env-wait-timer minutes must be a non-negative integer (got: %s)", rest)
}
e := o.findEnv(envName)
e.WaitTimer = &n
i += 2
case "--env-prevent-self-review":
v, err := need(i, a)
if err != nil {
return nil, err
}
e := o.findEnv(v)
e.PreventSelfReview = bptr(true)
i += 2
case "--env-no-admin-bypass":
v, err := need(i, a)
if err != nil {
return nil, err
}
e := o.findEnv(v)
e.CanAdminsBypass = bptr(false)
i += 2
case "--env-branch-policy":
v, err := need(i, a)
if err != nil {
return nil, err
}
envName, mode, ok := splitColon(v)
if !ok {
return nil, fmt.Errorf("--env-branch-policy expects ENV:MODE (got: %s)", v)
}
switch mode {
case "none", "protected", "custom":
default:
return nil, fmt.Errorf("--env-branch-policy MODE must be none|protected|custom (got: %s)", mode)
}
e := o.findEnv(envName)
e.BranchPolicy = mode
i += 2
case "--env-branch-pattern":
v, err := need(i, a)
if err != nil {
return nil, err
}
envName, pat, ok := splitColon(v)
if !ok || pat == "" {
return nil, fmt.Errorf("--env-branch-pattern expects ENV:PATTERN (got: %s)", v)
}
e := o.findEnv(envName)
e.BranchPatterns = append(e.BranchPatterns, pat)
i += 2
case "--":
i++
for ; i < len(argv); i++ {
if err := o.setRepo(argv[i]); err != nil {
return nil, err
}
}
default:
if strings.HasPrefix(a, "-") {
return nil, fmt.Errorf("unknown option: %s", a)
}
if err := o.setRepo(a); err != nil {
return nil, err
}
i++
}
}
if o.Repo == "" {
return nil, errors.New("first argument must be <owner>/<repo>")
}
if len(o.Environments) == 0 {
o.Environments = []*EnvSpec{{Name: "production"}}
}
if o.RepoMode == "" {
o.RepoMode = RepoModeData
}
if err := validateRepoModeFlags(o); err != nil {
return nil, err
}
return o, nil
}
// findConfigFlag scans argv for `--config FILE` or `--config=FILE`.
func findConfigFlag(argv []string) (string, bool) {
for i, a := range argv {
if a == "--config" {
if i+1 < len(argv) {
return argv[i+1], true
}
return "", true // present but missing; caller will fail exclusivity check
}
if strings.HasPrefix(a, "--config=") {
return strings.TrimPrefix(a, "--config="), true
}
}
return "", false
}
// validateRepoModeFlags rejects repo-settings flags when mode is "data".
func validateRepoModeFlags(o *Options) error {
if o.RepoMode != RepoModeData {
return nil
}
rs := o.RepoSettings
if rs.Visibility != "" || rs.Description != nil || rs.DefaultBranch != "" ||
len(rs.Topics) > 0 ||
rs.AllowMergeCommit != nil || rs.AllowSquashMerge != nil ||
rs.AllowRebaseMerge != nil || rs.DeleteBranchOnMerge != nil ||
rs.AutoInit != nil || rs.LicenseTemplate != "" || rs.GitignoreTemplate != "" {
return errors.New("repo-level settings require --create or --manage-repo")
}
return nil
}
// parseEnvReviewer parses `ENV:user:NAME-OR-ID` or `ENV:team:SLUG-OR-ID`.
func parseEnvReviewer(o *Options, spec string) error {
envName, rest, ok := splitColon(spec)
if !ok {
return fmt.Errorf("--env-reviewer expects ENV:user:... or ENV:team:... (got: %s)", spec)
}
kind, who, ok := splitColon(rest)
if !ok || who == "" {
return fmt.Errorf("--env-reviewer expects ENV:user:... or ENV:team:... (got: %s)", spec)
}
e := o.findEnv(envName)
switch kind {
case "user":
e.ReviewerUsers = append(e.ReviewerUsers, who)
case "team":
e.ReviewerTeams = append(e.ReviewerTeams, who)
default:
return fmt.Errorf("--env-reviewer kind must be user or team (got: %s)", kind)
}
return nil
}
// splitColon splits "a:b" into ("a", "b", true). For "a:b:c" returns ("a", "b:c", true).
func splitColon(s string) (string, string, bool) {
idx := strings.IndexByte(s, ':')
if idx <= 0 || idx == len(s)-1 {
return "", "", false
}
return s[:idx], s[idx+1:], true
}
func (o *Options) setRepo(a string) error {
if o.Repo != "" {
return fmt.Errorf("unexpected positional argument: %s", a)
}
slash := strings.IndexByte(a, '/')
if slash <= 0 || slash == len(a)-1 {
return errors.New("first argument must be <owner>/<repo>")
}
o.Owner = a[:slash]
o.Repo = a[slash+1:]
return nil
}
func parseBypass(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)
}
actorType, actorIDStr := parts[0], parts[1]
mode := "always"
if len(parts) == 3 {
mode = parts[2]
}
if actorType == "" || actorIDStr == "" {
return BypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (expected <actor_type>:<actor_id>[:<mode>])", spec)
}
id, err := strconv.Atoi(actorIDStr)
if err != nil || id < 0 {
return BypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (actor_id must be numeric)", spec)
}
switch mode {
case "always", "pull_request":
default:
return BypassActor{}, fmt.Errorf("invalid --bypass mode %q (must be 'always' or 'pull_request')", mode)
}
switch actorType {
case "RepositoryRole", "Team", "Integration", "OrganizationAdmin", "DeployKey":
default:
return BypassActor{}, fmt.Errorf("invalid --bypass actor_type %q", actorType)
}
return BypassActor{ActorID: id, ActorType: actorType, BypassMode: mode}, nil
}
// DefaultStateDir returns the per-repo state directory under
// $XDG_STATE_HOME/gh-repo-bootstrap or ~/.local/state/gh-repo-bootstrap.
func DefaultStateDir(owner, repo string) string {
base := os.Getenv("XDG_STATE_HOME")
if base == "" {
home, _ := os.UserHomeDir()
base = home + "/.local/state"
}
return fmt.Sprintf("%s/gh-repo-bootstrap/%s__%s", base, owner, repo)
}
// EnvNames returns the list of environment names in insertion order, for
// secret-file matching and other places that don't need the full EnvSpec.
func (o *Options) EnvNames() []string {
out := make([]string, len(o.Environments))
for i, e := range o.Environments {
out[i] = e.Name
}
return out
}
+119
View File
@@ -0,0 +1,119 @@
package cli
import (
"strings"
"testing"
)
func TestParse_ConfigExclusivity_OK(t *testing.T) {
o, err := Parse([]string{"--config", "foo.toml"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if o.ConfigFile != "foo.toml" {
t.Fatalf("ConfigFile = %q", o.ConfigFile)
}
}
func TestParse_ConfigExclusivity_Reject(t *testing.T) {
for _, args := range [][]string{
{"--config", "foo.toml", "JMR-dev/x"},
{"JMR-dev/x", "--config", "foo.toml"},
{"--config", "foo.toml", "--signed"},
} {
_, err := Parse(args)
if err == nil || !strings.Contains(err.Error(), "No other flags allowed with config file.") {
t.Errorf("args %v: expected exclusivity error, got %v", args, err)
}
}
}
func TestParse_EnvReviewer(t *testing.T) {
o, err := Parse([]string{
"JMR-dev/x",
"--env", "production",
"--env-reviewer", "production:user:octocat",
"--env-reviewer", "production:team:JMR-dev/release-managers",
"--env-reviewer", "staging:user:1234",
"--env-branch-policy", "production:custom",
"--env-branch-pattern", "production:release/*",
"--env-wait-timer", "production:5",
"--env-prevent-self-review", "production",
"--env-no-admin-bypass", "production",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(o.Environments) != 2 {
t.Fatalf("got %d envs, want 2", len(o.Environments))
}
prod := o.Environments[0]
if prod.Name != "production" {
t.Fatalf("prod.Name = %q", prod.Name)
}
if len(prod.ReviewerUsers) != 1 || prod.ReviewerUsers[0] != "octocat" {
t.Errorf("ReviewerUsers = %v", prod.ReviewerUsers)
}
if len(prod.ReviewerTeams) != 1 || prod.ReviewerTeams[0] != "JMR-dev/release-managers" {
t.Errorf("ReviewerTeams = %v", prod.ReviewerTeams)
}
if prod.BranchPolicy != "custom" || len(prod.BranchPatterns) != 1 {
t.Errorf("branch policy/patterns = %v / %v", prod.BranchPolicy, prod.BranchPatterns)
}
if prod.WaitTimer == nil || *prod.WaitTimer != 5 {
t.Errorf("WaitTimer = %v", prod.WaitTimer)
}
if prod.PreventSelfReview == nil || !*prod.PreventSelfReview {
t.Errorf("PreventSelfReview = %v", prod.PreventSelfReview)
}
if prod.CanAdminsBypass == nil || *prod.CanAdminsBypass {
t.Errorf("CanAdminsBypass = %v", prod.CanAdminsBypass)
}
}
func TestParse_RepoSettingsRequireMode(t *testing.T) {
_, err := Parse([]string{"JMR-dev/x", "--visibility", "private"})
if err == nil || !strings.Contains(err.Error(), "--create or --manage-repo") {
t.Fatalf("expected mode-required error, got %v", err)
}
}
func TestParse_CreateAndManageMutuallyExclusive(t *testing.T) {
_, err := Parse([]string{"JMR-dev/x", "--create", "--manage-repo"})
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutex error, got %v", err)
}
}
func TestParse_CreateFlags(t *testing.T) {
o, err := Parse([]string{
"JMR-dev/x", "--create",
"--visibility", "private",
"--description", "hello",
"--topic", "go",
"--topic", "api",
"--default-repo-branch", "main",
"--no-allow-merge-commit",
"--allow-squash-merge",
"--delete-branch-on-merge",
"--auto-init",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if o.RepoMode != RepoModeCreate {
t.Errorf("RepoMode = %v", o.RepoMode)
}
if o.RepoSettings.Visibility != "private" {
t.Errorf("Visibility = %q", o.RepoSettings.Visibility)
}
if o.RepoSettings.Description == nil || *o.RepoSettings.Description != "hello" {
t.Errorf("Description = %v", o.RepoSettings.Description)
}
if len(o.RepoSettings.Topics) != 2 {
t.Errorf("Topics = %v", o.RepoSettings.Topics)
}
if o.RepoSettings.AllowMergeCommit == nil || *o.RepoSettings.AllowMergeCommit {
t.Errorf("AllowMergeCommit = %v", o.RepoSettings.AllowMergeCommit)
}
}
+326
View File
@@ -0,0 +1,326 @@
package cli
import (
"fmt"
"os"
"github.com/BurntSushi/toml"
)
// configFile is the on-disk schema for `--config FILE`. The loader translates
// it into an *Options that downstream code consumes identically to flag-based
// invocations.
type configFile struct {
Owner string `toml:"owner"`
Name string `toml:"name"`
Mode string `toml:"mode"`
StateDir string `toml:"state_dir"`
Action string `toml:"action"`
Repo *configRepo `toml:"repo"`
Ruleset *configRuleset `toml:"ruleset"`
Environments []configEnv `toml:"environments"`
Secrets *configSecrets `toml:"secrets"`
}
type configRepo struct {
Visibility string `toml:"visibility"`
Description *string `toml:"description"`
DefaultBranch string `toml:"default_branch"`
Topics []string `toml:"topics"`
AllowMergeCommit *bool `toml:"allow_merge_commit"`
AllowSquashMerge *bool `toml:"allow_squash_merge"`
AllowRebaseMerge *bool `toml:"allow_rebase_merge"`
DeleteBranchOnMerge *bool `toml:"delete_branch_on_merge"`
AutoInit *bool `toml:"auto_init"`
LicenseTemplate string `toml:"license_template"`
GitignoreTemplate string `toml:"gitignore_template"`
}
type configRuleset struct {
Name string `toml:"name"`
Branch string `toml:"branch"`
RequiredReviews *int `toml:"required_reviews"`
RequireSignedCommits *bool `toml:"require_signed_commits"`
Bypass []configBypass `toml:"bypass"`
}
type configBypass struct {
ActorType string `toml:"actor_type"`
ActorID int `toml:"actor_id"`
Mode string `toml:"mode"`
}
type configEnv struct {
Name string `toml:"name"`
WaitTimer *int `toml:"wait_timer"`
PreventSelfReview *bool `toml:"prevent_self_review"`
CanAdminsBypass *bool `toml:"can_admins_bypass"`
ReviewersUsers []any `toml:"reviewers_users"`
ReviewersTeams []any `toml:"reviewers_teams"`
BranchPolicy string `toml:"branch_policy"`
BranchPatterns []string `toml:"branch_patterns"`
}
type configSecrets struct {
RepoFile string `toml:"repo_file"`
EnvDir string `toml:"env_dir"`
}
// LoadConfig reads the TOML file at path and returns a fully-populated
// *Options. It validates create-mode required fields and rejects unknown
// values up front.
func LoadConfig(path string) (*Options, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cf configFile
md, err := toml.Decode(string(raw), &cf)
if err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if undec := md.Undecoded(); len(undec) > 0 {
return nil, fmt.Errorf("unknown key in config: %s", undec[0])
}
if cf.Owner == "" {
return nil, fmt.Errorf("config: owner is required")
}
if cf.Name == "" {
return nil, fmt.Errorf("config: name is required")
}
mode := RepoMode(cf.Mode)
if mode == "" {
mode = RepoModeData
}
switch mode {
case RepoModeData, RepoModeCreate, RepoModeManage:
default:
return nil, fmt.Errorf("config: mode must be data|create|manage (got: %s)", cf.Mode)
}
action := ActionApply
switch cf.Action {
case "", "apply":
action = ActionApply
case "plan":
action = ActionPlan
case "destroy":
action = ActionDestroy
default:
return nil, fmt.Errorf("config: action must be apply|plan|destroy (got: %s)", cf.Action)
}
o := &Options{
Owner: cf.Owner,
Repo: cf.Name,
Action: action,
StateDir: cf.StateDir,
RepoMode: mode,
Branch: "main",
Reviews: 1,
Ruleset: "default-branch-protection",
}
// --- [repo] -----------------------------------------------------------
if cf.Repo != nil {
r := cf.Repo
if r.Visibility != "" && r.Visibility != "public" && r.Visibility != "private" {
return nil, fmt.Errorf("config: [repo].visibility must be public or private (got: %s)", r.Visibility)
}
o.RepoSettings = RepoSettings{
Visibility: r.Visibility,
Description: r.Description,
DefaultBranch: r.DefaultBranch,
Topics: r.Topics,
AllowMergeCommit: r.AllowMergeCommit,
AllowSquashMerge: r.AllowSquashMerge,
AllowRebaseMerge: r.AllowRebaseMerge,
DeleteBranchOnMerge: r.DeleteBranchOnMerge,
AutoInit: r.AutoInit,
LicenseTemplate: r.LicenseTemplate,
GitignoreTemplate: r.GitignoreTemplate,
}
}
if mode == RepoModeCreate {
if err := validateCreateRequired(cf.Repo); err != nil {
return nil, err
}
}
if mode == RepoModeManage {
if err := validateManageRequired(cf.Repo); err != nil {
return nil, err
}
}
// --- [ruleset] --------------------------------------------------------
if cf.Ruleset != nil {
if cf.Ruleset.Name != "" {
o.Ruleset = cf.Ruleset.Name
}
if cf.Ruleset.Branch != "" {
o.Branch = cf.Ruleset.Branch
}
if cf.Ruleset.RequiredReviews != nil {
if *cf.Ruleset.RequiredReviews < 0 {
return nil, fmt.Errorf("config: [ruleset].required_reviews must be >= 0")
}
o.Reviews = *cf.Ruleset.RequiredReviews
}
if cf.Ruleset.RequireSignedCommits != nil {
o.Signed = *cf.Ruleset.RequireSignedCommits
}
for _, b := range cf.Ruleset.Bypass {
mode := b.Mode
if mode == "" {
mode = "always"
}
if mode != "always" && mode != "pull_request" {
return nil, fmt.Errorf("config: ruleset bypass mode must be always|pull_request (got: %s)", b.Mode)
}
if b.ActorType == "" || b.ActorID == 0 {
return nil, fmt.Errorf("config: ruleset bypass entry requires actor_type and actor_id")
}
o.Bypass = append(o.Bypass, BypassActor{
ActorID: b.ActorID,
ActorType: b.ActorType,
BypassMode: mode,
})
}
}
// --- [[environments]] ------------------------------------------------
for idx, e := range cf.Environments {
if e.Name == "" {
return nil, fmt.Errorf("config: [[environments]][%d].name is required", idx)
}
spec := &EnvSpec{
Name: e.Name,
WaitTimer: e.WaitTimer,
PreventSelfReview: e.PreventSelfReview,
CanAdminsBypass: e.CanAdminsBypass,
}
for _, v := range e.ReviewersUsers {
s, err := anyToReviewerStr(v)
if err != nil {
return nil, fmt.Errorf("config: env %q reviewers_users: %w", e.Name, err)
}
spec.ReviewerUsers = append(spec.ReviewerUsers, s)
}
for _, v := range e.ReviewersTeams {
s, err := anyToReviewerStr(v)
if err != nil {
return nil, fmt.Errorf("config: env %q reviewers_teams: %w", e.Name, err)
}
spec.ReviewerTeams = append(spec.ReviewerTeams, s)
}
switch e.BranchPolicy {
case "", "none", "protected", "custom":
default:
return nil, fmt.Errorf("config: env %q branch_policy must be none|protected|custom (got: %s)", e.Name, e.BranchPolicy)
}
spec.BranchPolicy = e.BranchPolicy
spec.BranchPatterns = e.BranchPatterns
o.Environments = append(o.Environments, spec)
}
if len(o.Environments) == 0 {
o.Environments = []*EnvSpec{{Name: "production"}}
}
// --- [secrets] -------------------------------------------------------
if cf.Secrets != nil {
o.RepoSecretsFile = cf.Secrets.RepoFile
o.EnvSecretsDir = cf.Secrets.EnvDir
}
return o, nil
}
// anyToReviewerStr accepts either a TOML integer or string and renders it as
// the raw reviewer identifier that the resolver will later turn into an int.
func anyToReviewerStr(v any) (string, error) {
switch t := v.(type) {
case string:
return t, nil
case int64:
return fmt.Sprintf("%d", t), nil
case int:
return fmt.Sprintf("%d", t), nil
default:
return "", fmt.Errorf("reviewer entries must be strings or integers (got: %T)", v)
}
}
// validateCreateRequired checks every required key for mode = "create".
func validateCreateRequired(r *configRepo) error {
if r == nil {
return fmt.Errorf("config: [repo] table is required when mode = \"create\"")
}
missing := func(name string) error {
return fmt.Errorf("config: [repo].%s is required when mode = \"create\"", name)
}
if r.Visibility == "" {
return missing("visibility")
}
if r.Description == nil {
return missing("description")
}
if r.DefaultBranch == "" {
return missing("default_branch")
}
if r.AllowMergeCommit == nil {
return missing("allow_merge_commit")
}
if r.AllowSquashMerge == nil {
return missing("allow_squash_merge")
}
if r.AllowRebaseMerge == nil {
return missing("allow_rebase_merge")
}
if r.DeleteBranchOnMerge == nil {
return missing("delete_branch_on_merge")
}
if r.AutoInit == nil {
return missing("auto_init")
}
return nil
}
// validateManageRequired checks every required key for mode = "manage".
// auto_init / license_template / gitignore_template apply only at creation
// time and are ignored here.
func validateManageRequired(r *configRepo) error {
if r == nil {
return fmt.Errorf("config: [repo] table is required when mode = \"manage\"")
}
missing := func(name string) error {
return fmt.Errorf("config: [repo].%s is required when mode = \"manage\"", name)
}
if r.Visibility == "" {
return missing("visibility")
}
if r.Description == nil {
return missing("description")
}
if r.DefaultBranch == "" {
return missing("default_branch")
}
if r.AllowMergeCommit == nil {
return missing("allow_merge_commit")
}
if r.AllowSquashMerge == nil {
return missing("allow_squash_merge")
}
if r.AllowRebaseMerge == nil {
return missing("allow_rebase_merge")
}
if r.DeleteBranchOnMerge == nil {
return missing("delete_branch_on_merge")
}
return nil
}
+135
View File
@@ -0,0 +1,135 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeTmp(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "config.toml")
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatalf("write tmp: %v", err)
}
return p
}
func TestLoadConfig_DataMode_Defaults(t *testing.T) {
p := writeTmp(t, `
owner = "JMR-dev"
name = "x"
`)
o, err := LoadConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if o.Owner != "JMR-dev" || o.Repo != "x" {
t.Errorf("owner/name = %q/%q", o.Owner, o.Repo)
}
if o.RepoMode != RepoModeData {
t.Errorf("RepoMode = %v", o.RepoMode)
}
if len(o.Environments) != 1 || o.Environments[0].Name != "production" {
t.Errorf("Environments = %+v", o.Environments)
}
}
func TestLoadConfig_CreateRequiresFields(t *testing.T) {
p := writeTmp(t, `
owner = "JMR-dev"
name = "x"
mode = "create"
`)
_, err := LoadConfig(p)
if err == nil || !strings.Contains(err.Error(), "[repo] table is required") {
t.Fatalf("expected required-table error, got %v", err)
}
}
func TestLoadConfig_CreateMissingVisibility(t *testing.T) {
p := writeTmp(t, `
owner = "JMR-dev"
name = "x"
mode = "create"
[repo]
description = ""
default_branch = "main"
allow_merge_commit = false
allow_squash_merge = true
allow_rebase_merge = false
delete_branch_on_merge = true
auto_init = true
`)
_, err := LoadConfig(p)
if err == nil || !strings.Contains(err.Error(), "visibility is required") {
t.Fatalf("expected visibility-required error, got %v", err)
}
}
func TestLoadConfig_CreateFull(t *testing.T) {
p := writeTmp(t, `
owner = "JMR-dev"
name = "x"
mode = "create"
[repo]
visibility = "private"
description = "hi"
default_branch = "main"
topics = ["go", "api"]
allow_merge_commit = false
allow_squash_merge = true
allow_rebase_merge = false
delete_branch_on_merge = true
auto_init = true
[[environments]]
name = "production"
wait_timer = 5
prevent_self_review = true
can_admins_bypass = false
reviewers_users = ["octocat", 12345]
reviewers_teams = ["JMR-dev/release"]
branch_policy = "custom"
branch_patterns = ["release/*"]
`)
o, err := LoadConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if o.RepoMode != RepoModeCreate {
t.Fatalf("RepoMode = %v", o.RepoMode)
}
if o.RepoSettings.Visibility != "private" {
t.Errorf("visibility = %q", o.RepoSettings.Visibility)
}
if len(o.RepoSettings.Topics) != 2 {
t.Errorf("topics = %v", o.RepoSettings.Topics)
}
if len(o.Environments) != 1 {
t.Fatalf("len envs = %d", len(o.Environments))
}
prod := o.Environments[0]
if got := prod.ReviewerUsers; len(got) != 2 || got[0] != "octocat" || got[1] != "12345" {
t.Errorf("reviewer users = %v", got)
}
if prod.BranchPolicy != "custom" || len(prod.BranchPatterns) != 1 {
t.Errorf("branch policy = %v / patterns = %v", prod.BranchPolicy, prod.BranchPatterns)
}
}
func TestLoadConfig_UnknownKey(t *testing.T) {
p := writeTmp(t, `
owner = "JMR-dev"
name = "x"
nope = true
`)
_, err := LoadConfig(p)
if err == nil || !strings.Contains(err.Error(), "unknown key") {
t.Fatalf("expected unknown-key error, got %v", err)
}
}
+52
View File
@@ -0,0 +1,52 @@
package cli
// RepoMode selects how the repository itself is handled by Pulumi.
//
// - RepoModeData — repository is a data source only; ruleset/envs/secrets
// are managed against it but the repo settings themselves are untouched.
// This is the default and matches the original bash behavior.
// - RepoModeCreate — Pulumi creates the repository from scratch.
// - RepoModeManage — Pulumi imports the existing repository into state on
// first apply, then manages its settings going forward.
type RepoMode string
const (
RepoModeData RepoMode = "data"
RepoModeCreate RepoMode = "create"
RepoModeManage RepoMode = "manage"
)
// RepoSettings holds the repo-level configuration applied when RepoMode is
// "create" or "manage". Pointers distinguish "unset" from a deliberate false.
type RepoSettings struct {
Visibility string
Description *string
DefaultBranch string
Topics []string
AllowMergeCommit *bool
AllowSquashMerge *bool
AllowRebaseMerge *bool
DeleteBranchOnMerge *bool
// Create-only fields (ignored on manage).
AutoInit *bool
LicenseTemplate string
GitignoreTemplate string
}
// EnvSpec describes one deployment environment and its protection rules.
// ReviewerUsers / ReviewerTeams entries are raw strings (numeric IDs as
// strings, GitHub logins, or org/team-slug pairs) before they are resolved
// by the runner.
type EnvSpec struct {
Name string
WaitTimer *int
PreventSelfReview *bool
CanAdminsBypass *bool
ReviewerUsers []string
ReviewerTeams []string
// BranchPolicy: "" / "none" / "protected" / "custom"
BranchPolicy string
BranchPatterns []string
}
+103
View File
@@ -0,0 +1,103 @@
// Package githubapi resolves human-friendly GitHub identifiers (logins,
// org/team slugs) into numeric IDs by shelling out to `gh api`. Numeric
// inputs short-circuit.
package githubapi
import (
"encoding/json"
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
)
// Resolver caches user/team lookups for the lifetime of a single run.
type Resolver struct {
mu sync.Mutex
users map[string]int
teams map[string]int
}
// New returns a fresh Resolver.
func New() *Resolver {
return &Resolver{users: map[string]int{}, teams: map[string]int{}}
}
// ResolveUser turns a numeric string or a GitHub login (with optional leading
// `@`) into a numeric user ID. Numeric inputs are passed through unchanged.
func (r *Resolver) ResolveUser(s string) (int, error) {
s = strings.TrimPrefix(strings.TrimSpace(s), "@")
if id, err := strconv.Atoi(s); err == nil {
return id, nil
}
r.mu.Lock()
if id, ok := r.users[s]; ok {
r.mu.Unlock()
return id, nil
}
r.mu.Unlock()
id, err := ghAPIID("users/" + s)
if err != nil {
return 0, fmt.Errorf("resolving user %q: %w", s, err)
}
r.mu.Lock()
r.users[s] = id
r.mu.Unlock()
return id, nil
}
// ResolveTeam turns a numeric string or an `org/team-slug` pair into a
// numeric team ID. Bare slugs without an org prefix are rejected because the
// org cannot be inferred unambiguously.
func (r *Resolver) ResolveTeam(s string) (int, error) {
s = strings.TrimSpace(s)
if id, err := strconv.Atoi(s); err == nil {
return id, nil
}
if !strings.Contains(s, "/") {
return 0, fmt.Errorf("team reviewer %q must be in the form org/team-slug", s)
}
r.mu.Lock()
if id, ok := r.teams[s]; ok {
r.mu.Unlock()
return id, nil
}
r.mu.Unlock()
parts := strings.SplitN(s, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return 0, fmt.Errorf("team reviewer %q must be in the form org/team-slug", s)
}
id, err := ghAPIID(fmt.Sprintf("orgs/%s/teams/%s", parts[0], parts[1]))
if err != nil {
return 0, fmt.Errorf("resolving team %q: %w", s, err)
}
r.mu.Lock()
r.teams[s] = id
r.mu.Unlock()
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 := ExecCommand("gh", "api", path)
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
return 0, fmt.Errorf("gh api %s: %s", path, strings.TrimSpace(string(ee.Stderr)))
}
return 0, fmt.Errorf("gh api %s: %w", path, err)
}
var body struct {
ID int `json:"id"`
}
if err := json.Unmarshal(out, &body); err != nil {
return 0, fmt.Errorf("decoding gh api %s response: %w", path, err)
}
if body.ID == 0 {
return 0, fmt.Errorf("gh api %s returned no id", path)
}
return body.ID, nil
}
+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)
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package prompt provides minimal TTY-aware prompts used by --create.
package prompt
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
"golang.org/x/term"
)
var IsTerminal = term.IsTerminal
// IsInteractive reports whether stdin is a terminal.
func IsInteractive() bool {
return IsTerminal(int(os.Stdin.Fd()))
}
// Reader reads prompts from in and writes them to out. Use New() for the
// default stdin/stderr pairing.
type Reader struct {
r *bufio.Reader
w io.Writer
}
// New returns a Reader backed by stdin / stderr.
func New() *Reader {
return &Reader{r: bufio.NewReader(os.Stdin), w: os.Stderr}
}
// NewFromReader is exposed for tests.
func NewFromReader(in io.Reader, out io.Writer) *Reader {
return &Reader{r: bufio.NewReader(in), w: out}
}
// ErrNotInteractive is returned when a prompt is needed but stdin is not a TTY.
var ErrNotInteractive = errors.New("input is not a terminal and a required value was not provided")
// Line writes msg to the output stream and returns one trimmed line of input.
func (p *Reader) Line(msg string) (string, error) {
fmt.Fprint(p.w, msg)
s, err := p.r.ReadString('\n')
if err != nil && (err != io.EOF || s == "") {
return "", err
}
return strings.TrimRight(s, "\r\n"), nil
}
// Choice reads a value from prompt and validates it against allowed. If the
// input is empty, def is returned.
func (p *Reader) Choice(msg, def string, allowed []string) (string, error) {
for attempt := 0; attempt < 3; attempt++ {
v, err := p.Line(msg)
if err != nil {
return "", err
}
if v == "" {
v = def
}
for _, a := range allowed {
if v == a {
return v, nil
}
}
fmt.Fprintf(p.w, " invalid value %q; expected one of: %s\n", v, strings.Join(allowed, ", "))
}
return "", fmt.Errorf("no valid answer after 3 attempts")
}
+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)
}
}
+309
View File
@@ -0,0 +1,309 @@
// Package pulumiprog builds the inline Pulumi program that manages a single
// GitHub repository's settings, ruleset, environments, and Actions secrets.
package pulumiprog
import (
"fmt"
"github.com/JMR-dev/gh-repo-bootstrap/internal/cli"
"github.com/JMR-dev/gh-repo-bootstrap/internal/secrets"
"github.com/pulumi/pulumi-github/sdk/v6/go/github"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// ResolvedEnv mirrors cli.EnvSpec but with reviewer IDs resolved to ints.
type ResolvedEnv struct {
Name string
WaitTimer *int
PreventSelfReview *bool
CanAdminsBypass *bool
ReviewerUserIDs []int
ReviewerTeamIDs []int
BranchPolicy string
BranchPatterns []string
}
// Inputs is the set of values needed by the inline program. It is collected
// from CLI flags / TOML + parsed secret files / resolved reviewer IDs before
// pulumi runs.
type Inputs struct {
Owner string
Repo string
Branch string
Reviews int
Signed bool
RulesetName string
Environments []ResolvedEnv
Bypass []cli.BypassActor
RepoSecrets []secrets.Pair
EnvSecrets []secrets.EnvFile
RepoMode cli.RepoMode
RepoSettings cli.RepoSettings
}
// Build returns a pulumi.RunFunc that materializes the resources described
// by in.
func Build(in Inputs) pulumi.RunFunc {
return func(ctx *pulumi.Context) error {
// --- Repository ----------------------------------------------------
// repoName is the StringInput that downstream resources reference as
// `Repository`. For data mode it's just the literal string; for
// create/manage it's the managed resource's Name output so child
// resources implicitly depend on the repo.
var repoName pulumi.StringInput = pulumi.String(in.Repo)
var repoDep pulumi.Resource
if in.RepoMode == cli.RepoModeCreate || in.RepoMode == cli.RepoModeManage {
args := buildRepoArgs(in.Repo, in.RepoSettings, in.RepoMode)
opts := []pulumi.ResourceOption{}
if in.RepoMode == cli.RepoModeManage {
opts = append(opts, pulumi.Import(pulumi.ID(in.Repo)))
}
repo, err := github.NewRepository(ctx, "repo_"+sanitize(in.Repo), args, opts...)
if err != nil {
return fmt.Errorf("creating repository resource: %w", err)
}
repoName = repo.Name
repoDep = repo
}
// --- Ruleset on the default branch --------------------------------
var bypass github.RepositoryRulesetBypassActorArray
for _, b := range in.Bypass {
bypass = append(bypass, &github.RepositoryRulesetBypassActorArgs{
ActorId: pulumi.Int(b.ActorID),
ActorType: pulumi.String(b.ActorType),
BypassMode: pulumi.String(toCamelBypassMode(b.BypassMode)),
})
}
rulesetOpts := []pulumi.ResourceOption{}
if repoDep != nil {
rulesetOpts = append(rulesetOpts, pulumi.DependsOn([]pulumi.Resource{repoDep}))
}
_, err := github.NewRepositoryRuleset(ctx, "default_branch", &github.RepositoryRulesetArgs{
Repository: repoName,
Name: pulumi.String(in.RulesetName),
Target: pulumi.String("branch"),
Enforcement: pulumi.String("active"),
Conditions: &github.RepositoryRulesetConditionsArgs{
RefName: &github.RepositoryRulesetConditionsRefNameArgs{
Includes: pulumi.StringArray{pulumi.String("refs/heads/" + in.Branch)},
Excludes: pulumi.StringArray{},
},
},
BypassActors: bypass,
Rules: &github.RepositoryRulesetRulesArgs{
Deletion: pulumi.Bool(true),
NonFastForward: pulumi.Bool(true),
RequiredSignatures: pulumi.Bool(in.Signed),
PullRequest: &github.RepositoryRulesetRulesPullRequestArgs{
RequiredApprovingReviewCount: pulumi.Int(in.Reviews),
DismissStaleReviewsOnPush: pulumi.Bool(true),
RequireCodeOwnerReview: pulumi.Bool(false),
RequireLastPushApproval: pulumi.Bool(false),
RequiredReviewThreadResolution: pulumi.Bool(true),
},
},
}, rulesetOpts...)
if err != nil {
return fmt.Errorf("creating ruleset: %w", err)
}
// --- Environments + protection rules ------------------------------
envByName := map[string]*github.RepositoryEnvironment{}
for _, e := range in.Environments {
envArgs := &github.RepositoryEnvironmentArgs{
Repository: repoName,
Environment: pulumi.String(e.Name),
}
if e.WaitTimer != nil {
envArgs.WaitTimer = pulumi.Int(*e.WaitTimer)
}
if e.PreventSelfReview != nil {
envArgs.PreventSelfReview = pulumi.Bool(*e.PreventSelfReview)
}
if e.CanAdminsBypass != nil {
envArgs.CanAdminsBypass = pulumi.Bool(*e.CanAdminsBypass)
}
if len(e.ReviewerUserIDs) > 0 || len(e.ReviewerTeamIDs) > 0 {
users := make(pulumi.IntArray, 0, len(e.ReviewerUserIDs))
for _, id := range e.ReviewerUserIDs {
users = append(users, pulumi.Int(id))
}
teams := make(pulumi.IntArray, 0, len(e.ReviewerTeamIDs))
for _, id := range e.ReviewerTeamIDs {
teams = append(teams, pulumi.Int(id))
}
envArgs.Reviewers = github.RepositoryEnvironmentReviewerArray{
&github.RepositoryEnvironmentReviewerArgs{
Users: users,
Teams: teams,
},
}
}
switch e.BranchPolicy {
case "protected":
envArgs.DeploymentBranchPolicy = &github.RepositoryEnvironmentDeploymentBranchPolicyArgs{
ProtectedBranches: pulumi.Bool(true),
CustomBranchPolicies: pulumi.Bool(false),
}
case "custom":
envArgs.DeploymentBranchPolicy = &github.RepositoryEnvironmentDeploymentBranchPolicyArgs{
ProtectedBranches: pulumi.Bool(false),
CustomBranchPolicies: pulumi.Bool(true),
}
case "none":
envArgs.DeploymentBranchPolicy = &github.RepositoryEnvironmentDeploymentBranchPolicyArgs{
ProtectedBranches: pulumi.Bool(false),
CustomBranchPolicies: pulumi.Bool(false),
}
}
envOpts := []pulumi.ResourceOption{}
if repoDep != nil {
envOpts = append(envOpts, pulumi.DependsOn([]pulumi.Resource{repoDep}))
}
env, err := github.NewRepositoryEnvironment(ctx, "env_"+sanitize(e.Name), envArgs, envOpts...)
if err != nil {
return fmt.Errorf("creating environment %q: %w", e.Name, err)
}
envByName[e.Name] = env
// Custom branch policies (one resource per pattern).
if e.BranchPolicy == "custom" {
for i, pat := range e.BranchPatterns {
_, err := github.NewRepositoryDeploymentBranchPolicy(ctx,
fmt.Sprintf("bp_%s_%d", sanitize(e.Name), i),
&github.RepositoryDeploymentBranchPolicyArgs{
Repository: repoName,
EnvironmentName: env.Environment,
Name: pulumi.String(pat),
},
pulumi.DependsOn([]pulumi.Resource{env}),
)
if err != nil {
return fmt.Errorf("creating branch policy %q for env %q: %w", pat, e.Name, err)
}
}
}
}
// --- Repo-level Actions secrets -----------------------------------
for i, s := range in.RepoSecrets {
_, err := github.NewActionsSecret(ctx, fmt.Sprintf("rs_%d", i), &github.ActionsSecretArgs{
Repository: repoName,
SecretName: pulumi.String(s.Name),
PlaintextValue: pulumi.String(s.Value),
})
if err != nil {
return fmt.Errorf("creating repo secret %q: %w", s.Name, err)
}
}
// --- Env-level Actions secrets ------------------------------------
for ei, ef := range in.EnvSecrets {
env, ok := envByName[ef.Env]
if !ok {
return fmt.Errorf("env-secrets target %q has no matching environment", ef.Env)
}
for si, s := range ef.Secrets {
_, err := github.NewActionsEnvironmentSecret(ctx,
fmt.Sprintf("es_%d_%d", ei, si),
&github.ActionsEnvironmentSecretArgs{
Repository: repoName,
Environment: env.Environment,
SecretName: pulumi.String(s.Name),
PlaintextValue: pulumi.String(s.Value),
},
pulumi.DependsOn([]pulumi.Resource{env}),
)
if err != nil {
return fmt.Errorf("creating env secret %q in %q: %w", s.Name, ef.Env, err)
}
}
}
// --- Outputs -------------------------------------------------------
ctx.Export("repository_full_name", pulumi.String(in.Owner+"/"+in.Repo))
envNames := make(pulumi.StringArray, 0, len(in.Environments))
for _, e := range in.Environments {
envNames = append(envNames, pulumi.String(e.Name))
}
ctx.Export("environments", envNames)
return nil
}
}
// buildRepoArgs constructs github.RepositoryArgs from RepoSettings, honoring
// create-only fields only when mode == RepoModeCreate.
func buildRepoArgs(name string, s cli.RepoSettings, mode cli.RepoMode) *github.RepositoryArgs {
args := &github.RepositoryArgs{
Name: pulumi.String(name),
}
if s.Visibility != "" {
args.Visibility = pulumi.String(s.Visibility)
}
if s.Description != nil {
args.Description = pulumi.String(*s.Description)
}
if s.DefaultBranch != "" {
args.DefaultBranch = pulumi.String(s.DefaultBranch)
}
if len(s.Topics) > 0 {
topics := make(pulumi.StringArray, 0, len(s.Topics))
for _, t := range s.Topics {
topics = append(topics, pulumi.String(t))
}
args.Topics = topics
}
if s.AllowMergeCommit != nil {
args.AllowMergeCommit = pulumi.Bool(*s.AllowMergeCommit)
}
if s.AllowSquashMerge != nil {
args.AllowSquashMerge = pulumi.Bool(*s.AllowSquashMerge)
}
if s.AllowRebaseMerge != nil {
args.AllowRebaseMerge = pulumi.Bool(*s.AllowRebaseMerge)
}
if s.DeleteBranchOnMerge != nil {
args.DeleteBranchOnMerge = pulumi.Bool(*s.DeleteBranchOnMerge)
}
if mode == cli.RepoModeCreate {
if s.AutoInit != nil {
args.AutoInit = pulumi.Bool(*s.AutoInit)
}
if s.LicenseTemplate != "" {
args.LicenseTemplate = pulumi.String(s.LicenseTemplate)
}
if s.GitignoreTemplate != "" {
args.GitignoreTemplate = pulumi.String(s.GitignoreTemplate)
}
}
return args
}
// toCamelBypassMode converts the snake_case CLI value to the camelCase the
// Pulumi GitHub provider expects (always | pullRequest).
func toCamelBypassMode(m string) string {
if m == "pull_request" {
return "pullRequest"
}
return m
}
// sanitize turns a name into a Pulumi-resource-name-safe slug.
func sanitize(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-':
out = append(out, c)
default:
out = append(out, '_')
}
}
return string(out)
}
+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
}
+307
View File
@@ -0,0 +1,307 @@
// Package runner wires CLI options to the Pulumi Automation API: it
// configures a local-filesystem-backed workspace, manages the per-state-dir
// secrets passphrase, resolves GITHUB_TOKEN, runs interactive prompts and
// reviewer resolution, and dispatches up/preview/destroy.
package runner
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"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/JMR-dev/gh-repo-bootstrap/internal/pulumiprog"
"github.com/JMR-dev/gh-repo-bootstrap/internal/secrets"
"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/common/tokens"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
const (
projectName = "gh-repo-bootstrap"
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 ---
if opts.ConfigFile != "" {
loaded, err := cli.LoadConfig(opts.ConfigFile)
if err != nil {
return err
}
opts = loaded
}
// --- --create interactive prompts (CLI path only) -------------------
if opts.RepoMode == cli.RepoModeCreate && opts.ConfigFile == "" {
if err := runCreatePrompts(opts); err != nil {
return err
}
}
// Final create-mode sanity check (works for both CLI and TOML paths).
if opts.RepoMode == cli.RepoModeCreate {
if opts.RepoSettings.Visibility == "" {
return errors.New("--create requires --visibility (or set [repo].visibility in config)")
}
}
stateDir := opts.StateDir
if stateDir == "" {
stateDir = cli.DefaultStateDir(opts.Owner, opts.Repo)
}
if err := os.MkdirAll(stateDir, 0o700); err != nil {
return fmt.Errorf("creating state dir: %w", err)
}
absStateDir, err := filepath.Abs(stateDir)
if err != nil {
return fmt.Errorf("resolving state dir: %w", err)
}
stateDir = absStateDir
// --- Auth: prefer caller-supplied GITHUB_TOKEN, else borrow from gh ---
if os.Getenv("GITHUB_TOKEN") == "" {
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")
}
_ = os.Setenv("GITHUB_TOKEN", strings.TrimSpace(string(out)))
}
// --- Passphrase for the local backend's secret encryption ------------
if err := ensurePassphrase(stateDir); err != nil {
return err
}
// --- Parse secret files ----------------------------------------------
var repoSecrets []secrets.Pair
if opts.RepoSecretsFile != "" {
ps, err := secrets.ParseFile(opts.RepoSecretsFile)
if err != nil {
return err
}
repoSecrets = ps
}
var envSecrets []secrets.EnvFile
if opts.EnvSecretsDir != "" {
envSet := make(map[string]struct{}, len(opts.Environments))
for _, e := range opts.Environments {
envSet[e.Name] = struct{}{}
}
es, err := secrets.LoadEnvDir(opts.EnvSecretsDir, envSet)
if err != nil {
return err
}
envSecrets = es
}
// --- Resolve reviewer identifiers ------------------------------------
resolver := githubapi.New()
resolvedEnvs := make([]pulumiprog.ResolvedEnv, 0, len(opts.Environments))
for _, e := range opts.Environments {
re := pulumiprog.ResolvedEnv{
Name: e.Name,
WaitTimer: e.WaitTimer,
PreventSelfReview: e.PreventSelfReview,
CanAdminsBypass: e.CanAdminsBypass,
BranchPolicy: e.BranchPolicy,
BranchPatterns: e.BranchPatterns,
}
for _, u := range e.ReviewerUsers {
id, err := resolver.ResolveUser(u)
if err != nil {
return err
}
re.ReviewerUserIDs = append(re.ReviewerUserIDs, id)
}
for _, t := range e.ReviewerTeams {
id, err := resolver.ResolveTeam(t)
if err != nil {
return err
}
re.ReviewerTeamIDs = append(re.ReviewerTeamIDs, id)
}
resolvedEnvs = append(resolvedEnvs, re)
}
// --- Pulumi workspace pointed at file://<stateDir> -------------------
backendURL := "file://" + stateDir
projectSettings := workspace.Project{
Name: tokens.PackageName(projectName),
Runtime: workspace.NewProjectRuntimeInfo("go", nil),
Backend: &workspace.ProjectBackend{URL: backendURL},
}
program := pulumiprog.Build(pulumiprog.Inputs{
Owner: opts.Owner,
Repo: opts.Repo,
Branch: opts.Branch,
Reviews: opts.Reviews,
Signed: opts.Signed,
RulesetName: opts.Ruleset,
Environments: resolvedEnvs,
Bypass: opts.Bypass,
RepoSecrets: repoSecrets,
EnvSecrets: envSecrets,
RepoMode: opts.RepoMode,
RepoSettings: opts.RepoSettings,
})
fmt.Printf(">>> Working directory: %s\n", stateDir)
if opts.RepoMode == cli.RepoModeManage {
fmt.Println(">>> --manage-repo: the first apply imports the existing repo into state.")
fmt.Println(">>> Run with --plan first to review the import + any drift reconciliation.")
}
stack, err := upsertStack(ctx, stackName, projectName, program,
auto.WorkDir(stateDir),
auto.EnvVars(map[string]string{
"PULUMI_BACKEND_URL": backendURL,
"PULUMI_CONFIG_PASSPHRASE": os.Getenv("PULUMI_CONFIG_PASSPHRASE"),
"PULUMI_SKIP_UPDATE_CHECK": "true",
}),
auto.Project(projectSettings),
)
if err != nil {
return fmt.Errorf("creating Pulumi stack: %w", err)
}
if err := stack.SetConfig(ctx, "github:owner", auto.ConfigValue{Value: opts.Owner}); err != nil {
return fmt.Errorf("setting github:owner config: %w", err)
}
switch opts.Action {
case cli.ActionApply:
_, err = stack.Up(ctx, optup.ProgressStreams(os.Stdout), optup.ErrorProgressStreams(os.Stderr))
case cli.ActionPlan:
_, err = stack.Preview(ctx, optpreview.ProgressStreams(os.Stdout), optpreview.ErrorProgressStreams(os.Stderr))
case cli.ActionDestroy:
_, err = stack.Destroy(ctx, optdestroy.ProgressStreams(os.Stdout), optdestroy.ErrorProgressStreams(os.Stderr))
default:
return fmt.Errorf("unknown action: %s", opts.Action)
}
if err != nil {
return err
}
if opts.RepoMode == cli.RepoModeCreate && opts.Action == cli.ActionApply {
if err := setGitRemoteAndPush(opts.Owner, opts.Repo); err != nil {
return err
}
}
return nil
}
// setGitRemoteAndPush wires the newly created GitHub repo as the "origin"
// remote of the current git working tree and pushes all local commits.
// If the working directory is not inside a git repo the step is skipped.
func setGitRemoteAndPush(owner, repo string) error {
if err := execCommand("git", "rev-parse", "--is-inside-work-tree").Run(); err != nil {
fmt.Println(">>> Not inside a git repository; skipping remote setup.")
return nil
}
remoteURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, repo)
fmt.Printf(">>> Setting git remote origin → %s\n", remoteURL)
if execCommand("git", "remote", "get-url", "origin").Run() == nil {
if out, err := execCommand("git", "remote", "set-url", "origin", remoteURL).CombinedOutput(); err != nil {
return fmt.Errorf("updating git remote: %s: %w", strings.TrimSpace(string(out)), err)
}
} else {
if out, err := execCommand("git", "remote", "add", "origin", remoteURL).CombinedOutput(); err != nil {
return fmt.Errorf("adding git remote: %s: %w", strings.TrimSpace(string(out)), err)
}
}
fmt.Println(">>> Pushing local commits to origin...")
if out, err := execCommand("git", "push", "-u", "origin", "HEAD").CombinedOutput(); err != nil {
return fmt.Errorf("pushing to remote: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// runCreatePrompts asks the user for any missing required --create values
// (visibility, description) when those weren't supplied on the command line.
func runCreatePrompts(opts *cli.Options) error {
needsVisibility := opts.RepoSettings.Visibility == ""
needsDescription := opts.RepoSettings.Description == nil
if !needsVisibility && !needsDescription {
return nil
}
if !prompt.IsInteractive() {
return prompt.ErrNotInteractive
}
p := prompt.New()
fmt.Fprintf(os.Stderr, ">>> Creating %s/%s\n", opts.Owner, opts.Repo)
if needsVisibility {
v, err := p.Choice("Visibility? [public/private] (default: private): ",
"private", []string{"public", "private"})
if err != nil {
return err
}
opts.RepoSettings.Visibility = v
}
if needsDescription {
d, err := p.Line("Description (optional): ")
if err != nil {
return err
}
opts.RepoSettings.Description = &d
}
return nil
}
// ensurePassphrase makes sure PULUMI_CONFIG_PASSPHRASE is set for this process,
// auto-generating and persisting one at <stateDir>/.passphrase if needed.
func ensurePassphrase(stateDir string) error {
if os.Getenv("PULUMI_CONFIG_PASSPHRASE") != "" || os.Getenv("PULUMI_CONFIG_PASSPHRASE_FILE") != "" {
return nil
}
p := filepath.Join(stateDir, ".passphrase")
b, err := os.ReadFile(p)
if err == nil {
_ = os.Setenv("PULUMI_CONFIG_PASSPHRASE", strings.TrimRight(string(b), "\r\n"))
return nil
}
if !os.IsNotExist(err) {
return fmt.Errorf("reading passphrase file: %w", err)
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return fmt.Errorf("generating passphrase: %w", err)
}
pass := hex.EncodeToString(raw)
if err := os.WriteFile(p, []byte(pass+"\n"), 0o600); err != nil {
return fmt.Errorf("writing passphrase file: %w", err)
}
_ = os.Setenv("PULUMI_CONFIG_PASSPHRASE", pass)
return nil
}
+604
View File
@@ -0,0 +1,604 @@
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]
switch command {
case "gh":
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 gh command: %s\n", subCmd)
os.Exit(2)
}
case "git":
if os.Getenv("MOCK_GIT_ENABLED") != "1" {
fmt.Fprintf(os.Stderr, "git not mocked in this test\n")
os.Exit(2)
}
subCmd := args[1]
switch subCmd {
case "rev-parse":
// success — we're in a mock git repo
case "remote":
if len(args) >= 3 && args[2] == "get-url" {
// Simulate "no origin" by default; set MOCK_GIT_ORIGIN_EXISTS=1 to override.
if os.Getenv("MOCK_GIT_ORIGIN_EXISTS") != "1" {
os.Exit(1)
}
}
// add / set-url: exit 0 (success)
case "push":
// success
default:
fmt.Fprintf(os.Stderr, "unknown git subcommand: %s\n", subCmd)
os.Exit(2)
}
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", command)
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
}
// mockExecWithGit is like mockExec but also enables the git mock.
func mockExecWithGit(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", "MOCK_GIT_ENABLED=1")
return cmd
}
// makeRecordingExec wraps a delegate exec function and records every call.
func makeRecordingExec(delegate func(string, ...string) *exec.Cmd) (func(string, ...string) *exec.Cmd, *[][]string) {
calls := &[][]string{}
return func(name string, args ...string) *exec.Cmd {
*calls = append(*calls, append([]string{name}, args...))
return delegate(name, args...)
}, calls
}
// 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
}
// TestRun_RelativeStateDir verifies that a relative state_dir (e.g. "./state"
// from a TOML config) does not cause a "state/state" double-path in the
// file:// backend URL. Before the fix, upsertStack was called with a relative
// file:// URL that Pulumi resolved against its WorkDir, producing the wrong path.
func TestRun_RelativeStateDir(t *testing.T) {
oldExec := execCommand
oldGithubExec := githubapi.ExecCommand
oldUpsert := upsertStack
execCommand = mockExec
githubapi.ExecCommand = mockExec
defer func() {
execCommand = oldExec
githubapi.ExecCommand = oldGithubExec
upsertStack = oldUpsert
}()
// Capture the PULUMI_BACKEND_URL env var passed to upsertStack.
var capturedBackendURL string
mStack := &mockStack{setConfigCalls: make(map[string]string)}
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
// Apply options to a scratch workspace to extract env vars.
ws, err := auto.NewLocalWorkspace(ctx, opts...)
if err == nil {
env := ws.GetEnvVars()
capturedBackendURL = env["PULUMI_BACKEND_URL"]
}
return mStack, nil
}
parent := t.TempDir()
oldWd, _ := os.Getwd()
_ = os.Chdir(parent)
defer os.Chdir(oldWd)
opts := &cli.Options{
Owner: "JMR-dev",
Repo: "test-repo",
Branch: "main",
Action: cli.ActionApply,
StateDir: "./state",
Environments: []*cli.EnvSpec{{Name: "production"}},
}
oldToken := os.Getenv("GITHUB_TOKEN")
os.Setenv("GITHUB_TOKEN", "test-token")
defer os.Setenv("GITHUB_TOKEN", oldToken)
if err := Run(context.Background(), opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "file://" + filepath.Join(parent, "state")
if capturedBackendURL != want {
t.Errorf("PULUMI_BACKEND_URL = %q, want %q", capturedBackendURL, want)
}
}
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)
}
}
func TestRun_CreateMode_SetsRemoteAndPushes(t *testing.T) {
oldExec := execCommand
oldGithubExec := githubapi.ExecCommand
oldUpsert := upsertStack
defer func() {
execCommand = oldExec
githubapi.ExecCommand = oldGithubExec
upsertStack = oldUpsert
}()
githubapi.ExecCommand = mockExec
recorder, calls := makeRecordingExec(mockExecWithGit)
execCommand = recorder
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()
oldToken := os.Getenv("GITHUB_TOKEN")
os.Setenv("GITHUB_TOKEN", "test-token")
defer os.Setenv("GITHUB_TOKEN", oldToken)
desc := "test repo"
opts := &cli.Options{
Owner: "JMR-dev",
Repo: "new-repo",
Branch: "main",
Action: cli.ActionApply,
RepoMode: cli.RepoModeCreate,
StateDir: stateDir,
RepoSettings: cli.RepoSettings{
Visibility: "private",
Description: &desc,
},
}
if err := Run(context.Background(), opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the expected git command sequence was issued.
wantRemoteURL := "https://github.com/JMR-dev/new-repo.git"
checkCmd := func(want []string) {
t.Helper()
for _, c := range *calls {
if len(c) == len(want) {
match := true
for i := range want {
if c[i] != want[i] {
match = false
break
}
}
if match {
return
}
}
}
t.Errorf("expected command %v not found in recorded calls: %v", want, *calls)
}
checkCmd([]string{"git", "rev-parse", "--is-inside-work-tree"})
checkCmd([]string{"git", "remote", "get-url", "origin"})
checkCmd([]string{"git", "remote", "add", "origin", wantRemoteURL})
checkCmd([]string{"git", "push", "-u", "origin", "HEAD"})
}
func TestRun_NonCreateMode_NoRemoteSetup(t *testing.T) {
oldExec := execCommand
oldUpsert := upsertStack
defer func() {
execCommand = oldExec
upsertStack = oldUpsert
}()
recorder, calls := makeRecordingExec(mockExec)
execCommand = recorder
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()
oldToken := os.Getenv("GITHUB_TOKEN")
os.Setenv("GITHUB_TOKEN", "test-token")
defer os.Setenv("GITHUB_TOKEN", oldToken)
opts := &cli.Options{
Owner: "JMR-dev",
Repo: "existing-repo",
Branch: "main",
Action: cli.ActionApply,
RepoMode: cli.RepoModeData,
StateDir: stateDir,
}
if err := Run(context.Background(), opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, c := range *calls {
if len(c) > 0 && c[0] == "git" {
t.Errorf("expected no git commands for non-create mode, got: %v", c)
}
}
}
+179
View File
@@ -0,0 +1,179 @@
// Package secrets parses tfvars-style files of `NAME = "value"` lines into
// GitHub Actions secret name/value pairs.
package secrets
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Pair is a single parsed secret.
type Pair struct {
Name string
Value string
}
// EnvFile groups secrets parsed from one <env>.tfvars file.
type EnvFile struct {
Env string
Source string
Secrets []Pair
}
var (
nameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
lineRe = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=[[:space:]]*(.*)$`)
envRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
)
// IsValidGitHubSecretName reports whether n is a valid GitHub Actions secret
// name (alphanumerics + underscore, no leading digit, no GITHUB_ prefix).
func IsValidGitHubSecretName(n string) bool {
if !nameRe.MatchString(n) {
return false
}
if strings.HasPrefix(n, "GITHUB_") {
return false
}
return true
}
// ParseFile reads a tfvars-style secrets file and returns the parsed pairs in
// declaration order. Comments (# or //) and blank lines are ignored. Values
// may be double-quoted (with `\\ \" \n \r \t` escapes) or single-quoted (raw).
// Duplicate names within a single file are an error.
func ParseFile(path string) ([]Pair, 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 pairs []Pair
seen := map[string]struct{}{}
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineno := 0
for scanner.Scan() {
lineno++
line := strings.TrimRight(scanner.Text(), "\r")
line = strings.TrimLeft(line, " \t")
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
m := lineRe.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")
val, err := decodeValue(rest)
if err != nil {
return nil, fmt.Errorf("%s:%d: %v", path, lineno, err)
}
if !IsValidGitHubSecretName(name) {
return nil, fmt.Errorf("%s:%d: invalid GitHub secret name %q (alphanumerics + underscore, no leading digit, no GITHUB_ prefix)", path, lineno, name)
}
if _, dup := seen[name]; dup {
return nil, fmt.Errorf("duplicate secret %q in %s", name, path)
}
seen[name] = struct{}{}
pairs = append(pairs, Pair{Name: name, Value: val})
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
if len(pairs) == 0 {
return nil, fmt.Errorf("%s: no secrets found", path)
}
return pairs, nil
}
func decodeValue(rest string) (string, error) {
if len(rest) >= 2 && rest[0] == '"' && rest[len(rest)-1] == '"' {
inner := rest[1 : len(rest)-1]
return decodeDoubleQuoted(inner), nil
}
if len(rest) >= 2 && rest[0] == '\'' && rest[len(rest)-1] == '\'' {
return rest[1 : len(rest)-1], nil
}
return "", fmt.Errorf("value must be a single quoted string")
}
func decodeDoubleQuoted(s string) string {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c == '\\' && i+1 < len(s) {
switch s[i+1] {
case '\\':
b.WriteByte('\\')
case '"':
b.WriteByte('"')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
default:
b.WriteByte(c)
b.WriteByte(s[i+1])
}
i++
continue
}
b.WriteByte(c)
}
return b.String()
}
// LoadEnvDir loads every *.tfvars file in dir. Each basename (without
// extension) must be one of envSet. The list is sorted by env name so output
// is deterministic.
func LoadEnvDir(dir string, envSet map[string]struct{}) ([]EnvFile, error) {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return nil, fmt.Errorf("env-secrets dir not found: %s", dir)
}
matches, err := filepath.Glob(filepath.Join(dir, "*.tfvars"))
if err != nil {
return nil, err
}
if len(matches) == 0 {
return nil, fmt.Errorf("no *.tfvars files in %s", dir)
}
sort.Strings(matches)
declared := make([]string, 0, len(envSet))
for e := range envSet {
declared = append(declared, e)
}
sort.Strings(declared)
out := make([]EnvFile, 0, len(matches))
for _, ef := range matches {
base := strings.TrimSuffix(filepath.Base(ef), ".tfvars")
if !envRe.MatchString(base) {
return nil, fmt.Errorf("invalid env name derived from filename: %s (basename must match [A-Za-z][A-Za-z0-9_-]*)", ef)
}
if _, ok := envSet[base]; !ok {
return nil, fmt.Errorf("env-secrets file %q targets env %q which is not in --env list (%s)", ef, base, strings.Join(declared, " "))
}
pairs, err := ParseFile(ef)
if err != nil {
return nil, err
}
out = append(out, EnvFile{Env: base, Source: ef, Secrets: pairs})
}
return out, nil
}
+108
View File
@@ -0,0 +1,108 @@
package secrets
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return p
}
func TestParseFile_basics(t *testing.T) {
dir := t.TempDir()
p := writeFile(t, dir, "s.tfvars", `
# a comment
// another
API_TOKEN = "ghp_xyz"
WEBHOOK = 'raw\nvalue'
MULTI = "line1\nline2\twith\\backslash and \"quote\""
`)
got, err := ParseFile(p)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
want := []Pair{
{"API_TOKEN", "ghp_xyz"},
{"WEBHOOK", `raw\nvalue`}, // single-quoted is raw
{"MULTI", "line1\nline2\twith\\backslash and \"quote\""},
}
if len(got) != len(want) {
t.Fatalf("len=%d want %d: %#v", len(got), len(want), got)
}
for i := range got {
if got[i] != want[i] {
t.Errorf("[%d] = %#v want %#v", i, got[i], want[i])
}
}
}
func TestParseFile_errors(t *testing.T) {
dir := t.TempDir()
cases := []struct {
name, body, want string
}{
{"bad-name", `1FOO = "x"`, "cannot parse"},
{"github-prefix", `GITHUB_TOKEN = "x"`, "invalid GitHub secret name"},
{"unquoted", `FOO = bar`, "value must be a single quoted string"},
{"duplicate", "FOO = \"a\"\nFOO = \"b\"\n", "duplicate secret"},
{"empty", "# nothing\n", "no secrets found"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := writeFile(t, dir, tc.name+".tfvars", tc.body)
_, err := ParseFile(p)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err=%v want contains %q", err, tc.want)
}
})
}
}
func TestLoadEnvDir(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "production.tfvars", `DB = "prodpw"`+"\n")
writeFile(t, dir, "staging.tfvars", `DB = "stagepw"`+"\n")
set := map[string]struct{}{"production": {}, "staging": {}}
envs, err := LoadEnvDir(dir, set)
if err != nil {
t.Fatal(err)
}
if len(envs) != 2 || envs[0].Env != "production" || envs[1].Env != "staging" {
t.Fatalf("envs=%#v", envs)
}
}
func TestLoadEnvDir_unknownEnv(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "qa.tfvars", `DB = "x"`+"\n")
_, err := LoadEnvDir(dir, map[string]struct{}{"production": {}})
if err == nil || !strings.Contains(err.Error(), "not in --env list") {
t.Fatalf("err=%v", err)
}
}
func TestIsValidGitHubSecretName(t *testing.T) {
for _, c := range []struct {
in string
ok bool
}{
{"OK_NAME_1", true},
{"_underscore", true},
{"1bad", false},
{"GITHUB_TOKEN", false},
{"has space", false},
{"", false},
} {
if got := IsValidGitHubSecretName(c.in); got != c.ok {
t.Errorf("%q: got %v want %v", c.in, got, c.ok)
}
}
}
+20 -792
View File
@@ -1,805 +1,33 @@
// gh-repo-bootstrap: apply standard branch protection + environments to a
// GitHub repository, using the bundled OpenTofu `repo` module.
// gh-repo-bootstrap applies a standard branch-protection ruleset, a set of
// deployment environments, and optional GitHub Actions secrets to an existing
// GitHub repository, using Pulumi as the configuration engine.
//
// Installed as a gh extension, invoked as: gh repo-bootstrap <owner/repo> [opts]
// Installed as a `gh` extension and invoked as:
//
// gh repo-bootstrap <owner/repo> [options]
package main
import (
"bufio"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"github.com/JMR-dev/gh-repo-bootstrap/internal/cli"
"github.com/JMR-dev/gh-repo-bootstrap/internal/runner"
)
//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)
opts, err := cli.Parse(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "gh-repo-bootstrap:", err)
cli.PrintUsage()
os.Exit(1)
}
if opts == nil {
return // --help
}
if err := runner.Run(context.Background(), opts); err != nil {
fmt.Fprintln(os.Stderr, "gh-repo-bootstrap:", 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
@@ -1,304 +0,0 @@
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")
}
}
-24
View File
@@ -1,24 +0,0 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/integrations/github" {
version = "6.12.1"
constraints = "~> 6.2"
hashes = [
"h1:bGz4LIep/7PVrqy6P8cTYbAJpdxXGrupUJjkCczlzIs=",
"zh:3e1a4081ecb9518fdf0074db83c16ad00dc81ffe8249a6e3cf1894e947e28df6",
"zh:4cb8224b7f530795b674ac044675f6b22a7c9154f55eb9f76c5af6c7534056a4",
"zh:560bc08637926191f6871a89e986022ca67c70afda5bebca34b5216e6fac69c9",
"zh:5a70b5d2ac650c5c9819a1875411ebda229d0fcc6c9f57f9d751852ca3cd77ac",
"zh:8668d93bd4dc2ffa2545e1473af600a925d479b16033a71a4498a16f3b683c0c",
"zh:86eacc6059fd057948e178b665ba5cce74bd5488a9e1035734e60ff5ef1b6f8f",
"zh:a329fac98881d8dfc211a9bdc0ec6f2948f0b0c2704d1b6cbe5307403c7ad1b2",
"zh:dadd44abab3c52b9d572955afaef1658790e17ea355ee22b58996d81d28e02d8",
"zh:de9f455ef342cc38fb76bce844bfcd376fb81a4b9f9bc2fae023ff99efdf1338",
"zh:f8c6d2e8351b334491790358574e0a30a7c6d7f5b80f7daf32a7c0f3e9b1ab19",
"zh:fab41971a3edee04ab6eceaeab4eeb9a2b2f38a2af3b06eda93e2117b64994be",
"zh:fb1279b566dd9c8c117b2e4e0cc8344413b8fc8f2a3e24be22a9b2610551777b",
"zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25",
"zh:fe79d2a861fb9af420fa5bd7f02c031b2a0a3edf5dbc46022c8ecc7a33cf2b6d",
]
}
-66
View File
@@ -1,66 +0,0 @@
# `repo` module
Reusable OpenTofu module that applies a standard set of repository
guard-rails to an existing GitHub repository:
- A branch protection ruleset on the default branch
- A configurable set of deployment environments
The module does **not** create the repository — it only manages
protection + environments on a repo that already exists.
## Inputs
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `repo_owner` | string | — | GitHub user/org that owns the repo |
| `repo_name` | string | — | Repository name (no owner prefix) |
| `default_branch` | string | `"main"` | Branch to protect |
| `required_reviews` | number | `1` | Required PR approving reviews |
| `require_signed_commits` | bool | `false` | Enforce signed commits |
| `environments` | list(string) | `[]` | Environments to ensure exist |
| `ruleset_name` | string | `"default-branch-protection"` | Ruleset name |
| `bypass_actors` | list(object) | `[]` | Actors allowed to bypass the ruleset (see below) |
### `bypass_actors`
Each entry is an object:
```hcl
{
actor_id = 5 # numeric (built-in roles: 1=read 2=triage 3=write 4=maintain 5=admin)
actor_type = "RepositoryRole" # RepositoryRole | Team | Integration | OrganizationAdmin | DeployKey
bypass_mode = "always" # "always" or "pull_request"
}
```
Most useful entry for solo maintainers:
```hcl
{ actor_id = 5, actor_type = "RepositoryRole", bypass_mode = "always" }
```
…which lets the repo Admin (you) merge your own PRs even though
`required_reviews >= 1`.
## Provider
The caller is responsible for configuring the `github` provider (owner +
auth). The module only declares `required_providers`.
## Example
```hcl
provider "github" {
owner = "JMR-dev"
}
module "repo" {
source = "git::https://github.com/JMR-dev/gh-repo-bootstrap.git//modules/repo?ref=main"
repo_owner = "JMR-dev"
repo_name = "my-new-project"
required_reviews = 1
environments = ["production", "staging"]
}
```
-47
View File
@@ -1,47 +0,0 @@
data "github_repository" "this" {
name = var.repo_name
}
resource "github_repository_ruleset" "default_branch" {
repository = data.github_repository.this.name
name = var.ruleset_name
target = "branch"
enforcement = "active"
conditions {
ref_name {
include = ["refs/heads/${var.default_branch}"]
exclude = []
}
}
dynamic "bypass_actors" {
for_each = var.bypass_actors
content {
actor_id = bypass_actors.value.actor_id
actor_type = bypass_actors.value.actor_type
bypass_mode = bypass_actors.value.bypass_mode
}
}
rules {
deletion = true
non_fast_forward = true
required_signatures = var.require_signed_commits
pull_request {
required_approving_review_count = var.required_reviews
dismiss_stale_reviews_on_push = true
require_code_owner_review = false
require_last_push_approval = false
required_review_thread_resolution = true
}
}
}
resource "github_repository_environment" "envs" {
for_each = toset(var.environments)
repository = data.github_repository.this.name
environment = each.value
}
-24
View File
@@ -1,24 +0,0 @@
output "repository_full_name" {
value = data.github_repository.this.full_name
description = "Full name (owner/repo) of the repository being managed."
}
output "ruleset_id" {
value = github_repository_ruleset.default_branch.id
description = "ID of the branch protection ruleset."
}
output "environments" {
value = sort([for e in github_repository_environment.envs : e.environment])
description = "Environments managed by this configuration."
}
output "environments_by_name" {
value = github_repository_environment.envs
description = "Map of environment name => github_repository_environment resource, exported so callers can express dependencies on a specific env (e.g. environment-scoped secrets)."
}
output "repository_name" {
value = data.github_repository.this.name
description = "Repository short name (without owner)."
}
-57
View File
@@ -1,57 +0,0 @@
variable "repo_owner" {
description = "GitHub user or organization that owns the repository."
type = string
}
variable "repo_name" {
description = "Repository name (without owner prefix)."
type = string
}
variable "default_branch" {
description = "Branch protected by the ruleset."
type = string
default = "main"
}
variable "required_reviews" {
description = "Number of required approving reviews on PRs targeting the default branch."
type = number
default = 1
}
variable "require_signed_commits" {
description = "Require signed commits on the protected branch."
type = bool
default = false
}
variable "environments" {
description = "List of GitHub deployment environments to ensure exist."
type = list(string)
default = []
}
variable "ruleset_name" {
description = "Name to give the branch protection ruleset."
type = string
default = "default-branch-protection"
}
variable "bypass_actors" {
description = <<-EOT
Actors permitted to bypass the ruleset. Each entry needs:
- actor_id: numeric ID (for built-in repo roles: 1=read, 2=triage,
3=write, 4=maintain, 5=admin)
- actor_type: one of RepositoryRole, Team, Integration,
OrganizationAdmin, DeployKey
- bypass_mode: "always" or "pull_request"
EOT
type = list(object({
actor_id = number
actor_type = string
bypass_mode = string
}))
default = []
}
-10
View File
@@ -1,10 +0,0 @@
terraform {
required_version = ">= 1.8.0"
required_providers {
github = {
source = "integrations/github"
version = "~> 6.2"
}
}
}
-604
View File
@@ -1,604 +0,0 @@
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)
}
})
}
}