Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,42 @@ EOF
env-sync --env .env.production
```

### `--environments` で書き込み先環境を限定する

`env-sync.yaml` 1 本のまま、同期実行時に `--environments` で書き込み先を絞り込めます。

```yaml
# env-sync.yaml
variables:
DATABASE_URL:
secret: true
environments: [production, staging, preview]
API_KEY:
secret: true
environments: [production]
DEBUG_MODE:
secret: false
environments: [staging, preview]
```

```bash
# production のみに同期(environments に production が含まれない変数はスキップ)
env-sync --env .env.production --def env-sync.yaml --environments production

# staging と preview のみに同期
env-sync --env .env.staging --def env-sync.yaml --environments staging,preview

# prune との組み合わせ(削除スコープも staging に限定)
env-sync --env .env.staging --def env-sync.yaml --environments staging --prune
```

- 変数の `environments` と `--environments` の積集合が空になる変数はスキップされます(stderr に警告を出力)。
- 定義ファイルで `environments` を**宣言していない**変数も `--environments` 指定時はスキップされます(積集合を取れる宣言環境が存在しないため)。
- **全変数がスキップされた場合**、どのプロバイダーへの送信も行われず、prune も実行されずに exit 0 で正常終了します。
- `--environments` に指定できる名前は、定義ファイルの `environments` フィールドに存在する値、または標準の `production` / `preview` / `development` です。不正な名前が含まれる場合はエラーで終了(exit 1)します。
- 環境名は**大文字小文字を区別します** — `env-sync.yaml` の宣言と完全に一致する必要があります。
- **GCP**: GCP Secret に環境スコープの概念はありません。フィルタを通過した変数は引き続き同期され、`managed-by=env-sync` ラベルが付与・更新される副作用があります。`--prune` と組み合わせた場合、GCP の prune スコープは `--environments` で絞られません(`managed-by=env-sync` ラベル付き Secret が全件 prune 対象になります)。

### 環境変数参照 `${VAR}` / `${VAR:-default}`

config の値に環境変数参照を書くことができます(平文トークンを config ファイルに書きたくない場合に便利です)。
Expand Down Expand Up @@ -446,6 +482,7 @@ variables:
| `--dry-run` | – | 送信せず新規/更新の区別を含む登録予定一覧を表示 |
| `--yes` / `-y` | – | 更新(上書き)がある場合の確認をスキップ |
| `--prune` | – | 定義ファイルに無いリモートの変数を削除(定義ファイルの `prune: true` でも有効化可) |
| `--environments <list>` | – | 書き込み先環境をカンマ区切りで指定(例 `staging,preview`)。各変数の `environments` との積集合が空の変数はスキップ。`--prune` と併用すると削除スコープも指定環境に限定される |
| `--force` | – | `init` 時に既存の def ファイルを上書きする |
| `VERCEL_TOKEN` | ◯(Vercel) | Vercel アクセストークン(dry-run 時は不要) |
| `VERCEL_PROJECT_ID` | △(Vercel) | プロジェクト ID。未指定なら config ファイルまたは `.vercel/project.json` から自動取得 |
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,42 @@ EOF
env-sync --env .env.production
```

### Using `--environments` to Scope Writes to Specific Environments

With a single `env-sync.yaml` definition file, you can target different environments at sync time using `--environments`.

```yaml
# env-sync.yaml
variables:
DATABASE_URL:
secret: true
environments: [production, staging, preview]
API_KEY:
secret: true
environments: [production]
DEBUG_MODE:
secret: false
environments: [staging, preview]
```

```bash
# Sync only to production (variables with no 'production' in environments are skipped)
env-sync --env .env.production --def env-sync.yaml --environments production

# Sync only to staging and preview
env-sync --env .env.staging --def env-sync.yaml --environments staging,preview

# Prune + limit deletion scope to staging only
env-sync --env .env.staging --def env-sync.yaml --environments staging --prune
```

- Variables whose declared `environments` have **no overlap** with `--environments` are skipped (warning to stderr).
- Variables that have **no `environments` declared** in the definition file are also skipped when `--environments` is specified (they have no declared environments to intersect with).
- If **all variables are skipped**, no changes are sent to any provider and prune is also skipped — the tool exits successfully with code 0.
- `--environments` must contain names that appear in `environments` fields of the definition file, or the standard values `production` / `preview` / `development`. An invalid name causes an error and exits with code 1.
- Environment names are **case-sensitive** — they must match the declaration in `env-sync.yaml` exactly.
- **GCP**: GCP Secrets have no per-environment scope. Variables that pass the filter will still have the `managed-by=env-sync` label applied/updated as a side effect of syncing. When `--prune` is combined with `--environments`, GCP prune scope is not narrowed (all `managed-by=env-sync` secrets are considered for pruning).

### Environment Variable References `${VAR}` / `${VAR:-default}`

Config values can include environment variable references (useful for avoiding plaintext tokens in config files).
Expand Down Expand Up @@ -446,6 +482,7 @@ Safety behavior:
| `--dry-run` | – | Show planned registrations with new/update classification without sending |
| `--yes` / `-y` | – | Skip confirmation when updates (overwrites) exist |
| `--prune` | – | Delete remote variables not in the definition file (also enabled by `prune: true` in the definition file) |
| `--environments <list>` | – | Comma-separated list of environments to write to (e.g. `staging,preview`). Intersects with each variable's declared `environments`; variables with no overlap are skipped. When combined with `--prune`, deletion scope is also limited to the specified environments |
| `--force` | – | Overwrite existing def file during `init` |
| `VERCEL_TOKEN` | Yes (Vercel) | Vercel access token (not required for dry-run) |
| `VERCEL_PROJECT_ID` | Conditional (Vercel) | Project ID. Auto-detected from config file or `.vercel/project.json` if unset |
Expand Down
56 changes: 56 additions & 0 deletions cmd/env-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,15 @@
// --dry-run 実際には送信せず、新規/更新の区別を含む登録予定一覧を表示(値は出さない)
// --yes, -y 更新(上書き)を含む場合の確認をスキップして送信
// --prune 定義ファイルに無いリモートの変数を削除する(定義ファイルの prune: true でも有効化可)
// --environments <list> 書き込み先環境をカンマ区切りで絞り込む(例: staging,preview)
package main

import (
"fmt"
"os"
"runtime/debug"
"sort"
"strings"

"gopkg.in/yaml.v3"

Expand Down Expand Up @@ -264,6 +267,25 @@ func run() error {
return fmt.Errorf("%s", i18n.T(i18n.MsgDefFileYAMLFail, err))
}

// ---- --environments バリデーション(def パース後。def の Custom Environment 名を許容値に含める) ----
if len(opts.Environments) > 0 {
allowed := collectAllowedEnvironments(def)
var invalid []string
for _, e := range opts.Environments {
if !allowed[e] {
invalid = append(invalid, e)
}
}
if len(invalid) > 0 {
allowedSlice := make([]string, 0, len(allowed))
for k := range allowed {
allowedSlice = append(allowedSlice, k)
}
sort.Strings(allowedSlice)
return fmt.Errorf("%s", i18n.T(i18n.MsgInvalidEnvironmentsFlag, strings.Join(invalid, ", "), strings.Join(allowedSlice, ", ")))
}
}

// ---- prune の解決(--prune フラグ または 定義ファイルの prune: true) ----
if def.Prune {
opts.Prune = true
Expand Down Expand Up @@ -299,6 +321,16 @@ func run() error {
return err
}

// ---- 環境フィルタ(--environments 指定時)----
// DefinedKeys は積集合でフィルタせず全キーを渡す(スキップ変数が prune で誤削除されないため)。
if len(opts.Environments) > 0 {
var skipped []string
entries, skipped = internalsync.FilterEntriesByEnvironments(entries, opts.Environments)
for _, k := range skipped {
fmt.Fprint(os.Stderr, i18n.T(i18n.MsgSkipNoMatchingEnvironment, k))
}
}

// ---- プロバイダーごとに振り分け ----
providerEntries := map[string][]provider.Entry{}
for _, e := range entries {
Expand Down Expand Up @@ -331,3 +363,27 @@ func run() error {
func printUsage() {
fmt.Fprint(os.Stderr, i18n.T(i18n.MsgUsage))
}

// collectAllowedEnvironments は def から --environments バリデーション用の許容環境名セットを返す。
// production / preview / development は常に許容される。
// def.Defaults.Environments と各変数の environments に現れる名前(Custom Environment 名)も許容する。
func collectAllowedEnvironments(def config.Definition) map[string]bool {
allowed := map[string]bool{
"production": true,
"preview": true,
"development": true,
}
for _, e := range def.Defaults.Environments {
if t := strings.TrimSpace(e); t != "" {
allowed[t] = true
}
}
for _, vc := range def.Variables {
for _, e := range vc.Environments {
if t := strings.TrimSpace(e); t != "" {
allowed[t] = true
}
}
}
return allowed
}
106 changes: 106 additions & 0 deletions cmd/env-sync/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"os/exec"
"strings"
"testing"

"github.com/ptyhard/env-sync/internal/config"
)

// --version フラグの統合テスト(バイナリをビルドして実行)
Expand Down Expand Up @@ -100,6 +102,110 @@ func TestHelpFlag_ExitsZero(t *testing.T) {
}
}

func TestHelpFlag_IncludesEnvironmentsOption(t *testing.T) {
bin := t.TempDir() + "/env-sync-test"
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("ビルド失敗: %s\n%s", err, out)
}

cmd := exec.Command(bin, "--help")
out, _ := cmd.CombinedOutput() // --help は exit 0 だが念のため err を無視
if !strings.Contains(string(out), "--environments") {
t.Errorf("--help 出力に --environments が含まれない:\n%s", string(out))
}
}

// ---- collectAllowedEnvironments のユニットテスト ----

// TestCollectAllowedEnvironments_StandardsAlwaysAllowed は production/preview/development が
// def の内容に関わらず常に許容集合に含まれることを検証する。
func TestCollectAllowedEnvironments_StandardsAlwaysAllowed(t *testing.T) {
def := config.Definition{} // 空の定義
allowed := collectAllowedEnvironments(def)
for _, name := range []string{"production", "preview", "development"} {
if !allowed[name] {
t.Errorf("標準環境 %q が許容集合に含まれない", name)
}
}
}

// TestCollectAllowedEnvironments_IncludesDefaultsEnvironments は
// defaults.environments に宣言した名前(Custom Environment 名)が許容集合に含まれることを検証する。
func TestCollectAllowedEnvironments_IncludesDefaultsEnvironments(t *testing.T) {
def := config.Definition{}
def.Defaults.Environments = []string{"staging", "canary"}
allowed := collectAllowedEnvironments(def)
for _, name := range []string{"staging", "canary"} {
if !allowed[name] {
t.Errorf("defaults.environments の %q が許容集合に含まれない", name)
}
}
}

// TestCollectAllowedEnvironments_IncludesVariableEnvironments は
// 各変数の environments に宣言した名前が許容集合に含まれることを検証する。
func TestCollectAllowedEnvironments_IncludesVariableEnvironments(t *testing.T) {
def := config.Definition{
Variables: map[string]config.VarConf{
"DB_URL": {Environments: []string{"production", "staging"}},
"DEBUG": {Environments: []string{"preview"}},
},
}
allowed := collectAllowedEnvironments(def)
for _, name := range []string{"staging", "production", "preview"} {
if !allowed[name] {
t.Errorf("variables の environments の %q が許容集合に含まれない", name)
}
}
}

// TestCollectAllowedEnvironments_UnknownName_NotInAllowed は
// 定義ファイルにも標準3種にも無い名前が許容集合に含まれないことを検証する。
func TestCollectAllowedEnvironments_UnknownName_NotInAllowed(t *testing.T) {
def := config.Definition{
Variables: map[string]config.VarConf{
"KEY": {Environments: []string{"staging"}},
},
}
allowed := collectAllowedEnvironments(def)
if allowed["totally-unknown"] {
t.Error("定義に無い環境名 \"totally-unknown\" が許容集合に含まれてはならない")
}
}

// TestEnvironmentsFlag_InvalidName_ErrorsOut は --environments に宣言外の名前を指定すると
// exit 1 かつ stderr にエラーメッセージが出ることをバイナリ統合テストで検証する。
func TestEnvironmentsFlag_InvalidName_ErrorsOut(t *testing.T) {
bin := t.TempDir() + "/env-sync-test"
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("ビルド失敗: %s\n%s", err, out)
}

dir := t.TempDir()
envFile := dir + "/.env"
defFile := dir + "/env-sync.yaml"
if err := os.WriteFile(envFile, []byte("DB_URL=postgres://localhost/db\n"), 0600); err != nil {
t.Fatal(err)
}
// staging のみ宣言。totally-unknown は宣言外。
defContent := "variables:\n DB_URL:\n secret: true\n environments: [staging]\n"
if err := os.WriteFile(defFile, []byte(defContent), 0600); err != nil {
t.Fatal(err)
}

cmd := exec.Command(bin, "--dry-run", "--env", envFile, "--def", defFile, "--environments", "totally-unknown")
cmd.Env = append(os.Environ(), "VERCEL_PROJECT_ID=dummy-project")
out, err := cmd.CombinedOutput()
if err == nil {
t.Errorf("不正な --environments 値でも exit 0 になった(exit 1 を期待):\n%s", out)
}
if !strings.Contains(string(out), "totally-unknown") {
t.Errorf("エラー出力に不正な環境名 \"totally-unknown\" が含まれない:\n%s", out)
}
}

// ---- --dry-run フラグのテスト ----

func TestDryRunFlag_NoTokenRequired(t *testing.T) {
bin := t.TempDir() + "/env-sync-test"
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
Expand Down
9 changes: 9 additions & 0 deletions env-sync.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@
# GitHub: named environment 名(空なら repo レベル)
# ※ GitHub の named environment は事前に作成が必要
#
# --environments <list> (CLI フラグ。このファイルには書かない)
# カンマ区切りで書き込み先環境を実行時に絞り込む(例: --environments staging,preview)。
# 各変数の environments との積集合を取り、積集合が空の変数はスキップ(stderr に警告)。
# environments を宣言していない変数も --environments 指定時はスキップされる。
# 全変数がスキップされた場合は prune も実行されず exit 0 で正常終了する。
# --prune と組み合わせると削除スコープも指定環境に限定される。
# 指定できる名前は production|preview|development + この定義に現れる環境名のみ
# (それ以外はエラー。大文字小文字を区別する)。
#
# provider: vercel|github|gcp|[vercel, gcp]|[vercel, github]
# 同期先プロバイダーを変数ごとに指定する。
# - provider: vercel Vercel にのみ送る
Expand Down
27 changes: 27 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,20 @@ func ParseFlags(argv []string, printUsageFn func(), versionFn func()) provider.O
os.Exit(1)
}
opts.Provider = v
case arg == "--environments" || arg == "-environments":
v := splitEnvironments(next())
if len(v) == 0 {
fmt.Fprint(os.Stderr, i18n.T(i18n.MsgFlagNeedsNonEmpty, "--environments"))
os.Exit(1)
}
opts.Environments = v
case strings.HasPrefix(arg, "--environments="):
v := splitEnvironments(strings.TrimPrefix(arg, "--environments="))
if len(v) == 0 {
fmt.Fprint(os.Stderr, i18n.T(i18n.MsgFlagNeedsNonEmpty, "--environments"))
os.Exit(1)
}
opts.Environments = v
case arg == "--vercel-project" || arg == "-vercel-project":
opts.VercelProject = next()
case strings.HasPrefix(arg, "--vercel-project="):
Expand Down Expand Up @@ -163,3 +177,16 @@ func ParseFlags(argv []string, printUsageFn func(), versionFn func()) provider.O
}
return opts
}

// splitEnvironments はカンマ区切りの環境名文字列を分割・トリム・空要素除去して返す。
// --environments フラグの値の解析に使用する。
func splitEnvironments(s string) []string {
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
result = append(result, t)
}
}
return result
}
Loading