govgate 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +18 -0
  2. package/dist/index.js +155 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -40,6 +40,15 @@ standard reporter.
40
40
  Completes a run left open by `--no-complete` and evaluates the gate over **all** results
41
41
  in the run (including those posted by other jobs).
42
42
 
43
+ ### `govgate sync-env`
44
+
45
+ Upserts the environments declared in `govgate/config.json` to the tool. Idempotent by
46
+ slug: an existing `dev` is updated in place, never duplicated. Takes `--url`,
47
+ `--api-key`, `--config` (same resolution as `report`). `report` also runs this sync
48
+ automatically before creating a run when the block is present, so a brand-new stage
49
+ can never fail on an unknown environment — the explicit command exists for
50
+ onboarding-time provisioning and for verifying the catalog without reporting.
51
+
43
52
  ## Exit codes
44
53
 
45
54
  | Code | Meaning |
@@ -67,6 +76,15 @@ Resolution per test (results are unioned):
67
76
  {
68
77
  "suite": "checkout-regression",
69
78
  "defaultEnvironment": "dev",
79
+ // Code-first environment catalog (v0.4.0+): synced to the tool by slug
80
+ // (existing slugs update in place — never duplicated). Slugs are scoped per
81
+ // application, so the plain dev/uat/prod trio is safe for every service; add
82
+ // more (test, pre-prod, demo, …) as needed, up to 20.
83
+ "environments": [
84
+ { "slug": "dev", "name": "DEV", "baseUrl": "https://dev.example.com" },
85
+ { "slug": "uat", "name": "UAT" },
86
+ { "slug": "prod", "name": "PROD", "baseUrl": "https://www.example.com", "notes": "Live." }
87
+ ],
70
88
  "mappings": {
71
89
  "4": ["checkout.spec.ts::pays with saved card"],
72
90
  "7": ["auth/*.spec.ts::*expired session*", "Login flow > redirects*"],
package/dist/index.js CHANGED
@@ -46,6 +46,11 @@ var ApiClient = class {
46
46
  }
47
47
  return JSON.parse(text2);
48
48
  }
49
+ // Idempotent by (application, slug): existing environments are updated in
50
+ // place, never duplicated. The API key scopes everything to one application.
51
+ putEnvironments(environments) {
52
+ return this.request("PUT", "/api/v1/environments", { environments });
53
+ }
49
54
  createRun(input) {
50
55
  return this.request("POST", "/api/v1/runs", { ...input, source: "ci" });
51
56
  }
@@ -114,16 +119,27 @@ function emitRunIdVariable(runId, ctx, env = process.env) {
114
119
  }
115
120
 
116
121
  // src/config.ts
117
- import { readFileSync, existsSync } from "fs";
122
+ import { readFileSync, existsSync, readdirSync } from "fs";
118
123
  import { dirname, join, resolve } from "path";
119
124
  var ConfigError = class extends Error {
120
125
  };
121
- function findConfigFile(startDir, explicitPath) {
122
- if (explicitPath) {
123
- const p = resolve(startDir, explicitPath);
124
- if (!existsSync(p)) throw new ConfigError(`Config file not found: ${p}`);
125
- return p;
126
- }
126
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
127
+ "node_modules",
128
+ ".git",
129
+ ".next",
130
+ ".vs",
131
+ ".vscode",
132
+ ".idea",
133
+ "dist",
134
+ "build",
135
+ "bin",
136
+ "obj",
137
+ "coverage",
138
+ ".turbo",
139
+ ".cache"
140
+ ]);
141
+ var DOWNWARD_MAX_DEPTH = 6;
142
+ function findConfigUpward(startDir) {
127
143
  let dir = resolve(startDir);
128
144
  for (; ; ) {
129
145
  const candidate = join(dir, "govgate", "config.json");
@@ -133,6 +149,58 @@ function findConfigFile(startDir, explicitPath) {
133
149
  dir = parent;
134
150
  }
135
151
  }
152
+ function findConfigDownward(startDir) {
153
+ const root = resolve(startDir);
154
+ const found = [];
155
+ const queue = [{ dir: root, depth: 0 }];
156
+ while (queue.length > 0) {
157
+ const { dir, depth } = queue.shift();
158
+ const direct = join(dir, "govgate", "config.json");
159
+ if (existsSync(direct)) found.push(direct);
160
+ if (depth >= DOWNWARD_MAX_DEPTH) continue;
161
+ let entries;
162
+ try {
163
+ entries = readdirSync(dir, { withFileTypes: true });
164
+ } catch {
165
+ continue;
166
+ }
167
+ for (const entry of entries) {
168
+ if (!entry.isDirectory()) continue;
169
+ if (entry.name === "govgate") continue;
170
+ if (SKIP_DIRS.has(entry.name)) continue;
171
+ queue.push({ dir: join(dir, entry.name), depth: depth + 1 });
172
+ }
173
+ }
174
+ return [...new Set(found)].sort();
175
+ }
176
+ function findConfigFile(startDir, explicitPath) {
177
+ if (explicitPath) {
178
+ const p = resolve(startDir, explicitPath);
179
+ if (!existsSync(p)) throw new ConfigError(`Config file not found: ${p}`);
180
+ return p;
181
+ }
182
+ const upward = findConfigUpward(startDir);
183
+ if (upward) return upward;
184
+ const downward = findConfigDownward(startDir);
185
+ if (downward.length === 1) {
186
+ console.error(
187
+ `govgate: no govgate/config.json at or above ${resolve(startDir)}; using discovered ${downward[0]}`
188
+ );
189
+ return downward[0];
190
+ }
191
+ if (downward.length > 1) {
192
+ throw new ConfigError(
193
+ `Ambiguous config: found ${downward.length} govgate/config.json files under ${resolve(
194
+ startDir
195
+ )} and none at or above it:
196
+ ${downward.map((p) => ` - ${p}`).join(
197
+ "\n"
198
+ )}
199
+ Pick one with --config <path>, pass --suite <slug>, or run govgate from the intended directory.`
200
+ );
201
+ }
202
+ return void 0;
203
+ }
136
204
  function loadFileConfig(path) {
137
205
  let data;
138
206
  try {
@@ -154,6 +222,9 @@ function loadFileConfig(path) {
154
222
  throw new ConfigError(`${path}: "defaultEnvironment" must be a string`);
155
223
  out.defaultEnvironment = cfg.defaultEnvironment;
156
224
  }
225
+ if (cfg.environments !== void 0) {
226
+ out.environments = parseEnvironments(path, cfg.environments);
227
+ }
157
228
  if (cfg.mappings !== void 0) {
158
229
  if (typeof cfg.mappings !== "object" || cfg.mappings === null)
159
230
  throw new ConfigError(`${path}: "mappings" must be an object`);
@@ -169,6 +240,38 @@ function loadFileConfig(path) {
169
240
  }
170
241
  return out;
171
242
  }
243
+ var MAX_ENVIRONMENTS = 20;
244
+ var SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
245
+ function parseEnvironments(path, raw) {
246
+ if (!Array.isArray(raw)) throw new ConfigError(`${path}: "environments" must be an array`);
247
+ if (raw.length > MAX_ENVIRONMENTS)
248
+ throw new ConfigError(`${path}: "environments" supports at most ${MAX_ENVIRONMENTS} entries`);
249
+ const seen = /* @__PURE__ */ new Set();
250
+ const out = [];
251
+ for (const [i, entry] of raw.entries()) {
252
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
253
+ throw new ConfigError(`${path}: environments[${i}] must be an object`);
254
+ const e = entry;
255
+ if (typeof e.slug !== "string" || !SLUG_RE.test(e.slug))
256
+ throw new ConfigError(
257
+ `${path}: environments[${i}].slug must be a lowercase slug (a-z, 0-9, hyphens)`
258
+ );
259
+ if (seen.has(e.slug))
260
+ throw new ConfigError(`${path}: duplicate environment slug "${e.slug}"`);
261
+ seen.add(e.slug);
262
+ if (typeof e.name !== "string" || e.name.trim() === "")
263
+ throw new ConfigError(`${path}: environments[${i}].name must be a non-empty string`);
264
+ if (e.baseUrl !== void 0 && typeof e.baseUrl !== "string")
265
+ throw new ConfigError(`${path}: environments[${i}].baseUrl must be a string`);
266
+ if (e.notes !== void 0 && typeof e.notes !== "string")
267
+ throw new ConfigError(`${path}: environments[${i}].notes must be a string`);
268
+ const decl = { slug: e.slug, name: e.name };
269
+ if (e.baseUrl !== void 0) decl.baseUrl = e.baseUrl;
270
+ if (e.notes !== void 0) decl.notes = e.notes;
271
+ out.push(decl);
272
+ }
273
+ return out;
274
+ }
172
275
  function loadFileMappings(flags, cwd = process.cwd()) {
173
276
  const configPath = findConfigFile(cwd, flags.config);
174
277
  const file = configPath ? loadFileConfig(configPath) : {};
@@ -197,8 +300,15 @@ function resolveConfig(flags, cwd = process.cwd()) {
197
300
  );
198
301
  }
199
302
  if (!suite) {
303
+ if (!configPath) {
304
+ throw new ConfigError(
305
+ `No govgate/config.json found (searched upward from ${resolve(
306
+ cwd
307
+ )}, then downward). Run govgate from your repo root or the artifact/drop folder that contains govgate/, pass --config <path>, or pass --suite <slug>.`
308
+ );
309
+ }
200
310
  throw new ConfigError(
201
- 'Suite missing. Pass --suite <slug> or add { "suite": "<slug>" } to govgate/config.json in the repo root.'
311
+ `Suite missing in ${configPath}. Add { "suite": "<slug>" } to that file or pass --suite <slug>.`
202
312
  );
203
313
  }
204
314
  return {
@@ -206,6 +316,7 @@ function resolveConfig(flags, cwd = process.cwd()) {
206
316
  apiKey,
207
317
  suite,
208
318
  environment,
319
+ environments: file.environments,
209
320
  mappings: file.mappings ?? {},
210
321
  configPath
211
322
  };
@@ -526,6 +637,18 @@ program.command("report").description("Parse test result files (JUnit XML), map
526
637
  const ci = detectCiContext();
527
638
  let runId = opts.runId ?? "";
528
639
  if (!runId) {
640
+ if (config.environments?.length) {
641
+ try {
642
+ const sync = await api.putEnvironments(config.environments);
643
+ if (sync.created > 0) {
644
+ console.log(`Created ${sync.created} declared environment(s).`);
645
+ }
646
+ } catch (e) {
647
+ console.error(
648
+ `Warning: environment sync failed (${e instanceof Error ? e.message : String(e)}) \u2014 continuing.`
649
+ );
650
+ }
651
+ }
529
652
  const run = await api.createRun({
530
653
  suiteSlug: config.suite,
531
654
  environmentSlug: config.environment,
@@ -568,6 +691,30 @@ function tryResolveConfig(flags) {
568
691
  return void 0;
569
692
  }
570
693
  }
694
+ program.command("sync-env").description("Upsert the environments declared in govgate/config.json (idempotent by slug)").option("--url <url>", "tool URL (or GOVGATE_URL)").option("--api-key <key>", "API key (prefer GOVGATE_API_KEY)").option("--config <path>", "path to govgate/config.json (default: searched upward)").action(async (opts) => {
695
+ try {
696
+ const config = resolveConfig({
697
+ url: opts.url,
698
+ apiKey: opts.apiKey,
699
+ suite: "unused",
700
+ // environments sync does not involve a suite
701
+ config: opts.config
702
+ });
703
+ if (!config.environments?.length) {
704
+ throw new ConfigError(
705
+ `No "environments" declared in ${config.configPath ?? "govgate/config.json"}. Add e.g. { "environments": [{ "slug": "dev", "name": "DEV" }] } to that file.`
706
+ );
707
+ }
708
+ const api = new ApiClient(config.url, config.apiKey);
709
+ const res = await api.putEnvironments(config.environments);
710
+ console.log(`Environments synced: ${res.created} created, ${res.updated} updated.`);
711
+ for (const e of res.environments) {
712
+ console.log(` ${e.slug} ${e.name}${e.baseUrl ? ` ${e.baseUrl}` : ""}`);
713
+ }
714
+ } catch (e) {
715
+ handleError(e);
716
+ }
717
+ });
571
718
  program.command("complete").description("Complete a run and evaluate the gate (final job of a multi-job pipeline)").requiredOption("--run-id <uuid>", "run to complete").option("--url <url>", "tool URL (or GOVGATE_URL)").option("--api-key <key>", "API key (prefer GOVGATE_API_KEY)").option("--config <path>", "path to govgate/config.json").option("--suite <slug>", "suite slug (only used for config resolution)").option("--fail-on <list>", "statuses that fail the gate: fail | fail,blocked", "fail").option("--min-executed <pct>", "minimum executed percentage required by the gate").action(async (opts) => {
572
719
  try {
573
720
  const config = resolveConfig({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "govgate",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Governance gate for releases: report CI test results (JUnit XML) to your testing tool and gate promotion on the outcome.",
5
5
  "license": "MIT",
6
6
  "author": "HatStack",