From 4f90978d4adf7c7d67c95b7c51fcd6199586b707 Mon Sep 17 00:00:00 2001 From: polidog Date: Fri, 3 Jul 2026 18:18:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20--environments=20=E3=83=95=E3=83=A9?= =?UTF-8?q?=E3=82=B0=E3=81=A7=E6=9B=B8=E3=81=8D=E8=BE=BC=E3=81=BF=E5=85=88?= =?UTF-8?q?=E7=92=B0=E5=A2=83=E3=82=92=E7=B5=9E=E3=82=8A=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env-sync.yaml の environments を「対象になりうる環境の宣言」と位置づけ、 実際の書き込み先を --environments(カンマ区切り)との積集合に限定する。 - 積集合が空の変数はスキップ(正常終了・stderr に警告) - 未指定時は完全な後方互換 - 不正な環境名(標準3種 + 定義内 Custom Environment 名以外)はエラー終了 - prune の削除判定も指定環境の範囲内に限定 - Vercel: レコードの全環境(Target + customEnvironmentIds)が指定範囲内のときのみ削除 - GitHub: repo レベルスコープを除外し指定 named environment のみスキャン - i18n(en/ja)・README.md / README.ja.md 追記 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VLKa78rpZRLCUrEiv2uAMw --- README.ja.md | 37 +++++++++ README.md | 37 +++++++++ cmd/env-sync/main.go | 56 +++++++++++++ cmd/env-sync/main_test.go | 106 +++++++++++++++++++++++++ env-sync.yaml | 9 +++ internal/config/config.go | 17 ++++ internal/config/config_test.go | 54 +++++++++++++ internal/i18n/catalog_en.go | 31 +++++--- internal/i18n/catalog_ja.go | 31 +++++--- internal/i18n/keys.go | 6 ++ internal/provider/github/github.go | 31 ++++++-- internal/provider/github/prune_test.go | 47 ++++++++++- internal/provider/provider.go | 4 + internal/provider/vercel/prune_test.go | 76 ++++++++++++++++-- internal/provider/vercel/vercel.go | 79 +++++++++++++++++- internal/sync/entry.go | 43 ++++++++++ internal/sync/entry_test.go | 84 ++++++++++++++++++++ 17 files changed, 707 insertions(+), 41 deletions(-) diff --git a/README.ja.md b/README.ja.md index cd08328..f6a740b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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 ファイルに書きたくない場合に便利です)。 @@ -446,6 +482,7 @@ variables: | `--dry-run` | – | 送信せず新規/更新の区別を含む登録予定一覧を表示 | | `--yes` / `-y` | – | 更新(上書き)がある場合の確認をスキップ | | `--prune` | – | 定義ファイルに無いリモートの変数を削除(定義ファイルの `prune: true` でも有効化可) | +| `--environments ` | – | 書き込み先環境をカンマ区切りで指定(例 `staging,preview`)。各変数の `environments` との積集合が空の変数はスキップ。`--prune` と併用すると削除スコープも指定環境に限定される | | `--force` | – | `init` 時に既存の def ファイルを上書きする | | `VERCEL_TOKEN` | ◯(Vercel) | Vercel アクセストークン(dry-run 時は不要) | | `VERCEL_PROJECT_ID` | △(Vercel) | プロジェクト ID。未指定なら config ファイルまたは `.vercel/project.json` から自動取得 | diff --git a/README.md b/README.md index 4bd2f3a..e36be98 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 ` | – | 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 | diff --git a/cmd/env-sync/main.go b/cmd/env-sync/main.go index 04d7fd4..e23c846 100644 --- a/cmd/env-sync/main.go +++ b/cmd/env-sync/main.go @@ -75,12 +75,15 @@ // --dry-run 実際には送信せず、新規/更新の区別を含む登録予定一覧を表示(値は出さない) // --yes, -y 更新(上書き)を含む場合の確認をスキップして送信 // --prune 定義ファイルに無いリモートの変数を削除する(定義ファイルの prune: true でも有効化可) +// --environments 書き込み先環境をカンマ区切りで絞り込む(例: staging,preview) package main import ( "fmt" "os" "runtime/debug" + "sort" + "strings" "gopkg.in/yaml.v3" @@ -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 @@ -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 { @@ -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 +} diff --git a/cmd/env-sync/main_test.go b/cmd/env-sync/main_test.go index bebd1e9..820e8ea 100644 --- a/cmd/env-sync/main_test.go +++ b/cmd/env-sync/main_test.go @@ -5,6 +5,8 @@ import ( "os/exec" "strings" "testing" + + "github.com/ptyhard/env-sync/internal/config" ) // --version フラグの統合テスト(バイナリをビルドして実行) @@ -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 { diff --git a/env-sync.yaml b/env-sync.yaml index 526dbf4..ead6450 100644 --- a/env-sync.yaml +++ b/env-sync.yaml @@ -36,6 +36,15 @@ # GitHub: named environment 名(空なら repo レベル) # ※ GitHub の named environment は事前に作成が必要 # +# --environments (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 にのみ送る diff --git a/internal/config/config.go b/internal/config/config.go index 4eaa497..2c04018 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -129,6 +129,10 @@ func ParseFlags(argv []string, printUsageFn func(), versionFn func()) provider.O os.Exit(1) } opts.Provider = v + case arg == "--environments" || arg == "-environments": + opts.Environments = splitEnvironments(next()) + case strings.HasPrefix(arg, "--environments="): + opts.Environments = splitEnvironments(strings.TrimPrefix(arg, "--environments=")) case arg == "--vercel-project" || arg == "-vercel-project": opts.VercelProject = next() case strings.HasPrefix(arg, "--vercel-project="): @@ -163,3 +167,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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fbcd4e7..c14d397 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -361,3 +361,57 @@ func TestPrescanLang_Empty(t *testing.T) { t.Errorf("PrescanLang(空): got %q, want empty", got) } } + +// --environments フラグのテスト + +// TestParseFlags_Environments_CommaSeparated はカンマ区切りで複数の環境名を解析できること。 +func TestParseFlags_Environments_CommaSeparated(t *testing.T) { + opts := config.ParseFlags([]string{"--environments", "staging,preview"}, nil, nil) + want := []string{"staging", "preview"} + if len(opts.Environments) != len(want) { + t.Fatalf("Environments len = %d, want %d: %v", len(opts.Environments), len(want), opts.Environments) + } + for i, e := range want { + if opts.Environments[i] != e { + t.Errorf("Environments[%d] = %q, want %q", i, opts.Environments[i], e) + } + } +} + +// TestParseFlags_Environments_EqualForm は --environments=staging,preview の形式を解析できること。 +func TestParseFlags_Environments_EqualForm(t *testing.T) { + opts := config.ParseFlags([]string{"--environments=production"}, nil, nil) + if len(opts.Environments) != 1 || opts.Environments[0] != "production" { + t.Errorf("Environments = %v, want [production]", opts.Environments) + } +} + +// TestParseFlags_Environments_TrimmedSpaces はカンマ区切り値の前後スペースをトリムすること。 +func TestParseFlags_Environments_TrimmedSpaces(t *testing.T) { + opts := config.ParseFlags([]string{"--environments", " production , preview "}, nil, nil) + want := []string{"production", "preview"} + if len(opts.Environments) != len(want) { + t.Fatalf("Environments = %v, want %v", opts.Environments, want) + } + for i, e := range want { + if opts.Environments[i] != e { + t.Errorf("Environments[%d] = %q, want %q", i, opts.Environments[i], e) + } + } +} + +// TestParseFlags_Environments_ShortForm は -environments でも動作すること。 +func TestParseFlags_Environments_ShortForm(t *testing.T) { + opts := config.ParseFlags([]string{"-environments", "staging"}, nil, nil) + if len(opts.Environments) != 1 || opts.Environments[0] != "staging" { + t.Errorf("Environments = %v, want [staging]", opts.Environments) + } +} + +// TestParseFlags_Environments_EmptyDefault はフラグ未指定のとき Environments が nil であること。 +func TestParseFlags_Environments_EmptyDefault(t *testing.T) { + opts := config.ParseFlags([]string{}, nil, nil) + if len(opts.Environments) != 0 { + t.Errorf("Environments のデフォルト: got %v, want nil/空", opts.Environments) + } +} diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 790084c..cfc1496 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -3,21 +3,23 @@ package i18n // enCatalog は英語メッセージカタログ。 var enCatalog = map[MsgKey]string{ // ----- CLI フラグ共通 ----- - MsgFlagNeedsValue: "Error: %s requires a value\n", - MsgFlagNeedsNonEmpty: "Error: %s requires a non-empty value\n", - MsgFlagUnknown: "Error: unknown argument: %s\n", - MsgFlagProviderInvalid: "Error: --provider must be one of: %s\n", - MsgFlagOrSeparator: " or ", + MsgFlagNeedsValue: "Error: %s requires a value\n", + MsgFlagNeedsNonEmpty: "Error: %s requires a non-empty value\n", + MsgFlagUnknown: "Error: unknown argument: %s\n", + MsgFlagProviderInvalid: "Error: --provider must be one of: %s\n", + MsgFlagOrSeparator: " or ", + MsgInvalidEnvironmentsFlag: "--environments: invalid environment name(s): %s (allowed: %s)", // ----- Main / run() ----- - MsgErrorPrefix: "Error: %s\n", - MsgEnvFileNotFound: "env file not found: %s", - MsgDefFileNotFound: "definition file not found: %s", - MsgEnvFileReadFail: "failed to read env file: %s", - MsgDefFileReadFail: "failed to read definition file: %s", - MsgDefFileYAMLFail: "failed to parse definition file YAML: %s", - MsgSkipNoValueInEnv: "⚠ %s: defined but no value in %s, skipping\n", - MsgSkipNotDefined: "⚠ %s: in %s but not defined, skipping\n", + MsgErrorPrefix: "Error: %s\n", + MsgEnvFileNotFound: "env file not found: %s", + MsgDefFileNotFound: "definition file not found: %s", + MsgEnvFileReadFail: "failed to read env file: %s", + MsgDefFileReadFail: "failed to read definition file: %s", + MsgDefFileYAMLFail: "failed to parse definition file YAML: %s", + MsgSkipNoValueInEnv: "⚠ %s: defined but no value in %s, skipping\n", + MsgSkipNotDefined: "⚠ %s: in %s but not defined, skipping\n", + MsgSkipNoMatchingEnvironment: "⚠ %s: no matching environments after --environments filter, skipping\n", MsgUsage: `env-sync - sync environment variables declared in a definition file to Vercel or GitHub Actions Subcommands: @@ -38,6 +40,9 @@ Options (sync): --dry-run show planned entries (new/update) without sending (values not shown) --yes, -y skip confirmation when updates are included --prune delete remote variables not in the definition file (also enabled by prune: true in the definition file) + --environments comma-separated list of environments to write to (e.g. staging,preview) + intersects with each variable's declared environments; variables with no matching environment are skipped + when combined with --prune: deletion scope is also limited to the specified environments --vercel-project sync only the named project from config vercel.projects (monorepo) --github-repo sync only the named repo from config github.repos (monorepo) --lang display language (en / ja, default: en) diff --git a/internal/i18n/catalog_ja.go b/internal/i18n/catalog_ja.go index 1aa929a..f317f7c 100644 --- a/internal/i18n/catalog_ja.go +++ b/internal/i18n/catalog_ja.go @@ -3,21 +3,23 @@ package i18n // jaCatalog は日本語メッセージカタログ。 var jaCatalog = map[MsgKey]string{ // ----- CLI フラグ共通 ----- - MsgFlagNeedsValue: "エラー: %s には値が必要です\n", - MsgFlagNeedsNonEmpty: "エラー: %s には空でない値が必要です\n", - MsgFlagUnknown: "エラー: 不明な引数: %s\n", - MsgFlagProviderInvalid: "エラー: --provider には %s を指定してください\n", - MsgFlagOrSeparator: " または ", + MsgFlagNeedsValue: "エラー: %s には値が必要です\n", + MsgFlagNeedsNonEmpty: "エラー: %s には空でない値が必要です\n", + MsgFlagUnknown: "エラー: 不明な引数: %s\n", + MsgFlagProviderInvalid: "エラー: --provider には %s を指定してください\n", + MsgFlagOrSeparator: " または ", + MsgInvalidEnvironmentsFlag: "--environments: 不正な環境名が含まれています: %s(許容値: %s)", // ----- Main / run() ----- - MsgErrorPrefix: "エラー: %s\n", - MsgEnvFileNotFound: "env ファイルが見つかりません: %s", - MsgDefFileNotFound: "定義ファイルが見つかりません: %s", - MsgEnvFileReadFail: "env ファイルの読み込みに失敗: %s", - MsgDefFileReadFail: "定義ファイルの読み込みに失敗: %s", - MsgDefFileYAMLFail: "定義ファイルの YAML パースに失敗: %s", - MsgSkipNoValueInEnv: "⚠ %s: 定義にあるが %s に値が無いためスキップ\n", - MsgSkipNotDefined: "⚠ %s: %s にあるが定義に無いためスキップ\n", + MsgErrorPrefix: "エラー: %s\n", + MsgEnvFileNotFound: "env ファイルが見つかりません: %s", + MsgDefFileNotFound: "定義ファイルが見つかりません: %s", + MsgEnvFileReadFail: "env ファイルの読み込みに失敗: %s", + MsgDefFileReadFail: "定義ファイルの読み込みに失敗: %s", + MsgDefFileYAMLFail: "定義ファイルの YAML パースに失敗: %s", + MsgSkipNoValueInEnv: "⚠ %s: 定義にあるが %s に値が無いためスキップ\n", + MsgSkipNotDefined: "⚠ %s: %s にあるが定義に無いためスキップ\n", + MsgSkipNoMatchingEnvironment: "⚠ %s: --environments フィルタ後に一致する環境が無いためスキップ\n", MsgUsage: `env-sync - 定義ファイルで宣言した環境変数を Vercel または GitHub Actions へ一括登録(同期)する サブコマンド: @@ -38,6 +40,9 @@ var jaCatalog = map[MsgKey]string{ --dry-run 送信せず新規/更新の区別を含む登録予定一覧を表示(値は出さない) --yes, -y 更新(上書き)を含む場合の確認をスキップして送信 --prune 定義ファイルに無いリモートの変数を削除する(定義ファイルの prune: true でも有効化可) + --environments 書き込み先環境をカンマ区切りで絞り込む(例: staging,preview) + 各変数の宣言済み environments との積集合を取る。一致なしはスキップ(エラーにしない) + --prune 併用時: 削除スコープも指定環境に限定する --vercel-project config の vercel.projects から指定名のプロジェクトのみ同期(モノレポ対応) --github-repo config の github.repos から指定名のリポジトリのみ同期(モノレポ対応) --lang 表示言語(en / ja、デフォルト en) diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index 9be067b..3d8f6cf 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -19,6 +19,9 @@ const ( MsgFlagProviderInvalid MsgKey = "flag.provider_invalid" // MsgFlagOrSeparator はプロバイダ名一覧を結合するセパレータ。 MsgFlagOrSeparator MsgKey = "flag.or_separator" + // MsgInvalidEnvironmentsFlag は --environments に不正な環境名が指定されたときのエラー + // (書式: 不正値一覧, 許容値一覧)。 + MsgInvalidEnvironmentsFlag MsgKey = "flag.invalid_environments" // ----- Main / run() ----- @@ -38,6 +41,9 @@ const ( MsgSkipNoValueInEnv MsgKey = "skip.no_value_in_env" // MsgSkipNotDefined は env にあるが定義にないキーのスキップ警告(書式: キー名, envファイル名)。 MsgSkipNotDefined MsgKey = "skip.not_defined" + // MsgSkipNoMatchingEnvironment は --environments 指定時に積集合が空になった変数のスキップ警告 + // (書式: 変数名)。 + MsgSkipNoMatchingEnvironment MsgKey = "skip.no_matching_env" // MsgUsage は --help で表示する全体使用方法テキスト(書式引数なし)。 MsgUsage MsgKey = "usage" diff --git a/internal/provider/github/github.go b/internal/provider/github/github.go index 53959b5..f427234 100644 --- a/internal/provider/github/github.go +++ b/internal/provider/github/github.go @@ -107,13 +107,32 @@ type githubPruneTarget struct { } // pruneScopes は prune でスキャンする envScope の一覧を返す純粋関数。 -// リポジトリレベル("")は常に含め、tasks に現れる named environment を重複なく加える。 +// filterEnvs が空のとき: リポジトリレベル("") を常に含め、tasks に現れる named environment を重複なく加える。 +// filterEnvs が非空のとき: リポジトリレベル("") を除外し(指定外環境の変数を誤削除しないため)、 +// tasks に現れ、かつ filterEnvs に含まれる named environment のみを加える。 // 定義ファイルに現れない environment はスキャンしない(削除対象の探索範囲を管理対象に限定する)。 -func pruneScopes(tasks []githubTask) []string { - scopes := []string{""} - seen := map[string]bool{"": true} +func pruneScopes(tasks []githubTask, filterEnvs []string) []string { + if len(filterEnvs) == 0 { + // フラグ未指定: 従来通り repo レベルを含む全スコープ + scopes := []string{""} + seen := map[string]bool{"": true} + for _, t := range tasks { + if !seen[t.envScope] { + seen[t.envScope] = true + scopes = append(scopes, t.envScope) + } + } + return scopes + } + // --environments 指定時: repo レベル("") を除外し、指定環境のみを対象にする + filterSet := make(map[string]bool, len(filterEnvs)) + for _, e := range filterEnvs { + filterSet[e] = true + } + var scopes []string + seen := make(map[string]bool) for _, t := range tasks { - if !seen[t.envScope] { + if t.envScope != "" && filterSet[t.envScope] && !seen[t.envScope] { seen[t.envScope] = true scopes = append(scopes, t.envScope) } @@ -349,7 +368,7 @@ func (g *githubProvider) Sync(opts provider.Options, entries []provider.Entry) e } // prune 削除対象の収集(一覧取得に失敗した場合は削除をスキップする安全側フォールバック) if opts.Prune { - pt, err := collectGitHubPrune(client, targetToken, ownerStr, repoStr, pruneScopes(tasks), pruneKeep) + pt, err := collectGitHubPrune(client, targetToken, ownerStr, repoStr, pruneScopes(tasks, opts.Environments), pruneKeep) if err == nil { pruneTargets = pt } else { diff --git a/internal/provider/github/prune_test.go b/internal/provider/github/prune_test.go index 841cb4a..c2282a4 100644 --- a/internal/provider/github/prune_test.go +++ b/internal/provider/github/prune_test.go @@ -20,7 +20,7 @@ func keepFunc(definedKeys ...string) func(string) bool { // --- pruneScopes のテスト --- func TestPruneScopes_AlwaysIncludesRepoLevel(t *testing.T) { - scopes := pruneScopes(nil) + scopes := pruneScopes(nil, nil) if len(scopes) != 1 || scopes[0] != "" { t.Errorf("scopes = %v, want [\"\"](repo レベルは常にスキャン)", scopes) } @@ -33,7 +33,7 @@ func TestPruneScopes_CollectsUniqueEnvScopes(t *testing.T) { {envScope: "production", entry: provider.Entry{Key: "C"}}, {envScope: "staging", entry: provider.Entry{Key: "D"}}, } - scopes := pruneScopes(tasks) + scopes := pruneScopes(tasks, nil) want := []string{"", "production", "staging"} if len(scopes) != len(want) { t.Fatalf("scopes = %v, want %v", scopes, want) @@ -45,6 +45,49 @@ func TestPruneScopes_CollectsUniqueEnvScopes(t *testing.T) { } } +// --- pruneScopes + filterEnvs のテスト --- + +// filterEnvs 指定時は repo レベル("") を除外すること +func TestPruneScopes_FilterEnvs_ExcludesRepoLevel(t *testing.T) { + tasks := []githubTask{ + {envScope: "", entry: provider.Entry{Key: "A"}}, + {envScope: "production", entry: provider.Entry{Key: "B"}}, + } + scopes := pruneScopes(tasks, []string{"production"}) + for _, s := range scopes { + if s == "" { + t.Errorf("scopes に repo レベル (\"\") が含まれている: %v(filterEnvs 指定時は除外するべき)", scopes) + } + } + if len(scopes) != 1 || scopes[0] != "production" { + t.Errorf("scopes = %v, want [production](指定環境のみ)", scopes) + } +} + +// filterEnvs に含まれない named environment はスコープに入らないこと +func TestPruneScopes_FilterEnvs_OnlyIncludesSpecifiedEnvs(t *testing.T) { + tasks := []githubTask{ + {envScope: "production", entry: provider.Entry{Key: "A"}}, + {envScope: "staging", entry: provider.Entry{Key: "B"}}, + } + scopes := pruneScopes(tasks, []string{"production"}) + if len(scopes) != 1 || scopes[0] != "production" { + t.Errorf("scopes = %v, want [production](staging は filterEnvs 外なので除外)", scopes) + } +} + +// filterEnvs が空のとき従来の動作(repo レベルを含む全スコープ)を返すこと(後方互換) +func TestPruneScopes_FilterEnvs_Empty_BackwardCompat(t *testing.T) { + tasks := []githubTask{ + {envScope: "", entry: provider.Entry{Key: "A"}}, + {envScope: "production", entry: provider.Entry{Key: "B"}}, + } + scopes := pruneScopes(tasks, nil) + if len(scopes) != 2 || scopes[0] != "" || scopes[1] != "production" { + t.Errorf("scopes = %v, want [\"\", \"production\"](後方互換)", scopes) + } +} + // --- undefinedNames のテスト --- func TestUndefinedNames(t *testing.T) { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b2fa1a0..a423f52 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -23,6 +23,10 @@ type Options struct { DefinedKeys []string // PruneExclude は prune で削除しないキー名の glob パターン一覧(定義ファイルの prune_exclude)。 PruneExclude []string + // Environments は --environments フラグで指定した書き込み先環境の絞り込みリスト。 + // 空のとき絞り込みなし(従来通り宣言済み全環境へ書き込む)。 + // 非空のとき、各変数の宣言済み environments との積集合が書き込み先になる。 + Environments []string } // PruneKeep は prune 時に key を保持すべきか(削除しないか)を判定する関数を返す。 diff --git a/internal/provider/vercel/prune_test.go b/internal/provider/vercel/prune_test.go index 447b0b2..8717604 100644 --- a/internal/provider/vercel/prune_test.go +++ b/internal/provider/vercel/prune_test.go @@ -21,7 +21,7 @@ func TestComputeVercelPrune_UndefinedKeyIsPruned(t *testing.T) { {ID: "id1", Key: "DEFINED_KEY"}, {ID: "id2", Key: "STALE_KEY"}, } - got := computeVercelPrune(envs, keepFunc("DEFINED_KEY")) + got := computeVercelPrune(envs, keepFunc("DEFINED_KEY"), nil, nil) if len(got) != 1 || got[0].Key != "STALE_KEY" { t.Errorf("prune 対象 = %v, want [STALE_KEY]", got) } @@ -34,7 +34,7 @@ func TestComputeVercelPrune_SkipsSystemAndIntegration(t *testing.T) { {ID: "id3", Key: "BLOB_READ_WRITE_TOKEN", ConfigurationID: "icfg_12345"}, // インテグレーション(Blob Store 等) {ID: "id4", Key: "STALE_KEY"}, } - got := computeVercelPrune(envs, keepFunc()) + got := computeVercelPrune(envs, keepFunc(), nil, nil) if len(got) != 1 || got[0].Key != "STALE_KEY" { t.Errorf("prune 対象 = %v, want [STALE_KEY](system / インテグレーション由来は除外)", got) } @@ -46,7 +46,7 @@ func TestComputeVercelPrune_MultipleRecordsSameKey(t *testing.T) { {ID: "id1", Key: "STALE_KEY", Target: []string{"production"}}, {ID: "id2", Key: "STALE_KEY", Target: []string{"preview"}}, } - got := computeVercelPrune(envs, keepFunc()) + got := computeVercelPrune(envs, keepFunc(), nil, nil) if len(got) != 2 { t.Errorf("prune 対象件数 = %d, want 2(レコード単位で削除)", len(got)) } @@ -58,7 +58,7 @@ func TestComputeVercelPrune_SkipsEmptyID(t *testing.T) { {ID: "", Key: "NO_ID_KEY"}, {ID: "id1", Key: "STALE_KEY"}, } - got := computeVercelPrune(envs, keepFunc()) + got := computeVercelPrune(envs, keepFunc(), nil, nil) if len(got) != 1 || got[0].Key != "STALE_KEY" { t.Errorf("prune 対象 = %v, want [STALE_KEY](ID 空は除外)", got) } @@ -66,11 +66,77 @@ func TestComputeVercelPrune_SkipsEmptyID(t *testing.T) { func TestComputeVercelPrune_AllDefined_Empty(t *testing.T) { envs := []vercelEnv{{ID: "id1", Key: "FOO"}} - if got := computeVercelPrune(envs, keepFunc("FOO")); len(got) != 0 { + if got := computeVercelPrune(envs, keepFunc("FOO"), nil, nil); len(got) != 0 { t.Errorf("prune 対象 = %v, want 空", got) } } +// --- computeVercelPrune + filterEnvs のテスト --- + +// filterEnvs 指定時、Target が指定環境のみからなるレコードのみ削除対象になること +func TestComputeVercelPrune_FilterEnvs_DeletesIfAllTargetInFilter(t *testing.T) { + envs := []vercelEnv{ + {ID: "id1", Key: "STALE_KEY", Target: []string{"production"}}, + {ID: "id2", Key: "STALE_KEY", Target: []string{"preview"}}, + {ID: "id3", Key: "STALE_KEY", Target: []string{"staging"}}, + } + // filterEnvs = ["production"] → production のみのレコードが削除対象 + got := computeVercelPrune(envs, keepFunc(), []string{"production"}, nil) + if len(got) != 1 || got[0].ID != "id1" { + t.Errorf("prune 対象 = %v, want [id1(production)](指定環境内レコードのみ)", got) + } +} + +// filterEnvs 指定時、Target が指定環境外を含むレコードは保持されること(削除されない) +func TestComputeVercelPrune_FilterEnvs_KeepsIfTargetHasExternalEnv(t *testing.T) { + envs := []vercelEnv{ + // production + preview の複合レコード: filterEnvs=["production"] だと preview が範囲外 → 保持 + {ID: "id1", Key: "STALE_KEY", Target: []string{"production", "preview"}}, + } + got := computeVercelPrune(envs, keepFunc(), []string{"production"}, nil) + if len(got) != 0 { + t.Errorf("prune 対象 = %v, want 空(指定外環境を含む複合レコードは保持)", got) + } +} + +// filterEnvs 指定時、Target と CustomEnvironmentIDs が両方空のレコードは保持されること +func TestComputeVercelPrune_FilterEnvs_KeepsEmptyScopeRecord(t *testing.T) { + envs := []vercelEnv{ + {ID: "id1", Key: "STALE_KEY", Target: nil, CustomEnvironmentIDs: nil}, + } + got := computeVercelPrune(envs, keepFunc(), []string{"production"}, nil) + if len(got) != 0 { + t.Errorf("prune 対象 = %v, want 空(スコープ不明レコードは安全側に保持)", got) + } +} + +// filterEnvs 未指定時は従来通りすべての定義外レコードが削除対象になること(後方互換) +func TestComputeVercelPrune_FilterEnvs_NilFilter_BackwardCompat(t *testing.T) { + envs := []vercelEnv{ + {ID: "id1", Key: "STALE_KEY", Target: []string{"production", "preview"}}, + {ID: "id2", Key: "STALE_KEY2", Target: []string{"staging"}}, + } + got := computeVercelPrune(envs, keepFunc(), nil, nil) + if len(got) != 2 { + t.Errorf("prune 対象件数 = %d, want 2(filterEnvs 未指定時は全レコード対象)", len(got)) + } +} + +// filterEnvs に Custom Environment slug が含まれる場合、slugToID で ID 解決して比較すること +func TestComputeVercelPrune_FilterEnvs_CustomEnvByID(t *testing.T) { + envs := []vercelEnv{ + // custom env ID が "env_staging123" → filterEnvs="staging" で slugToID["staging"]="env_staging123" ならば削除対象 + {ID: "id1", Key: "STALE_KEY", Target: nil, CustomEnvironmentIDs: []string{"env_staging123"}}, + // custom env ID が "env_other" → filterEnvs に含まれない → 保持 + {ID: "id2", Key: "STALE_KEY2", Target: nil, CustomEnvironmentIDs: []string{"env_other"}}, + } + slugToID := map[string]string{"staging": "env_staging123"} + got := computeVercelPrune(envs, keepFunc(), []string{"staging"}, slugToID) + if len(got) != 1 || got[0].ID != "id1" { + t.Errorf("prune 対象 = %v, want [id1(staging custom env)]", got) + } +} + // --- deleteVercelEnvs のテスト --- func TestDeleteVercelEnvs_SendsDeleteToCorrectURL(t *testing.T) { diff --git a/internal/provider/vercel/vercel.go b/internal/provider/vercel/vercel.go index b370872..0b74e97 100644 --- a/internal/provider/vercel/vercel.go +++ b/internal/provider/vercel/vercel.go @@ -111,6 +111,26 @@ func (v *vercelProvider) Sync(opts provider.Options, entries []provider.Entry) e } } + // prune の環境フィルタに Custom Environment のIDが必要な場合に slug→ID マップを取得する。 + // filterEnvs に標準環境名以外(Custom Environment slug)が含まれる場合のみ API を呼ぶ。 + // dry-run でも prune プレビューに使うため DryRun を条件に含めない(read-only GET なので安全)。 + var slugToIDForPrune map[string]string + if opts.Prune && len(opts.Environments) > 0 && tgt.Token != "" { + hasCustomEnv := false + for _, e := range opts.Environments { + if !stdVercelEnvNames[e] { + hasCustomEnv = true + break + } + } + if hasCustomEnv { + if m, fetchErr := vercelFetchCustomEnvironments(client, tgt.Token, tgt.ProjectID, tgt.TeamID); fetchErr == nil { + slugToIDForPrune = m + } + // 取得失敗時は slugToIDForPrune = nil のまま(custom env フィルタなし、安全側) + } + } + // 既存 envs を問い合わせて新規/更新を分類し、prune 対象を計算する(結果を保存して後で再利用) var classified []classifiedVercelItem if tgt.Token != "" { @@ -118,7 +138,7 @@ func (v *vercelProvider) Sync(opts provider.Options, entries []provider.Entry) e if err == nil { classified = classifyVercelItems(items, existingKeySet(envs)) if opts.Prune { - perTargetPrune[i] = computeVercelPrune(envs, pruneKeep) + perTargetPrune[i] = computeVercelPrune(envs, pruneKeep, opts.Environments, slugToIDForPrune) } } else { // API 失敗時は classified = nil のまま(確認スキップしない安全側フォールバック)。 @@ -386,6 +406,10 @@ type vercelEnv struct { Key string `json:"key"` Type string `json:"type"` Target []string `json:"target"` + // CustomEnvironmentIDs は Custom Environment(staging 等)の ID リスト。 + // 標準環境(production/preview/development)は Target に入り、 + // Custom Environment は Target が空になり CustomEnvironmentIDs に ID が入る。 + CustomEnvironmentIDs []string `json:"customEnvironmentIds"` // ConfigurationID が非空の変数はインテグレーション(Blob Store 等)が作成・管理している。 ConfigurationID string `json:"configurationId"` // System が true の変数は Vercel が自動提供するシステム変数。 @@ -442,6 +466,13 @@ func existingKeySet(envs []vercelEnv) map[string]bool { return existing } +// stdVercelEnvNames は Vercel の標準環境名セット(Target フィールドに入る値)。 +var stdVercelEnvNames = map[string]bool{ + "production": true, + "preview": true, + "development": true, +} + // computeVercelPrune は既存 envs のうち定義ファイルに無いレコードを削除対象として返す純粋関数。 // 以下は env-sync の管理外とみなし削除対象から除外する: // - システム変数(system=true または type=system) @@ -449,17 +480,61 @@ func existingKeySet(envs []vercelEnv) map[string]bool { // - ID が空のレコード(削除 URL を組み立てられないため安全側に倒して除外) // // keep は Options.PruneKeep が返す保持判定(定義済みキー + prune_exclude パターン)。 -func computeVercelPrune(envs []vercelEnv, keep func(key string) bool) []vercelEnv { +// filterEnvs が非空のとき、レコードの全環境(Target + CustomEnvironmentIDs)が +// filterEnvs の範囲内に収まる場合のみ削除対象にする(部分的でも範囲外を含むレコードは保持)。 +// slugToID は Custom Environment の slug→ID マップ(filterEnvs に custom 名が含まれる場合に使用)。 +func computeVercelPrune(envs []vercelEnv, keep func(key string) bool, filterEnvs []string, slugToID map[string]string) []vercelEnv { var prune []vercelEnv for _, e := range envs { if e.ID == "" || e.System || e.Type == "system" || e.ConfigurationID != "" || keep(e.Key) { continue } + // --environments 指定時: 指定環境内に完全に収まるレコードのみ削除対象にする + if len(filterEnvs) > 0 && !vercelEnvWithinFilter(e, filterEnvs, slugToID) { + continue + } prune = append(prune, e) } return prune } +// vercelEnvWithinFilter は vercelEnv のすべての環境(Target + CustomEnvironmentIDs)が +// filterEnvs の範囲内かどうかを確認する純粋関数。 +// true → 指定環境内に完全に収まる(削除対象候補) +// false → 指定環境外の環境を含む、または Target/CustomEnvironmentIDs が両方空(保持) +func vercelEnvWithinFilter(e vercelEnv, filterEnvs []string, slugToID map[string]string) bool { + // Target と CustomEnvironmentIDs が両方空 → スコープ不明 → 安全側に倒して保持(削除しない) + if len(e.Target) == 0 && len(e.CustomEnvironmentIDs) == 0 { + return false + } + // 標準環境フィルタセット + filterStd := make(map[string]bool, len(filterEnvs)) + // Custom Environment ID フィルタセット(slugToID から変換) + filterCustomIDs := make(map[string]bool) + for _, f := range filterEnvs { + if stdVercelEnvNames[f] { + filterStd[f] = true + } else if slugToID != nil { + if id, ok := slugToID[f]; ok { + filterCustomIDs[id] = true + } + } + } + // Target の全環境がフィルタ内か確認 + for _, t := range e.Target { + if !filterStd[t] { + return false + } + } + // CustomEnvironmentIDs の全 ID がフィルタ内か確認 + for _, id := range e.CustomEnvironmentIDs { + if !filterCustomIDs[id] { + return false + } + } + return true +} + // deleteVercelEnvs は prune 対象の環境変数レコードを 1 件ずつ削除し、成功数・失敗数を返す。 // os.Exit は呼ばない。 func deleteVercelEnvs(client *http.Client, token, projectID, teamID string, envs []vercelEnv) (ok, ng int) { diff --git a/internal/sync/entry.go b/internal/sync/entry.go index a81d16c..afb76f1 100644 --- a/internal/sync/entry.go +++ b/internal/sync/entry.go @@ -116,6 +116,49 @@ func ResolveEntries(def config.Definition, envVars map[string]string, defKeys [] return entries, nil } +// FilterEntriesByEnvironments は filterEnvs(--environments フラグの値)で entries を絞り込む。 +// filterEnvs が空のとき entries をそのまま返す(後方互換)。 +// filterEnvs が非空のとき: +// - entry.Environments が nil/空(宣言なし)→ 「全環境」ではなく「宣言なし」とみなしスキップ +// (Vercel は envs 空だと暗黙で [production, preview] に書くため、nil のまま通すと +// フラグ外環境へ書く事故になる。安全側に倒してスキップする) +// - entry.Environments ∩ filterEnvs が空 → スキップ +// - 積集合が非空 → entry の Environments を積集合に置き換えて返す +// +// 返り値: (filtered []provider.Entry, skippedKeys []string) +func FilterEntriesByEnvironments(entries []provider.Entry, filterEnvs []string) ([]provider.Entry, []string) { + if len(filterEnvs) == 0 { + return entries, nil + } + filterSet := make(map[string]bool, len(filterEnvs)) + for _, e := range filterEnvs { + filterSet[e] = true + } + filtered := make([]provider.Entry, 0, len(entries)) + var skipped []string + for _, e := range entries { + // environments が nil/空 → 宣言なし → filterEnvs 指定時はスキップ + if len(e.Environments) == 0 { + skipped = append(skipped, e.Key) + continue + } + var intersection []string + for _, env := range e.Environments { + if filterSet[env] { + intersection = append(intersection, env) + } + } + if len(intersection) == 0 { + skipped = append(skipped, e.Key) + continue + } + // 積集合で Environments を上書き + e.Environments = intersection + filtered = append(filtered, e) + } + return filtered, skipped +} + // deduplicateProviders は providers スライスから空文字・空白のみの要素を除去し重複を排除する。 // [vercel, vercel] のような重複指定を正規化し、二重 Sync を防ぐ。 func deduplicateProviders(providers []string) []string { diff --git a/internal/sync/entry_test.go b/internal/sync/entry_test.go index 51b816b..35aca82 100644 --- a/internal/sync/entry_test.go +++ b/internal/sync/entry_test.go @@ -474,3 +474,87 @@ func TestResolveEntries_VercelProject_Unset(t *testing.T) { t.Errorf("VercelProjects = %v, want [] (未指定なら全ターゲット向け)", entries[0].VercelProjects) } } + +// FilterEntriesByEnvironments のテスト + +// filterEnvs が空のとき entries をそのまま返す(後方互換) +func TestFilterEntriesByEnvironments_NoFilter_All(t *testing.T) { + entries := []provider.Entry{ + {Key: "FOO", Environments: []string{"production", "preview"}}, + {Key: "BAR", Environments: []string{"staging"}}, + } + filtered, skipped := FilterEntriesByEnvironments(entries, nil) + if len(filtered) != 2 { + t.Errorf("filtered len = %d, want 2", len(filtered)) + } + if len(skipped) != 0 { + t.Errorf("skipped = %v, want 空", skipped) + } +} + +// filterEnvs 指定時、宣言 environments ∩ filterEnvs で絞り込まれること +func TestFilterEntriesByEnvironments_Intersection(t *testing.T) { + entries := []provider.Entry{ + {Key: "FOO", Environments: []string{"production", "preview", "staging"}}, + } + filtered, skipped := FilterEntriesByEnvironments(entries, []string{"production"}) + if len(filtered) != 1 { + t.Fatalf("filtered len = %d, want 1", len(filtered)) + } + if len(filtered[0].Environments) != 1 || filtered[0].Environments[0] != "production" { + t.Errorf("Environments = %v, want [production](積集合に置き換え)", filtered[0].Environments) + } + if len(skipped) != 0 { + t.Errorf("skipped = %v, want 空(積集合非空なのでスキップなし)", skipped) + } +} + +// filterEnvs 指定時、積集合が空の変数はスキップされること +func TestFilterEntriesByEnvironments_EmptyIntersection_Skipped(t *testing.T) { + entries := []provider.Entry{ + {Key: "FOO", Environments: []string{"production", "staging"}}, + } + filtered, skipped := FilterEntriesByEnvironments(entries, []string{"preview"}) + if len(filtered) != 0 { + t.Errorf("filtered len = %d, want 0(積集合空はスキップ)", len(filtered)) + } + if len(skipped) != 1 || skipped[0] != "FOO" { + t.Errorf("skipped = %v, want [FOO]", skipped) + } +} + +// filterEnvs 指定時、environments 未宣言(nil)の変数はスキップされること +func TestFilterEntriesByEnvironments_NilEnvironments_Skipped(t *testing.T) { + entries := []provider.Entry{ + {Key: "FOO", Environments: nil}, + } + filtered, skipped := FilterEntriesByEnvironments(entries, []string{"production"}) + if len(filtered) != 0 { + t.Errorf("filtered len = %d, want 0(宣言なしはフラグ指定時スキップ)", len(filtered)) + } + if len(skipped) != 1 || skipped[0] != "FOO" { + t.Errorf("skipped = %v, want [FOO]", skipped) + } +} + +// 複数変数混在: 一部スキップ・一部通過 +func TestFilterEntriesByEnvironments_Mixed(t *testing.T) { + entries := []provider.Entry{ + {Key: "A", Environments: []string{"production", "staging"}}, + {Key: "B", Environments: []string{"preview"}}, + {Key: "C", Environments: nil}, + } + filtered, skipped := FilterEntriesByEnvironments(entries, []string{"production", "preview"}) + if len(filtered) != 2 { + t.Fatalf("filtered len = %d, want 2: %v", len(filtered), filtered) + } + if filtered[0].Key != "A" || len(filtered[0].Environments) != 1 || filtered[0].Environments[0] != "production" { + t.Errorf("filtered[0] = %v, want A with [production]", filtered[0]) + } + if filtered[1].Key != "B" || len(filtered[1].Environments) != 1 || filtered[1].Environments[0] != "preview" { + t.Errorf("filtered[1] = %v, want B with [preview]", filtered[1]) + } + if len(skipped) != 1 || skipped[0] != "C" { + t.Errorf("skipped = %v, want [C](宣言なしはスキップ)", skipped) + } +} From 519d7dc3432310ce1d2ec1f56c17a7f24ce90938 Mon Sep 17 00:00:00 2001 From: polidog Date: Fri, 3 Jul 2026 18:29:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20--environments=20=E3=81=AB=E7=A9=BA?= =?UTF-8?q?=E5=80=A4=E3=82=92=E6=B8=A1=E3=81=97=E3=81=9F=E5=A0=B4=E5=90=88?= =?UTF-8?q?=E3=81=AB=E3=82=A8=E3=83=A9=E3=83=BC=E7=B5=82=E4=BA=86=E3=81=99?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E4=BF=AE=E6=AD=A3=E3=80=81=E3=82=B3?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=E8=AA=A4=E8=A1=A8=E7=8F=BE=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `--environments=` や `--environments ,` のように空・空白のみの値を渡した場合、 splitEnvironments() が空スライスを返してフィルタ無効扱い(全環境書き込み)になる問題を修正。 フラグが指定されてパース結果が空ならエラー終了させ、意図せぬスコープ拡大を防ぐ。 - env-sync.yaml のコメント「重複が無い変数はスキップ」を 「積集合が空の変数はスキップ」に修正("重複" は誤解を招く表現のため)。 Co-Authored-By: Claude Sonnet 4.6 --- env-sync.yaml | 2 +- internal/config/config.go | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/env-sync.yaml b/env-sync.yaml index ead6450..19ea607 100644 --- a/env-sync.yaml +++ b/env-sync.yaml @@ -38,7 +38,7 @@ # # --environments (CLI フラグ。このファイルには書かない) # カンマ区切りで書き込み先環境を実行時に絞り込む(例: --environments staging,preview)。 -# 各変数の environments との積集合を取り、重複が無い変数はスキップ(stderr に警告)。 +# 各変数の environments との積集合を取り、積集合が空の変数はスキップ(stderr に警告)。 # environments を宣言していない変数も --environments 指定時はスキップされる。 # 全変数がスキップされた場合は prune も実行されず exit 0 で正常終了する。 # --prune と組み合わせると削除スコープも指定環境に限定される。 diff --git a/internal/config/config.go b/internal/config/config.go index 2c04018..55206af 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -130,9 +130,19 @@ func ParseFlags(argv []string, printUsageFn func(), versionFn func()) provider.O } opts.Provider = v case arg == "--environments" || arg == "-environments": - opts.Environments = splitEnvironments(next()) + 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="): - opts.Environments = splitEnvironments(strings.TrimPrefix(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="):