@brutalsystems/dray 0.1.10 → 0.1.12

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.
package/README.md CHANGED
@@ -45,13 +45,18 @@ dray secrets sync <repo>
45
45
  ```
46
46
 
47
47
  Global flags: `--dry-run` (print the plan, run nothing), `--allow-dirty`
48
- (build a dirty tree, tags `:<sha>-dirty`), `--all` (every registered repo).
48
+ (build a dirty tree, tags `:<sha>-dirty`), `--all` (every registered repo),
49
+ `--skip-ci-check` (deploy even if CI is red — see [CI gate](#ci-gate)).
49
50
 
50
51
  ## `.dray/config.json` (per repo)
51
52
 
52
53
  ```jsonc
53
54
  {
54
55
  "name": "myrepo",
56
+ // optional per-repo overrides of ~/.dray/config.json defaults
57
+ // (profile, region, account, platform, context, namespace, ciGate);
58
+ // a workload may override them again.
59
+ "defaults": { "namespace": "bs", "ciGate": true },
55
60
  "images": [
56
61
  { "name": "app", "ecr": "app", "source": { "local": true },
57
62
  "dockerfile": "Dockerfile", "context": ".",
@@ -93,6 +98,49 @@ Global flags: `--dry-run` (print the plan, run nothing), `--allow-dirty`
93
98
  }
94
99
  ```
95
100
 
101
+ ### CI gate
102
+
103
+ With `"ciGate": true` in a repo's `defaults`, `push`, `apply`, `rollout` and
104
+ `ship` refuse to run unless that repo's GitHub Actions CI is green for the code
105
+ being shipped. It reads `gh run list` (so `gh` must be installed and
106
+ authenticated) and blocks on all three of:
107
+
108
+ - **failed** — CI is red.
109
+ - **pending** — CI is still running. This is the case that motivated the gate:
110
+ a deploy once pushed an image to ECR six minutes before the test suite it was
111
+ racing had finished, and nothing would have stopped a red result.
112
+ - **absent** — no run exists for HEAD *or any recent ancestor*, i.e. CI has
113
+ never run for this line of work. "No run found" has to block: if it passed,
114
+ never pushing would be the way around the gate.
115
+
116
+ It does *not* demand a run for HEAD itself. Workflows commonly use
117
+ `paths-ignore`, so a docs-only commit produces no run at all; requiring one
118
+ would refuse to ship a README change, and a gate that blocks legitimate work
119
+ gets switched off. Instead dray walks back to the **newest CI-covered
120
+ ancestor** and requires that verdict to be green.
121
+
122
+ Default is off, so registering a repo does not change its behavior — arm it
123
+ per repo. `--skip-ci-check` overrides the gate for one command and prints a
124
+ loud warning. The gate also self-disables when `GITHUB_ACTIONS=true` (inside
125
+ CI it would be waiting on the run that invoked it — a deadlock) and on
126
+ `--dry-run`.
127
+
128
+ **What it does not cover**, deliberately:
129
+
130
+ - Images built from `source.git` — their code lives in another repository whose
131
+ CI is not this repo's to read, so those units are skipped rather than falsely
132
+ reported as verified.
133
+ - `rollback`, which re-applies a SHA that was already deployed. Blocking a
134
+ rollback because `main` is red would be exactly backwards during an incident.
135
+ - `publish` (pilets), which does not go through the deploy path.
136
+ - A commit you have not pushed: with no run of its own it falls back to its
137
+ newest covered ancestor, so an unpushed change on top of a green commit
138
+ passes. The gate closes the red/racing cases, not "I never pushed it".
139
+
140
+ It reads the repo's 100 most recent workflow runs. In a repo busy enough that
141
+ HEAD's run has already fallen out of that window, the gate reports `absent` and
142
+ blocks — noisy, but never the wrong way around.
143
+
96
144
  ### Image pinning
97
145
 
98
146
  Manifests reference managed images by a placeholder var — `${APP_IMAGE}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/dray",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Convention-driven multi-repo deploy orchestrator (forge for shipping)",
5
5
  "bin": {
6
6
  "dray": "bin/dray.js"
package/src/cli/index.js CHANGED
@@ -6,6 +6,7 @@ async function run(argv) {
6
6
  program.version(require('../../package.json').version, '-v, --version', 'show dray version');
7
7
  program.option('--dry-run', 'print planned commands, run nothing');
8
8
  program.option('--allow-dirty', 'allow building from a dirty tree (tags :<sha>-dirty)');
9
+ program.option('--skip-ci-check', 'deploy even if CI is red, pending, or absent');
9
10
  for (const c of ['version', 'init', 'add', 'remove', 'reload', 'list',
10
11
  'build', 'push', 'apply', 'rollout', 'ship', 'status', 'rollback', 'publish', 'secrets']) {
11
12
  require(`./commands/${c}`)(program);
@@ -1,9 +1,14 @@
1
1
  const { loadGlobalConfig } = require('../config/global'); const { allRepos } = require('../config/registry');
2
2
  const { resolveTargets } = require('../core/resolve'); const { planFor } = require('../core/plan'); const { execute } = require('../core/engine');
3
- async function runAction(target, action, { dryRun, allowDirty, all }) {
3
+ const { assertCiGreen } = require('../core/ciGate');
4
+ // Actions that publish an artifact or touch the cluster. `build` is local and
5
+ // leaves nothing behind, so it stays ungated.
6
+ const GATED = new Set(['push', 'apply', 'rollout', 'ship']);
7
+ async function runAction(target, action, { dryRun, allowDirty, all, skipCiCheck }) {
4
8
  const registry = allRepos(); const { defaults } = loadGlobalConfig();
5
9
  const spec = all ? '--all' : target;
6
10
  const units = resolveTargets({ registry, globalDefaults: defaults, spec });
11
+ if (GATED.has(action)) await assertCiGreen(units, { dryRun, allowDirty, skipCiCheck });
7
12
  await execute(planFor(units, action), { dryRun, allowDirty });
8
13
  }
9
14
  module.exports = { runAction };
@@ -24,12 +24,17 @@ function validateRepoConfig(cfg) {
24
24
  // Both gate deployment on truthiness, so a JSON string ("false", "no") would
25
25
  // silently skip the workload -- in `manual`'s case, silently drop it from CI.
26
26
  // Fail loud instead of deploying something different from what was meant.
27
- for (const flag of ['disabled', 'manual']) {
27
+ for (const flag of ['disabled', 'manual', 'ciGate']) {
28
28
  if (w[flag] !== undefined && typeof w[flag] !== 'boolean') {
29
29
  e.push(`workload "${w.name}" ${flag} must be a boolean, got ${typeof w[flag]}`);
30
30
  }
31
31
  }
32
32
  }
33
+ // A JSON string ("false") is truthy and would silently ARM the gate -- or, as
34
+ // "true" in the wrong place, silently disarm it. Both are worth failing loud.
35
+ if (cfg.defaults && cfg.defaults.ciGate !== undefined && typeof cfg.defaults.ciGate !== 'boolean') {
36
+ e.push(`defaults.ciGate must be a boolean, got ${typeof cfg.defaults.ciGate}`);
37
+ }
33
38
  for (const s of cfg.secrets || []) {
34
39
  if (!s.name) e.push('secret missing name');
35
40
  if (!SECRET_KINDS.has(s.kind)) e.push(`secret "${s.name}" kind must be sops-manifest|literal-from-env|sops-exec-env`);
@@ -0,0 +1,98 @@
1
+ const real = { gh: require('../primitives/gh'), git: require('../primitives/git') };
2
+
3
+ const PASSING = new Set(['success', 'skipped', 'neutral']);
4
+
5
+ // Pure decision: given HEAD's ancestry (newest first) and the repo's recent
6
+ // workflow runs, may we deploy?
7
+ //
8
+ // Why walk back through history instead of demanding a run for HEAD itself:
9
+ // workflows commonly use paths-ignore, so a docs-only commit produces NO run
10
+ // at all -- not a skipped one. Requiring a run for HEAD would refuse to ship a
11
+ // README change, and a gate that blocks legitimate work is a gate that gets
12
+ // switched off. Instead we require the newest CI-COVERED ancestor to be green:
13
+ // if the commits after it were deemed unable to affect the artifact, the last
14
+ // verdict still stands.
15
+ function evaluate({ commits, runs }) {
16
+ const byShaPrefix = new Map();
17
+ for (const r of runs) {
18
+ if (!r.headSha) continue;
19
+ const list = byShaPrefix.get(r.headSha) || [];
20
+ list.push(r);
21
+ byShaPrefix.set(r.headSha, list);
22
+ }
23
+ for (const sha of commits) {
24
+ const hits = byShaPrefix.get(sha);
25
+ if (!hits || !hits.length) continue; // not CI-covered; keep walking back
26
+ const short = sha.slice(0, 7);
27
+ const pending = hits.filter((r) => r.status !== 'completed');
28
+ if (pending.length) {
29
+ return { ok: false, sha: short, reason: 'pending',
30
+ detail: `CI is still running for ${short} (${pending.map((r) => r.workflowName).join(', ')}). Wait for it to finish.`,
31
+ url: pending[0].url };
32
+ }
33
+ const failed = hits.filter((r) => !PASSING.has(r.conclusion));
34
+ if (failed.length) {
35
+ return { ok: false, sha: short, reason: 'failed',
36
+ detail: `CI failed for ${short}: ${failed.map((r) => `${r.workflowName}=${r.conclusion}`).join(', ')}`,
37
+ url: failed[0].url };
38
+ }
39
+ return { ok: true, sha: short, reason: 'green',
40
+ detail: `CI green for ${short} (${hits.map((r) => r.workflowName).join(', ')})` };
41
+ }
42
+ return { ok: false, reason: 'absent',
43
+ detail: `no CI run found for HEAD or any of its ${commits.length} most recent ancestors. `
44
+ + 'Push the branch and let CI run, or override.' };
45
+ }
46
+
47
+ // Repos to gate, deduped by path. Images sourced from a remote git ref are
48
+ // skipped: their code lives in another repository and its CI is not this
49
+ // repo's to read. That is a real hole -- see README -- not an oversight.
50
+ function gatedRepos(units) {
51
+ const out = new Map();
52
+ for (const u of units) {
53
+ if (!u.defaults || !u.defaults.ciGate) continue;
54
+ if (u.image && u.image.source && u.image.source.git) continue;
55
+ if (!out.has(u.repoPath)) out.set(u.repoPath, u.repo);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ async function assertCiGreen(units, { dryRun = false, allowDirty = false, skipCiCheck = false, deps, log = console.error } = {}) {
61
+ const d = { ...real, ...deps };
62
+ const repos = gatedRepos(units);
63
+ if (!repos.size) return;
64
+ if (dryRun) return;
65
+ // Running inside GitHub Actions, the gate would be checking the very run that
66
+ // invoked it -- always "pending", a guaranteed deadlock. CI reaching dray at
67
+ // all means the pipeline already decided to deploy.
68
+ if (process.env.GITHUB_ACTIONS === 'true') return;
69
+ if (skipCiCheck) {
70
+ for (const [, name] of repos) log(`!! CI gate SKIPPED for ${name} (--skip-ci-check) -- deploying unverified code`);
71
+ return;
72
+ }
73
+ for (const [repoPath, name] of repos) {
74
+ if (allowDirty && await d.git.isDirty(repoPath)) {
75
+ throw new Error(`${name}: working tree is dirty, so no CI run can exist for what you are about to ship.\n`
76
+ + ' Commit and push it, or pass --skip-ci-check to override.');
77
+ }
78
+ let commits, runs;
79
+ try {
80
+ [commits, runs] = await Promise.all([d.gh.recentCommits(repoPath), d.gh.recentRuns(repoPath)]);
81
+ } catch (err) {
82
+ // A gh that cannot answer is not a green suite. Say it in the gate's own
83
+ // voice: a bare "gh CLI not authenticated" reads like an unrelated tool
84
+ // failure rather than like the deploy having been stopped on purpose.
85
+ throw new Error(`${name}: CI gate could not check CI, so it is refusing to deploy.\n ${err.message}`
86
+ + '\n Fix that, or pass --skip-ci-check to deploy without the check.');
87
+ }
88
+ const verdict = evaluate({ commits, runs });
89
+ if (!verdict.ok) {
90
+ throw new Error(`${name}: CI gate blocked this deploy.\n ${verdict.detail}`
91
+ + (verdict.url ? `\n ${verdict.url}` : '')
92
+ + '\n Override with --skip-ci-check if you must ship anyway.');
93
+ }
94
+ log(`✓ ${name}: ${verdict.detail}`);
95
+ }
96
+ }
97
+
98
+ module.exports = { evaluate, gatedRepos, assertCiGreen };
@@ -52,9 +52,25 @@ async function execute(steps, { dryRun = false, allowDirty = false, deps } = {})
52
52
  const vars = {}; for (const s of u.stamp) vars[s.var] = `${s.repoUri}:${sha}`;
53
53
  const files = d.render.renderManifests(u.manifests, vars, u.repoPath, { dryRun });
54
54
  if (files[0]) renderDirs.push(path.dirname(files[0]));
55
+ // Read the running image BEFORE applying. If it differs from what we
56
+ // are about to stamp, this apply changes the pod template and the
57
+ // Deployment controller starts a rollout on its own — so the rollout
58
+ // step that follows must wait rather than restart, or every ship
59
+ // produces two ReplicaSets and replaces the pod twice.
60
+ //
61
+ // Deliberately conservative: any doubt (dry run, cronjob, unreadable
62
+ // deployment) leaves the restart in place, which is the previous
63
+ // behaviour.
64
+ u._applyStartsRollout = false;
65
+ if (!dryRun && u.workload && u.kind !== 'cronjob') {
66
+ try {
67
+ const before = await d.kubectl.runningImage({ workload: u.workload, kind: u.kind, context: u.defaults.context, namespace: u.defaults.namespace });
68
+ u._applyStartsRollout = before !== `${u.repoUri}:${sha}`;
69
+ } catch { u._applyStartsRollout = false; }
70
+ }
55
71
  for (const f of files) await d.kubectl.applyFile({ file: f, context: u.defaults.context, namespace: u.defaults.namespace, dryRun });
56
72
  } else if (step.kind === 'rollout') {
57
- try { await d.kubectl.rollout({ deployment: u.workload, context: u.defaults.context, namespace: u.defaults.namespace, dryRun }); }
73
+ try { await d.kubectl.rollout({ deployment: u.workload, context: u.defaults.context, namespace: u.defaults.namespace, dryRun, restart: !u._applyStartsRollout }); }
58
74
  catch (err) { await d.kubectl.rolloutUndo({ deployment: u.workload, context: u.defaults.context, namespace: u.defaults.namespace, dryRun }); throw err; }
59
75
  }
60
76
  }
@@ -1,4 +1,4 @@
1
- const KEYS = ['profile', 'region', 'account', 'platform', 'context', 'namespace'];
1
+ const KEYS = ['profile', 'region', 'account', 'platform', 'context', 'namespace', 'ciGate'];
2
2
  const upperSnake = (s) => s.replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase();
3
3
  function imageVar(image) { return image.templateVar || `${upperSnake(image.name)}_IMAGE`; }
4
4
  function mergeDefaults(globalDefaults = {}, repoCfg = {}, entry = {}) {
@@ -0,0 +1,37 @@
1
+ let _run = require('./exec').run; function _withRun(fn) { _run = fn; }
2
+
3
+ // Runs for the last `limit` workflow executions in this repo, newest first.
4
+ // One call for the whole repo rather than one per commit: the gate walks back
5
+ // through history looking for the newest CI-covered ancestor, and N lookups
6
+ // would be N round trips to the API.
7
+ async function recentRuns(cwd, limit = 100) {
8
+ let code, stdout, stderr;
9
+ try {
10
+ ({ code, stdout, stderr } = await _run('gh',
11
+ ['run', 'list', '--limit', String(limit), '--json', 'headSha,status,conclusion,workflowName,url'],
12
+ { cwd, capture: true, allowFail: true }));
13
+ } catch (err) {
14
+ // spawn() itself failed -- with no `gh` on PATH the process never starts, so
15
+ // there is no exit code or stderr to inspect below.
16
+ if (/ENOENT/i.test(err.message)) throw new Error('gh CLI not installed (brew install gh)');
17
+ throw err;
18
+ }
19
+ if (code !== 0) {
20
+ const msg = (stderr || '').trim();
21
+ // Distinguish "gh cannot answer" from "gh says no". A missing binary, an
22
+ // expired token or a repo with Actions disabled must not read as a green
23
+ // suite -- the caller turns this into a block, not a pass.
24
+ if (/not found|ENOENT/i.test(msg)) throw new Error('gh CLI not installed (brew install gh)');
25
+ if (/auth|login|token/i.test(msg)) throw new Error('gh CLI not authenticated (gh auth login)');
26
+ throw new Error(`gh run list failed: ${msg || `exit ${code}`}`);
27
+ }
28
+ try { return JSON.parse(stdout || '[]'); } catch { throw new Error('gh run list returned unparseable JSON'); }
29
+ }
30
+
31
+ // Commit shas from HEAD backwards, newest first.
32
+ async function recentCommits(cwd, limit = 50) {
33
+ const { stdout } = await _run('git', ['rev-list', '-n', String(limit), 'HEAD'], { cwd, capture: true });
34
+ return stdout.trim().split('\n').filter(Boolean);
35
+ }
36
+
37
+ module.exports = { recentRuns, recentCommits, _withRun };
@@ -1,8 +1,12 @@
1
1
  let _run = require('./exec').run; function _withRun(fn) { _run = fn; }
2
2
  const ns = (c, n) => ['-n', n, '--context', c];
3
3
  function applyFile({ file, context, namespace, dryRun }) { return _run('kubectl', ['apply', '-f', file, ...ns(context, namespace)], { dryRun }); }
4
- async function rollout({ deployment, context, namespace, dryRun }) {
5
- await _run('kubectl', ['rollout', 'restart', `deployment/${deployment}`, ...ns(context, namespace)], { dryRun });
4
+ // `restart: false` waits without restarting. Used when the preceding apply
5
+ // stamped a new image SHA and therefore already started a rollout — issuing
6
+ // `rollout restart` on top of that produces a SECOND ReplicaSet and a second
7
+ // pod replacement, doubling the disruption window on every deploy.
8
+ async function rollout({ deployment, context, namespace, dryRun, restart = true }) {
9
+ if (restart) await _run('kubectl', ['rollout', 'restart', `deployment/${deployment}`, ...ns(context, namespace)], { dryRun });
6
10
  return _run('kubectl', ['rollout', 'status', `deployment/${deployment}`, '--timeout=180s', ...ns(context, namespace)], { dryRun });
7
11
  }
8
12
  function rolloutUndo({ deployment, context, namespace, dryRun }) { return _run('kubectl', ['rollout', 'undo', `deployment/${deployment}`, ...ns(context, namespace)], { dryRun }); }