breakaway 2.0.0-main.60 → 2.0.0-main.62

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "2.0.0-main.60",
3
+ "version": "2.0.0-main.62",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "breakaway",
9
- "version": "2.0.0-main.60",
9
+ "version": "2.0.0-main.62",
10
10
  "license": "FSL-1.1-Apache-2.0",
11
11
  "bin": {
12
12
  "breakaway": "scripts/tasks.mjs"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "2.0.0-main.60",
3
+ "version": "2.0.0-main.62",
4
4
  "description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -24,6 +24,7 @@
24
24
  "src/infra-check.js",
25
25
  "src/infra-currency.js",
26
26
  "src/infra-desired.js",
27
+ "src/infra-environments.js",
27
28
  "src/infra-inventory.js",
28
29
  "src/infra-cloudflare.js",
29
30
  "src/infra-cloudflare-analytics.js",
@@ -29,7 +29,7 @@ export const SUBCOMMANDS = {
29
29
  'listen',
30
30
  ],
31
31
  specs: ['list', 'show'],
32
- infra: ['init', 'runner', 'check', 'act', ...INFRA_READS],
32
+ infra: ['init', 'runner', 'check', 'adopt', 'act', ...INFRA_READS],
33
33
  };
34
34
 
35
35
  /** Commands that take nothing after their name, so a word there is a mistake (an old copy's missing subcommand, say). */
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `npx breakaway infra adopt <environment>` (CLI-23, docs/specs/IDEA-19-architect.md, "Desired state"): writes the
3
+ * board's draft of an environment's desired state into the checkout, so nobody hand-writes
4
+ * `.github/breakaway-infra/<environment>.json`. The board drafts it from what runs there (BRK-240,
5
+ * GET /api/infra/environments/<environment>/draft, read only, so an agent's token may ask); this writes it where it
6
+ * goes, then checks it the way `infra check` does and says what to commit.
7
+ *
8
+ * Files only: it never makes a plan and never applies anything. It never overwrites a file without --force, and it
9
+ * writes nothing for an observe-only environment, which takes no desired state. The path is worked out here from the
10
+ * environment's name, never taken from the board's answer, so the file can't land anywhere else.
11
+ */
12
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { checkDesiredFile, desiredPath, environmentOfFile } from '../../src/infra-desired.js';
15
+ import { infraCheck, readInfraFolder } from './infra-check.js';
16
+
17
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
18
+
19
+ /**
20
+ * Runs `infra adopt`. `code` is what the CLI exits with: 1 when nothing was written, or the written file doesn't check.
21
+ * @param {string[]} args what follows `infra adopt`: the environment's name (or ID)
22
+ * @param {{ root: string, repo: string | null, force?: boolean, dryRun?: boolean,
23
+ * get: (path: string) => Promise<{ ok: boolean, status: number, data: any }>,
24
+ * post: (path: string, body: any) => Promise<{ ok: boolean, status: number, data: any }> }} ctx
25
+ * @returns {Promise<{ code: number, data: any, text: string }>}
26
+ */
27
+ export async function infraAdopt(args, { root, repo, get, post, force = false, dryRun = false }) {
28
+ const refused = (error, text = `${error}.`) => ({ code: 1, data: { ok: false, written: false, error }, text });
29
+ const ref = args[0];
30
+ if (!ref)
31
+ return refused(
32
+ 'name the environment',
33
+ 'Name the environment: infra adopt <environment>. npx breakaway infra lists this repository’s.',
34
+ );
35
+
36
+ const res = await get(
37
+ `infra/environments/${encodeURIComponent(ref)}/draft${repo ? `?repo=${encodeURIComponent(repo)}` : ''}`,
38
+ );
39
+ if (!res.ok) {
40
+ if (res.status === 404 && /^no route for/u.test(String(res.data?.error ?? '')))
41
+ return refused(
42
+ 'this board has no draft yet',
43
+ 'This board can’t draft a desired state yet: its owner updates it to a release that can, then try again.',
44
+ );
45
+ const error = res.status === 0 ? `can’t reach the board (${res.data?.error ?? 'no answer'})` : res.data?.error;
46
+ return refused(error ?? `HTTP ${res.status}`, `Nothing was written: ${error ?? `HTTP ${res.status}`}.`);
47
+ }
48
+
49
+ const draft = res.data?.draft;
50
+ const named = environmentOfFile(`${draft?.environment ?? ''}.json`);
51
+ if (!draft || typeof draft.json !== 'string' || !named || !('environment' in named))
52
+ return refused('the board’s draft isn’t one this CLI can read: update npx breakaway, then try again');
53
+ const environment = named.environment;
54
+ const path = desiredPath(environment);
55
+ const notes = Array.isArray(draft.notes) ? draft.notes.map(String) : [];
56
+ const data = { environment, path, resources: draft.resources ?? 0, observeOnly: Boolean(draft.observeOnly), notes };
57
+ const withNotes = (lines) => [...lines, ...(notes.length ? ['', ...notes.map((n) => `- ${n}`)] : [])];
58
+
59
+ if (draft.observeOnly)
60
+ return {
61
+ code: 1,
62
+ data: { ...data, ok: false, written: false, error: `${environment} is observe only` },
63
+ text: withNotes([
64
+ `Nothing was written: ${environment} is observe only, so it takes no desired state and the board never changes it.`,
65
+ ]).join('\n'),
66
+ };
67
+ // The board checks its draft before it answers, so this only fails when the two disagree: write nothing bad.
68
+ const checked = checkDesiredFile(draft.json, {});
69
+ if ('error' in checked)
70
+ return refused(
71
+ `the draft doesn’t check at ${checked.error.field ?? 'the top'}: ${checked.error.message}`,
72
+ `Nothing was written: the board’s draft for ${environment} doesn’t check here (${checked.error.field ?? 'the top'}: ${checked.error.message}). Update npx breakaway, then try again.`,
73
+ );
74
+
75
+ const file = join(root, path);
76
+ const exists = existsSync(file);
77
+ if (exists && !force)
78
+ return {
79
+ code: 1,
80
+ data: { ...data, ok: false, written: false, error: `${path} is already there` },
81
+ text: `Nothing was written: ${path} is already there. See the draft with --dry-run, or replace the file with --force.`,
82
+ };
83
+
84
+ if (dryRun)
85
+ return {
86
+ code: 0,
87
+ data: { ...data, ok: true, written: false, json: draft.json },
88
+ text: withNotes([
89
+ `Would ${exists ? 'replace' : 'write'} ${path}, ${plural(data.resources, 'resource')}:`,
90
+ '',
91
+ draft.json.trimEnd(),
92
+ ]).join('\n'),
93
+ };
94
+
95
+ mkdirSync(dirname(file), { recursive: true });
96
+ writeFileSync(file, draft.json);
97
+ const check = await infraCheck([environment], { files: readInfraFolder(root), repo, post });
98
+ const next =
99
+ check.code === 0
100
+ ? [
101
+ '',
102
+ `Next: commit ${path} and open a pull request. Nothing changes until it merges: then the board plans from it, and the plan waits for you.`,
103
+ ]
104
+ : ['', `Fix what’s above, then npx breakaway infra check ${environment} before you commit ${path}.`];
105
+ return {
106
+ code: check.code,
107
+ data: { ...data, ok: check.code === 0, written: true, replaced: exists, check: check.data },
108
+ text: [
109
+ ...withNotes([`${exists ? 'Replaced' : 'Wrote'} ${path}, ${plural(data.resources, 'resource')}.`]),
110
+ '',
111
+ check.text,
112
+ ...next,
113
+ ].join('\n'),
114
+ };
115
+ }
package/scripts/tasks.mjs CHANGED
@@ -434,6 +434,10 @@ Infrastructure (init and add need no board: run them in the checkout of the re
434
434
  run it holds (--task <ID>, else the one routine run it has claimed here); it says whether
435
435
  the owner's envelope covers it or it waits for the owner, and names the plan. Only a
436
436
  runbook's run may; it's the one change a run may ask for, and it never applies anything
437
+ infra adopt <environment> write the board's draft of the environment's desired state, drafted from what runs
438
+ there, to .github/breakaway-infra/<environment>.json, then check it like infra check and say
439
+ what to commit; it never plans or applies, and writes nothing for an observe-only environment
440
+ [--force] replaces a file that's there [--dry-run] prints the draft instead
437
441
  infra add [<template>] [<environment>] [<input>=<value> …] a golden path: write the change one of the owner's
438
442
  templates makes (.github/breakaway-infra/templates/<name>/, else breakaway's example of that
439
443
  name) into the environment's file and the code it needs, for a pull request; it never
@@ -1441,6 +1445,22 @@ const commands = {
1441
1445
  process.exitCode = result.code;
1442
1446
  return;
1443
1447
  }
1448
+ if (args[0] === 'adopt') {
1449
+ // infra adopt (CLI-23): the board's draft of an environment's desired state, written into the checkout and checked.
1450
+ const { infraAdopt } = await import('./tasks/infra-adopt.js');
1451
+ const { topOf } = await import('./tasks/pipeline.js');
1452
+ const result = await infraAdopt(args.slice(1), {
1453
+ root: topOf(process.cwd()),
1454
+ repo: slug,
1455
+ force: Boolean(opts.force),
1456
+ dryRun: Boolean(opts['dry-run']),
1457
+ get: (path) => call('GET', path, undefined, { raw: true }),
1458
+ post: (path, body) => call('POST', path, body, { raw: true }),
1459
+ });
1460
+ print(result.data, () => result.text);
1461
+ process.exitCode = result.code;
1462
+ return;
1463
+ }
1444
1464
  if (args[0] === 'act') {
1445
1465
  // infra act (CLI-24): a runbook's agent asks for one scale or restart, for the run it holds; the board decides.
1446
1466
  const { infraAct, InfraActError } = await import('./tasks/infra-act.js');
@@ -17,8 +17,11 @@ import { redact } from './redact.js';
17
17
  export const DESIRED_DIR = '.github/breakaway-infra';
18
18
  /** @param {string} environment */
19
19
  export const desiredPath = (environment) => `${DESIRED_DIR}/${environment}.json`;
20
- /** Files in the folder that aren't an environment's: policy (BRK-181) and scaling rules (BRK-186). */
21
- export const RESERVED_FILES = ['policy', 'scaling'];
20
+ /**
21
+ * Files in the folder that aren't an environment's: policy (BRK-181), scaling rules (BRK-186), and the template
22
+ * short-lived environments are made from (BRK-200).
23
+ */
24
+ export const RESERVED_FILES = ['policy', 'scaling', 'short-lived'];
22
25
  /** The only version there is. */
23
26
  export const DESIRED_VERSION = 1;
24
27
  /** Bigger than any one environment needs, and small enough to keep a copy of each in the store. */
@@ -204,10 +207,11 @@ function secretIn(value, path) {
204
207
  * resource's kind must be one it declares. With `expectProvider` (the environment's provider ID), a `provider` in the
205
208
  * file must be that one.
206
209
  * @param {string} source the file's text
207
- * @param {{ provider?: import('./infra-provider.js').Provider | null, expectProvider?: string | null }} [options]
210
+ * @param {{ provider?: import('./infra-provider.js').Provider | null, expectProvider?: string | null, extra?: string[] }} [options]
211
+ * `extra` names more top-level keys a caller reads itself, like a short-lived template's `target` (BRK-200)
208
212
  * @returns {{ ok: true, desired: import('./infra-provider.js').DesiredState, provider: string | null } | { ok: false, error: DesiredError }}
209
213
  */
210
- export function checkDesiredFile(source, { provider = null, expectProvider = null } = {}) {
214
+ export function checkDesiredFile(source, { provider = null, expectProvider = null, extra = [] } = {}) {
211
215
  /** @param {string | null} field @param {string} message @param {number | null} [at] */
212
216
  const wrong = (field, message, at) => ({
213
217
  ok: /** @type {const} */ (false),
@@ -228,8 +232,11 @@ export function checkDesiredFile(source, { provider = null, expectProvider = nul
228
232
  if (!isObject(file))
229
233
  return wrong(null, 'the file is one JSON object: { "version": 1, "resources": [ … ] }', lines.get('') ?? 1);
230
234
  for (const key of Object.keys(file))
231
- if (!TOP.includes(key))
232
- return wrong(key, `“${key}” isn’t part of a desired state: the file has version, provider, and resources`);
235
+ if (!TOP.includes(key) && !extra.includes(key))
236
+ return wrong(
237
+ key,
238
+ `“${key}” isn’t part of a desired state: the file has ${[...TOP, ...extra].join(', ').replace(/, (?=[^,]*$)/u, ', and ')}`,
239
+ );
233
240
  if (file.version !== DESIRED_VERSION)
234
241
  return wrong(
235
242
  file.version === undefined ? null : 'version',
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Architect's environments (docs/specs/IDEA-19-architect.md, "Environments"; BRK-174): a named target in one of the
3
+ * board's repositories that plans apply to. Pure, so the rules are tested without the Durable Object; the table and
4
+ * the API are in store-infra-environments.js.
5
+ */
6
+ import { RESERVED_FILES } from './infra-desired.js';
7
+ import { InputError } from './model.js';
8
+
9
+ /** What an environment is for; production gates apply to `production` unless the owner says otherwise. */
10
+ export const ENVIRONMENT_KINDS = ['production', 'staging', 'short-lived'];
11
+ /** Per repository: enough for staging, production, and a handful of short-lived ones at once. */
12
+ export const MAX_ENVIRONMENTS = 50;
13
+
14
+ /** A name that's also a file name: `.github/breakaway-infra/<environment>.json` (BRK-180). */
15
+ const NAME = /^[a-z0-9][a-z0-9-]{0,39}$/u;
16
+ /** A provider's ID in the registry (BRK-173), like `cloudflare`. */
17
+ const PROVIDER = /^[a-z][a-z0-9-]{0,31}$/u;
18
+ /** What the environment points at on its provider, like a Worker's name. */
19
+ const TARGET = /^[\w.:/-]{1,100}$/u;
20
+
21
+ /** @param {unknown} value */
22
+ export function checkEnvironmentName(value) {
23
+ const name = String(value ?? '').trim();
24
+ if (!NAME.test(name))
25
+ throw new InputError(
26
+ 'name the environment with lowercase letters, digits, and - (up to 40), like staging or preview-12: it’s also its desired-state file’s name',
27
+ );
28
+ if (RESERVED_FILES.includes(name))
29
+ throw new InputError(
30
+ `${name} is the name of Architect’s ${name} file, so an environment can’t have it: pick another`,
31
+ );
32
+ return name;
33
+ }
34
+
35
+ /** @param {unknown} value */
36
+ export function checkEnvironmentKind(value) {
37
+ const kind = String(value ?? '').trim();
38
+ if (!ENVIRONMENT_KINDS.includes(kind)) throw new InputError('kind is production, staging, or short-lived');
39
+ return kind;
40
+ }
41
+
42
+ /** @param {unknown} value */
43
+ export function checkProvider(value) {
44
+ const provider = String(value ?? '').trim();
45
+ if (!PROVIDER.test(provider))
46
+ throw new InputError('provider is the ID of the provider the environment runs on, like cloudflare');
47
+ return provider;
48
+ }
49
+
50
+ /** @param {unknown} value @returns {string | null} */
51
+ export function checkTarget(value) {
52
+ if (value === null || value === undefined || value === '') return null;
53
+ const target = String(value).trim();
54
+ if (!TARGET.test(target))
55
+ throw new InputError(
56
+ 'target is what the environment runs on at its provider, like a Worker’s name (up to 100 characters)',
57
+ );
58
+ return target;
59
+ }
60
+
61
+ /** @param {unknown} value @param {string} field */
62
+ export function checkSwitch(value, field) {
63
+ if (typeof value !== 'boolean') throw new InputError(`${field} is true or false`);
64
+ return value;
65
+ }
66
+
67
+ /**
68
+ * Whether an environment runs the board's own install: it points at the install's Worker (src/install.js). That one
69
+ * is always observe only (BRK-169), whatever its row says, so Architect never applies to the board it runs on.
70
+ * @param {{ target: string | null }} row
71
+ * @param {string} worker the install's Worker
72
+ */
73
+ export const runsTheBoard = (row, worker) => Boolean(row.target) && row.target === worker;
74
+
75
+ /**
76
+ * The environment as the API shows it.
77
+ * @param {{ id: number, repo: string, name: string, kind: string, provider: string | null, target: string | null, task: string | null, frozen: number, frozen_at: number | null, gates: number | null, observe_only: number, pipeline?: string | null, created: number, edited: number }} row
78
+ * @param {{ worker: string, task?: { uuid: string, wid: string | null, description: string } | null }} context
79
+ */
80
+ export function environmentView(row, { worker, task = null }) {
81
+ const own = runsTheBoard(row, worker);
82
+ return {
83
+ id: row.id,
84
+ repo: row.repo,
85
+ name: row.name,
86
+ kind: row.kind,
87
+ provider: row.provider ?? null,
88
+ target: row.target ?? null,
89
+ task,
90
+ frozen: Boolean(row.frozen),
91
+ frozenAt: row.frozen && row.frozen_at ? new Date(row.frozen_at).toISOString() : null,
92
+ gates: row.gates === null || row.gates === undefined ? row.kind === 'production' : Boolean(row.gates),
93
+ observeOnly: own || Boolean(row.observe_only),
94
+ runsTheBoard: own,
95
+ // `staging` or `production` when it's its repository's pipeline's (BRK-195), else null.
96
+ pipeline: row.pipeline ?? null,
97
+ created: new Date(row.created).toISOString(),
98
+ edited: new Date(row.edited).toISOString(),
99
+ };
100
+ }
@@ -21,10 +21,10 @@ export const PLAN_STATES = ['draft', 'waiting', 'approved', 'rejected', 'applyin
21
21
 
22
22
  /**
23
23
  * Where a plan comes from: a pull request that changed a desired-state file (BRK-185), drift (BRK-184), an envelope
24
- * acting (BRK-186), an incident's runbook (BRK-197), the deploy flow's Promote and Roll back (BRK-195), or removing
25
- * what nobody owns once its grace period is over (BRK-201).
24
+ * acting (BRK-186), an incident's runbook (BRK-197), the deploy flow's Promote and Roll back (BRK-195), removing what
25
+ * nobody owns once its grace period is over (BRK-201), or a task's short-lived environment being made or removed (BRK-200).
26
26
  */
27
- export const PLAN_SOURCES = ['pull-request', 'drift', 'envelope', 'incident', 'deploy', 'cleanup'];
27
+ export const PLAN_SOURCES = ['pull-request', 'drift', 'envelope', 'incident', 'deploy', 'cleanup', 'short-lived'];
28
28
 
29
29
  /**
30
30
  * Which states a plan may move to from each. A draft waits for the owner or is dropped; only an approved plan is
@@ -25,6 +25,7 @@
25
25
  import { CHANGE_KINDS } from './infra-provider.js';
26
26
  import { DESIRED_DIR, parseWithLines } from './infra-desired.js';
27
27
  import { rateWords } from './infra-currency.js';
28
+ import { ENVIRONMENT_KINDS } from './infra-environments.js';
28
29
 
29
30
  /** @typedef {import('./infra-provider.js').PlanDiff} PlanDiff */
30
31
  /** @typedef {import('./infra-provider.js').Change} Change */
@@ -67,6 +68,8 @@ export const DEFAULT_POLICY = Object.freeze({
67
68
  * @typedef {object} AllowRule
68
69
  * @property {string} name what the plan and the audit trail call it
69
70
  * @property {string[]} [environments] the environments it covers, by name; every one when left out
71
+ * @property {string[]} [environmentKinds] the kinds of environment it covers (ENVIRONMENT_KINDS), like `short-lived`
72
+ * for the environments tasks ask for (BRK-200), whose names aren't known ahead; every kind when left out
70
73
  * @property {string[]} [changes] the change kinds it covers (CHANGE_KINDS); every one when left out
71
74
  * @property {string[]} [kinds] the resource kinds it covers; every one when left out
72
75
  * @property {number} [maxChanges] the most changes a plan it lets through has
@@ -86,7 +89,7 @@ export const DEFAULT_POLICY = Object.freeze({
86
89
  const TOP = ['version', 'costLimit', 'budget', 'environments', 'access', 'allow'];
87
90
  const LIMITS = ['costLimit', 'budget'];
88
91
  const ACCESS = ['kinds', 'settings'];
89
- const RULE = ['name', 'environments', 'changes', 'kinds', 'maxChanges'];
92
+ const RULE = ['name', 'environments', 'environmentKinds', 'changes', 'kinds', 'maxChanges'];
90
93
  const ENV_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/u;
91
94
  const KIND = /^[a-z][a-z0-9-]{0,39}$/u;
92
95
  const SETTING = /^[A-Za-z_][\w.-]{0,79}$/u;
@@ -239,6 +242,22 @@ export function checkPolicyFile(source) {
239
242
  if (bad) return bad;
240
243
  rule.environments = [...r.environments];
241
244
  }
245
+ if (r.environmentKinds !== undefined) {
246
+ const bad = names(
247
+ r.environmentKinds,
248
+ `${at}.environmentKinds`,
249
+ KIND,
250
+ `a kind of environment: ${ENVIRONMENT_KINDS.join(', ')}`,
251
+ );
252
+ if (bad) return bad;
253
+ const unknown = r.environmentKinds.findIndex((k) => !ENVIRONMENT_KINDS.includes(k));
254
+ if (unknown >= 0)
255
+ return wrong(
256
+ `${at}.environmentKinds[${unknown}]`,
257
+ `${r.environmentKinds[unknown]} isn’t a kind of environment: ${ENVIRONMENT_KINDS.join(', ')}`,
258
+ );
259
+ rule.environmentKinds = [...r.environmentKinds];
260
+ }
242
261
  if (r.changes !== undefined) {
243
262
  const bad = names(r.changes, `${at}.changes`, KIND, `a change kind: ${CHANGE_KINDS.join(', ')}`);
244
263
  if (bad) return bad;
@@ -369,8 +388,9 @@ const upper = (s) => s.charAt(0).toUpperCase() + s.slice(1);
369
388
  * keeps and shows. Pure: the caller gives the environment as it is now, and the plan's diff and cost change.
370
389
  * @param {Policy} policy
371
390
  * @param {object} plan
372
- * @param {{ name: string, frozen: boolean, gates: boolean }} plan.environment `gates` is production's gates
373
- * (environmentView's `gates`: on for a production environment unless the owner turned them off)
391
+ * @param {{ name: string, kind?: string, frozen: boolean, gates: boolean }} plan.environment `gates` is production's
392
+ * gates (environmentView's `gates`: on for a production environment unless the owner turned them off); `kind` is
393
+ * what an allow rule's `environmentKinds` matches
374
394
  * @param {PlanDiff} plan.diff
375
395
  * @param {CostChange} plan.cost
376
396
  * @param {import('./infra-provider.js').Provider | null} [plan.provider] for the kinds and settings it marks as access
@@ -471,7 +491,7 @@ export function evaluatePolicy(
471
491
  outcome = 'needs-owner';
472
492
  rule = asked[0].rule;
473
493
  } else {
474
- const allowed = policy.allow.find((a) => allows(a, env, diff));
494
+ const allowed = policy.allow.find((a) => allows(a, env, diff, environment.kind));
475
495
  if (allowed) {
476
496
  outcome = 'allowed';
477
497
  rule = allowed.name;
@@ -511,9 +531,11 @@ export function evaluatePolicy(
511
531
  * @param {AllowRule} rule
512
532
  * @param {string} environment
513
533
  * @param {PlanDiff} diff
534
+ * @param {string} [kind] the environment's kind, for a rule with `environmentKinds`
514
535
  */
515
- export function allows(rule, environment, diff) {
536
+ export function allows(rule, environment, diff, kind) {
516
537
  if (rule.environments && !rule.environments.includes(environment)) return false;
538
+ if (rule.environmentKinds && !rule.environmentKinds.includes(String(kind))) return false;
517
539
  if (rule.maxChanges !== undefined && diff.changes.length > rule.maxChanges) return false;
518
540
  return diff.changes.every(
519
541
  (c) => (!rule.changes || rule.changes.includes(c.op)) && (!rule.kinds || rule.kinds.includes(c.kind)),