Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e230791b4 | ||
|
|
61eacd00a4 | ||
|
|
62016d9189 | ||
|
|
47f0290652 | ||
|
|
1e41904491 | ||
|
|
b983e601e8 | ||
|
|
04847ebebc | ||
|
|
b60295a7f1 | ||
|
|
53e4146886 | ||
|
|
5d5cc2a0b0 | ||
|
|
43ab4fcf34 | ||
|
|
de186b6264 | ||
|
|
35746cc781 | ||
|
|
759bbac686 | ||
|
|
5187788f87 |
@@ -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/*
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
Generated
-24
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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 = []
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
terraform {
|
||||
required_version = ">= 1.8.0"
|
||||
|
||||
required_providers {
|
||||
github = {
|
||||
source = "integrations/github"
|
||||
version = "~> 6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
-604
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user