govgate 0.3.3 → 0.5.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 +37 -1
  2. package/dist/index.js +121 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,7 +23,8 @@ standard reporter.
23
23
  | `--api-key <key>` | `GOVGATE_API_KEY` | API key (env var strongly preferred) |
24
24
  | `--config <path>` | `govgate/config.json` searched upward | Config file |
25
25
  | `--suite <slug>` | config `suite` | Target suite |
26
- | `--env <slug>` | config `defaultEnvironment` | Environment for the run |
26
+ | `--env <slug>` | inferred, else config `defaultEnvironment` | Environment for the run (overrides inference) |
27
+ | `--base-url <url>` | `API_BASEURL` | Live app base URL; matched against the declared environments' `baseUrl`s to infer the environment |
27
28
  | `--name <name>` | derived from CI context | Run name |
28
29
  | `--build-version <v>` | short commit SHA from CI | Build version |
29
30
  | `--external-url <url>` | CI build URL | "Build ↗" link on the run page |
@@ -40,6 +41,32 @@ standard reporter.
40
41
  Completes a run left open by `--no-complete` and evaluates the gate over **all** results
41
42
  in the run (including those posted by other jobs).
42
43
 
44
+ ### `govgate sync-env`
45
+
46
+ Upserts the environments declared in `govgate/config.json` to the tool. Idempotent by
47
+ slug: an existing `dev` is updated in place, never duplicated. Takes `--url`,
48
+ `--api-key`, `--config` (same resolution as `report`). `report` also runs this sync
49
+ automatically before creating a run when the block is present, so a brand-new stage
50
+ can never fail on an unknown environment — the explicit command exists for
51
+ onboarding-time provisioning and for verifying the catalog without reporting.
52
+
53
+ ## Environment inference (v0.5.0+)
54
+
55
+ When `--env` is absent, `report` resolves the environment **from the deployment
56
+ under test**: the base URL (from `--base-url` or the `API_BASEURL` env var — the
57
+ same variable your smoke tests already use) is matched against the declared
58
+ environments' `baseUrl`s, and the matching slug wins. Falls back to
59
+ `defaultEnvironment` when nothing matches. Matching ignores scheme, casing, and
60
+ trailing slashes.
61
+
62
+ This makes one identical report step correct in every stage — no per-stage
63
+ `--env` variable to configure or mis-copy. The chosen slug is always printed to
64
+ the CI log.
65
+
66
+ ```
67
+ npx govgate report "test-results/*.xml" --fail-on fail # env inferred from API_BASEURL
68
+ ```
69
+
43
70
  ## Exit codes
44
71
 
45
72
  | Code | Meaning |
@@ -67,6 +94,15 @@ Resolution per test (results are unioned):
67
94
  {
68
95
  "suite": "checkout-regression",
69
96
  "defaultEnvironment": "dev",
97
+ // Code-first environment catalog (v0.4.0+): synced to the tool by slug
98
+ // (existing slugs update in place — never duplicated). Slugs are scoped per
99
+ // application, so the plain dev/uat/prod trio is safe for every service; add
100
+ // more (test, pre-prod, demo, …) as needed, up to 20.
101
+ "environments": [
102
+ { "slug": "dev", "name": "DEV", "baseUrl": "https://dev.example.com" },
103
+ { "slug": "uat", "name": "UAT" },
104
+ { "slug": "prod", "name": "PROD", "baseUrl": "https://www.example.com", "notes": "Live." }
105
+ ],
70
106
  "mappings": {
71
107
  "4": ["checkout.spec.ts::pays with saved card"],
72
108
  "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
  }
@@ -217,6 +222,9 @@ function loadFileConfig(path) {
217
222
  throw new ConfigError(`${path}: "defaultEnvironment" must be a string`);
218
223
  out.defaultEnvironment = cfg.defaultEnvironment;
219
224
  }
225
+ if (cfg.environments !== void 0) {
226
+ out.environments = parseEnvironments(path, cfg.environments);
227
+ }
220
228
  if (cfg.mappings !== void 0) {
221
229
  if (typeof cfg.mappings !== "object" || cfg.mappings === null)
222
230
  throw new ConfigError(`${path}: "mappings" must be an object`);
@@ -232,6 +240,60 @@ function loadFileConfig(path) {
232
240
  }
233
241
  return out;
234
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
+ }
275
+ function normalizeBaseUrl(raw) {
276
+ const trimmed = raw.trim();
277
+ if (trimmed === "") return void 0;
278
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
279
+ let url;
280
+ try {
281
+ url = new URL(withScheme);
282
+ } catch {
283
+ return void 0;
284
+ }
285
+ const path = url.pathname.replace(/\/+$/, "");
286
+ return `${url.host}${path}`.toLowerCase();
287
+ }
288
+ function inferEnvironment(environments, baseUrl) {
289
+ if (!baseUrl || !environments?.length) return void 0;
290
+ const target = normalizeBaseUrl(baseUrl);
291
+ if (!target) return void 0;
292
+ for (const env of environments) {
293
+ if (env.baseUrl && normalizeBaseUrl(env.baseUrl) === target) return env.slug;
294
+ }
295
+ return void 0;
296
+ }
235
297
  function loadFileMappings(flags, cwd = process.cwd()) {
236
298
  const configPath = findConfigFile(cwd, flags.config);
237
299
  const file = configPath ? loadFileConfig(configPath) : {};
@@ -248,7 +310,22 @@ function resolveConfig(flags, cwd = process.cwd()) {
248
310
  const url = flags.url ?? process.env.GOVGATE_URL;
249
311
  const apiKey = flags.apiKey ?? process.env.GOVGATE_API_KEY;
250
312
  const suite = flags.suite ?? file.suite;
251
- const environment = flags.env ?? file.defaultEnvironment;
313
+ let environment = flags.env;
314
+ if (!environment) {
315
+ const liveBaseUrl = flags.baseUrl ?? process.env.API_BASEURL;
316
+ const inferred = inferEnvironment(file.environments, liveBaseUrl);
317
+ if (inferred) {
318
+ console.error(`govgate: environment '${inferred}' inferred from base URL ${liveBaseUrl}`);
319
+ environment = inferred;
320
+ } else {
321
+ if (liveBaseUrl && file.environments?.some((e) => e.baseUrl)) {
322
+ console.error(
323
+ `govgate: base URL ${liveBaseUrl} matches no declared environment baseUrl` + (file.defaultEnvironment ? `; falling back to defaultEnvironment '${file.defaultEnvironment}'` : " and no defaultEnvironment is set")
324
+ );
325
+ }
326
+ environment = file.defaultEnvironment;
327
+ }
328
+ }
252
329
  if (!url) {
253
330
  throw new ConfigError(
254
331
  "Tool URL missing. Set GOVGATE_URL or pass --url https://<your-deployment>"
@@ -276,6 +353,7 @@ function resolveConfig(flags, cwd = process.cwd()) {
276
353
  apiKey,
277
354
  suite,
278
355
  environment,
356
+ environments: file.environments,
279
357
  mappings: file.mappings ?? {},
280
358
  configPath
281
359
  };
@@ -561,14 +639,18 @@ async function completeAndGate(api, url, runId, gate) {
561
639
  if (!verdict.passed) process.exit(EXIT_GATE_FAILED);
562
640
  }
563
641
  var program = new Command().name("govgate").description("Report CI test results to your testing tool and gate releases on the outcome.");
564
- program.command("report").description("Parse test result files (JUnit XML), map tests to cases, and report a run").argument("<files...>", "result files or globs, e.g. test-results/*.xml").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)").option("--suite <slug>", "suite slug (overrides config)").option("--env <slug>", "environment slug (overrides config)").option("--name <name>", "run name (default: derived from CI context)").option("--build-version <v>", "build version (default: short commit SHA from CI)").option("--external-url <url>", "link to the build/PR (default: from CI context)").option("--format <format>", "input format", "junit").option("--run-id <uuid>", "append results to an existing run instead of creating one").option("--no-complete", "leave the run in progress (multi-job pipelines); skips the gate").option("--fail-on <list>", "statuses that fail the gate: fail | fail,blocked", "fail").option("--min-executed <pct>", "minimum executed percentage required by the gate").option("--actor <label>", "tested-by label on results", "govgate").option("--dry-run", "parse and map only; post nothing").action(async (files, opts) => {
642
+ program.command("report").description("Parse test result files (JUnit XML), map tests to cases, and report a run").argument("<files...>", "result files or globs, e.g. test-results/*.xml").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)").option("--suite <slug>", "suite slug (overrides config)").option("--env <slug>", "environment slug (overrides config and inference)").option(
643
+ "--base-url <url>",
644
+ "base URL of the app under test; matched against the declared environments' baseUrls to infer the environment (default: API_BASEURL)"
645
+ ).option("--name <name>", "run name (default: derived from CI context)").option("--build-version <v>", "build version (default: short commit SHA from CI)").option("--external-url <url>", "link to the build/PR (default: from CI context)").option("--format <format>", "input format", "junit").option("--run-id <uuid>", "append results to an existing run instead of creating one").option("--no-complete", "leave the run in progress (multi-job pipelines); skips the gate").option("--fail-on <list>", "statuses that fail the gate: fail | fail,blocked", "fail").option("--min-executed <pct>", "minimum executed percentage required by the gate").option("--actor <label>", "tested-by label on results", "govgate").option("--dry-run", "parse and map only; post nothing").action(async (files, opts) => {
565
646
  try {
566
647
  const flags = {
567
648
  url: opts.url,
568
649
  apiKey: opts.apiKey,
569
650
  suite: opts.suite,
570
651
  env: opts.env,
571
- config: opts.config
652
+ config: opts.config,
653
+ baseUrl: opts.baseUrl
572
654
  };
573
655
  const tests = await parseFiles(files, opts.format);
574
656
  const config = opts.dryRun ? tryResolveConfig(flags) : resolveConfig(flags);
@@ -596,6 +678,18 @@ program.command("report").description("Parse test result files (JUnit XML), map
596
678
  const ci = detectCiContext();
597
679
  let runId = opts.runId ?? "";
598
680
  if (!runId) {
681
+ if (config.environments?.length) {
682
+ try {
683
+ const sync = await api.putEnvironments(config.environments);
684
+ if (sync.created > 0) {
685
+ console.log(`Created ${sync.created} declared environment(s).`);
686
+ }
687
+ } catch (e) {
688
+ console.error(
689
+ `Warning: environment sync failed (${e instanceof Error ? e.message : String(e)}) \u2014 continuing.`
690
+ );
691
+ }
692
+ }
599
693
  const run = await api.createRun({
600
694
  suiteSlug: config.suite,
601
695
  environmentSlug: config.environment,
@@ -638,6 +732,30 @@ function tryResolveConfig(flags) {
638
732
  return void 0;
639
733
  }
640
734
  }
735
+ 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) => {
736
+ try {
737
+ const config = resolveConfig({
738
+ url: opts.url,
739
+ apiKey: opts.apiKey,
740
+ suite: "unused",
741
+ // environments sync does not involve a suite
742
+ config: opts.config
743
+ });
744
+ if (!config.environments?.length) {
745
+ throw new ConfigError(
746
+ `No "environments" declared in ${config.configPath ?? "govgate/config.json"}. Add e.g. { "environments": [{ "slug": "dev", "name": "DEV" }] } to that file.`
747
+ );
748
+ }
749
+ const api = new ApiClient(config.url, config.apiKey);
750
+ const res = await api.putEnvironments(config.environments);
751
+ console.log(`Environments synced: ${res.created} created, ${res.updated} updated.`);
752
+ for (const e of res.environments) {
753
+ console.log(` ${e.slug} ${e.name}${e.baseUrl ? ` ${e.baseUrl}` : ""}`);
754
+ }
755
+ } catch (e) {
756
+ handleError(e);
757
+ }
758
+ });
641
759
  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) => {
642
760
  try {
643
761
  const config = resolveConfig({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "govgate",
3
- "version": "0.3.3",
3
+ "version": "0.5.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",