mandrel-platform 0.3.0 → 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.
- package/package.json +1 -1
- package/scripts/check-workflow-portability.mjs +223 -72
- package/templates/runbooks/README.md +42 -0
- package/templates/runbooks/branch-protection-setup.md +45 -0
- package/templates/runbooks/database-backup-restore.md +49 -0
- package/templates/runbooks/dependency-update.md +40 -0
- package/templates/runbooks/deploy-promotion.md +47 -0
- package/templates/runbooks/environments-provisioning.md +42 -0
- package/templates/runbooks/incident-response.md +52 -0
- package/templates/runbooks/observability.md +49 -0
- package/templates/runbooks/post-deploy-smoke.md +39 -0
- package/templates/runbooks/.gitkeep +0 -0
package/package.json
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Cross-repo portability lint for reusable workflows and composite actions.
|
|
6
6
|
*
|
|
7
|
-
* GitHub validates a reusable workflow's
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* "
|
|
7
|
+
* GitHub validates a reusable workflow's / composite action's interface only
|
|
8
|
+
* when it is *called from another repo* — never when its own repo runs CI on
|
|
9
|
+
* it. That blind spot has shipped five consecutive consumer-facing breakages
|
|
10
|
+
* from this repo (the #24 → #29 → #30 → #32 → #35 chain on the athportal /
|
|
11
|
+
* domio Story worktrees), each a silent "workflow file issue / 0 jobs" or a
|
|
12
|
+
* "Set up job" load failure that no in-repo check could catch.
|
|
13
13
|
*
|
|
14
|
-
* This lint closes the blind spot by statically asserting the
|
|
15
|
-
*
|
|
14
|
+
* This lint closes the blind spot by statically asserting the invariants that
|
|
15
|
+
* GitHub only enforces at cross-repo call / action-load time:
|
|
16
16
|
*
|
|
17
17
|
* 1. RELATIVE `uses:` PATHS (e.g. `uses: ./.github/actions/foo`) are
|
|
18
18
|
* PROHIBITED inside a reusable workflow. A cross-repo caller checks out
|
|
@@ -24,9 +24,20 @@
|
|
|
24
24
|
* input `default:` fields are PROHIBITED. GitHub evaluates these during
|
|
25
25
|
* interface validation, where contexts like `runner.*` do not yet exist,
|
|
26
26
|
* so the call fails silently. The SAME footgun applies to composite
|
|
27
|
-
* `action.yml` input `default:` values — a composite
|
|
28
|
-
* expression-evaluated
|
|
29
|
-
*
|
|
27
|
+
* `action.yml` input `default:` and `description:` values — a composite
|
|
28
|
+
* default is never expression-evaluated (passed through as a literal),
|
|
29
|
+
* and a `${{ }}` in a composite description throws "Unrecognized
|
|
30
|
+
* named-value" at action-load time ("Set up job"). (Caused #32 / #35.)
|
|
31
|
+
*
|
|
32
|
+
* 3. INTERNAL SHA PINS must point to a CLEAN manifest. A reusable workflow
|
|
33
|
+
* that pins a first-party action by `owner/repo/path@<sha>` is validated
|
|
34
|
+
* here against the manifest AT THAT SHA — not just the working-tree copy.
|
|
35
|
+
* A pin left lagging on a pre-fix commit re-introduces a footgun the
|
|
36
|
+
* working tree already fixed: this is exactly #35, where pr-quality.yml
|
|
37
|
+
* kept pinning setup-toolchain@<pre-fix-sha> after the description was
|
|
38
|
+
* cleaned in the working tree, so every consumer job died at "Set up
|
|
39
|
+
* job". Requires git history (run CI checkout with fetch-depth: 0); the
|
|
40
|
+
* check degrades to a skipped NOTE when the pinned blob is unreachable.
|
|
30
41
|
*
|
|
31
42
|
* What this lint deliberately does NOT flag: `${{ }}` in `runs.steps[].with`
|
|
32
43
|
* (e.g. `dest: ${{ inputs['pnpm-dest'] || format('{0}/pnpm', runner.temp) }}`)
|
|
@@ -38,6 +49,7 @@
|
|
|
38
49
|
* node scripts/check-workflow-portability.mjs
|
|
39
50
|
* node scripts/check-workflow-portability.mjs --workflows-dir .github/workflows
|
|
40
51
|
* node scripts/check-workflow-portability.mjs --actions-dir .github/actions
|
|
52
|
+
* node scripts/check-workflow-portability.mjs --no-pin-check # skip Rule 3
|
|
41
53
|
*
|
|
42
54
|
* Exit codes:
|
|
43
55
|
* 0 — every reusable workflow and composite action is cross-repo portable
|
|
@@ -45,15 +57,19 @@
|
|
|
45
57
|
*
|
|
46
58
|
* Consumer adoption:
|
|
47
59
|
* Copy this script into your project's `scripts/` directory, then wire it
|
|
48
|
-
* into your CI alongside check-required-contexts.mjs:
|
|
60
|
+
* into your CI alongside check-required-contexts.mjs. Use fetch-depth: 0 on
|
|
61
|
+
* the checkout so Rule 3 can resolve pinned blobs:
|
|
49
62
|
*
|
|
63
|
+
* - uses: actions/checkout@<sha>
|
|
64
|
+
* with: { fetch-depth: 0 }
|
|
50
65
|
* - name: Lint workflow portability
|
|
51
66
|
* run: node scripts/check-workflow-portability.mjs
|
|
52
67
|
*
|
|
53
68
|
* It is dependency-free (no YAML parser) so it copies cleanly into any repo.
|
|
54
69
|
*/
|
|
55
70
|
|
|
56
|
-
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
71
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
72
|
+
import { execFileSync } from "node:child_process";
|
|
57
73
|
import { resolve, join, relative, basename } from "node:path";
|
|
58
74
|
|
|
59
75
|
// ---------------------------------------------------------------------------
|
|
@@ -63,15 +79,18 @@ import { resolve, join, relative, basename } from "node:path";
|
|
|
63
79
|
const args = process.argv.slice(2);
|
|
64
80
|
let workflowsDir = null;
|
|
65
81
|
let actionsDir = null;
|
|
82
|
+
let pinCheck = true;
|
|
66
83
|
|
|
67
84
|
for (let i = 0; i < args.length; i++) {
|
|
68
85
|
if ((args[i] === "--workflows-dir" || args[i] === "-w") && args[i + 1]) {
|
|
69
86
|
workflowsDir = args[++i];
|
|
70
87
|
} else if ((args[i] === "--actions-dir" || args[i] === "-a") && args[i + 1]) {
|
|
71
88
|
actionsDir = args[++i];
|
|
89
|
+
} else if (args[i] === "--no-pin-check") {
|
|
90
|
+
pinCheck = false;
|
|
72
91
|
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
73
92
|
process.stdout.write(
|
|
74
|
-
"Usage: node scripts/check-workflow-portability.mjs [--workflows-dir <dir>] [--actions-dir <dir>]\n"
|
|
93
|
+
"Usage: node scripts/check-workflow-portability.mjs [--workflows-dir <dir>] [--actions-dir <dir>] [--no-pin-check]\n"
|
|
75
94
|
);
|
|
76
95
|
process.exit(0);
|
|
77
96
|
}
|
|
@@ -170,6 +189,159 @@ function walkYaml(content) {
|
|
|
170
189
|
|
|
171
190
|
const EXPR = /\$\{\{/;
|
|
172
191
|
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Content checks (reused for both working-tree files and pinned blobs)
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/** Reusable-workflow checks (Rules 1 & 2). Returns [{line, message}]. */
|
|
197
|
+
function checkWorkflowContent(content) {
|
|
198
|
+
const violations = [];
|
|
199
|
+
const records = walkYaml(content);
|
|
200
|
+
|
|
201
|
+
const isReusable = records.some(
|
|
202
|
+
(r) => r.path.join(".") === "on.workflow_call" || r.path.join(".").startsWith("on.workflow_call.")
|
|
203
|
+
);
|
|
204
|
+
if (!isReusable) return violations;
|
|
205
|
+
|
|
206
|
+
// Rule 1: no relative `uses:` anywhere in a reusable workflow.
|
|
207
|
+
content.split("\n").forEach((raw, idx) => {
|
|
208
|
+
if (/^\s*uses:\s*['"]?\.\//.test(raw)) {
|
|
209
|
+
violations.push({
|
|
210
|
+
line: idx + 1,
|
|
211
|
+
message:
|
|
212
|
+
`relative \`uses: ./\` path in a reusable workflow — a cross-repo ` +
|
|
213
|
+
`caller resolves \`./\` against its own checkout. Use absolute ` +
|
|
214
|
+
`\`owner/repo/path@ref\` form.`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Rule 2: no `${{ }}` in workflow_call input/secret description or default.
|
|
220
|
+
for (const r of records) {
|
|
221
|
+
const p = r.path.join(".");
|
|
222
|
+
const isInputMeta = /^on\.workflow_call\.inputs\.[^.]+\.(description|default)$/.test(p);
|
|
223
|
+
const isSecretMeta = /^on\.workflow_call\.secrets\.[^.]+\.description$/.test(p);
|
|
224
|
+
if ((isInputMeta || isSecretMeta) && EXPR.test(r.value)) {
|
|
225
|
+
const field = r.path[r.path.length - 1];
|
|
226
|
+
const name = r.path[r.path.length - 2];
|
|
227
|
+
violations.push({
|
|
228
|
+
line: r.lineNo,
|
|
229
|
+
message:
|
|
230
|
+
`\`\${{ }}\` expression in workflow_call \`${name}.${field}\` — ` +
|
|
231
|
+
`GitHub evaluates this during interface validation (where ` +
|
|
232
|
+
`runner.*/secrets.* do not exist), failing every cross-repo call. ` +
|
|
233
|
+
`Write it as plain text.`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return violations;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Composite-action checks (Rules 3/4 of the original; manifest cleanliness). */
|
|
242
|
+
function checkActionContent(content) {
|
|
243
|
+
const violations = [];
|
|
244
|
+
const records = walkYaml(content);
|
|
245
|
+
|
|
246
|
+
for (const r of records) {
|
|
247
|
+
const p = r.path.join(".");
|
|
248
|
+
if (/^inputs\.[^.]+\.(default|description)$/.test(p) && EXPR.test(r.value)) {
|
|
249
|
+
const field = r.path[r.path.length - 1];
|
|
250
|
+
const name = r.path[r.path.length - 2];
|
|
251
|
+
violations.push({
|
|
252
|
+
line: r.lineNo,
|
|
253
|
+
message:
|
|
254
|
+
`\`\${{ }}\` expression in action input \`${name}.${field}\` — ` +
|
|
255
|
+
`composite ${field}s are not expression-evaluated; \`${field}\` ` +
|
|
256
|
+
`throws "Unrecognized named-value" at action-load time. Move ` +
|
|
257
|
+
`runtime expressions into \`runs.steps[].with\`, or write it as ` +
|
|
258
|
+
`plain text.`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return violations;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Internal SHA-pin guard (Rule 3) — validate the PINNED manifest, not just
|
|
268
|
+
// the working tree. Catches a self-reference that lags a fix (e.g. #35).
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
let gitAvailable = null;
|
|
272
|
+
function isGitRepo() {
|
|
273
|
+
if (gitAvailable !== null) return gitAvailable;
|
|
274
|
+
try {
|
|
275
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
276
|
+
cwd: repoRoot,
|
|
277
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
278
|
+
});
|
|
279
|
+
gitAvailable = true;
|
|
280
|
+
} catch {
|
|
281
|
+
gitAvailable = false;
|
|
282
|
+
}
|
|
283
|
+
return gitAvailable;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** `git show <sha>:<path>` → file content, or null if unreachable. */
|
|
287
|
+
function gitShow(sha, path) {
|
|
288
|
+
try {
|
|
289
|
+
return execFileSync("git", ["show", `${sha}:${path}`], {
|
|
290
|
+
cwd: repoRoot,
|
|
291
|
+
encoding: "utf8",
|
|
292
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
293
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
294
|
+
});
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Collect internal (same-repo, full-SHA-pinned) `uses:` references from a
|
|
302
|
+
* workflow/action file. "Internal" is detected structurally: the sub-path
|
|
303
|
+
* after `owner/repo/` exists in the local working tree. External refs
|
|
304
|
+
* (actions/checkout, pnpm/action-setup, …) resolve to no local path and are
|
|
305
|
+
* skipped — so the heuristic needs no knowledge of this repo's own slug.
|
|
306
|
+
*/
|
|
307
|
+
function collectInternalPins(content) {
|
|
308
|
+
const pins = [];
|
|
309
|
+
content.split("\n").forEach((raw, idx) => {
|
|
310
|
+
const m = raw.match(
|
|
311
|
+
/uses:\s*['"]?[\w.-]+\/[\w.-]+\/([^@\s'"]+)@([0-9a-fA-F]{40})/
|
|
312
|
+
);
|
|
313
|
+
if (!m) return;
|
|
314
|
+
const subpath = m[1];
|
|
315
|
+
const sha = m[2];
|
|
316
|
+
const local = join(repoRoot, subpath);
|
|
317
|
+
if (!existsSync(local)) return; // external ref → skip
|
|
318
|
+
pins.push({ subpath, sha, line: idx + 1 });
|
|
319
|
+
});
|
|
320
|
+
return pins;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Resolve a pinned ref's manifest path + kind ('action' | 'workflow'). */
|
|
324
|
+
function resolvePinnedManifest(subpath) {
|
|
325
|
+
const local = join(repoRoot, subpath);
|
|
326
|
+
let st;
|
|
327
|
+
try {
|
|
328
|
+
st = statSync(local);
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
if (st.isDirectory()) {
|
|
333
|
+
for (const name of ["action.yml", "action.yaml"]) {
|
|
334
|
+
if (existsSync(join(local, name))) return { path: `${subpath}/${name}`, kind: "action" };
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
if (/\.ya?ml$/.test(subpath)) {
|
|
339
|
+
const kind = basename(subpath).startsWith("action.") ? "action" : "workflow";
|
|
340
|
+
return { path: subpath, kind };
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
173
345
|
// ---------------------------------------------------------------------------
|
|
174
346
|
// File discovery
|
|
175
347
|
// ---------------------------------------------------------------------------
|
|
@@ -212,10 +384,11 @@ function listActionFiles(dir) {
|
|
|
212
384
|
}
|
|
213
385
|
|
|
214
386
|
// ---------------------------------------------------------------------------
|
|
215
|
-
//
|
|
387
|
+
// Per-file lint
|
|
216
388
|
// ---------------------------------------------------------------------------
|
|
217
389
|
|
|
218
|
-
|
|
390
|
+
const pinSkips = [];
|
|
391
|
+
|
|
219
392
|
function lintFile(filePath) {
|
|
220
393
|
const violations = [];
|
|
221
394
|
let content;
|
|
@@ -225,68 +398,38 @@ function lintFile(filePath) {
|
|
|
225
398
|
return [{ line: 0, message: `cannot read file: ${err.message}` }];
|
|
226
399
|
}
|
|
227
400
|
|
|
228
|
-
const records = walkYaml(content);
|
|
229
401
|
const isWorkflow = filePath.startsWith(resolvedWorkflowsDir);
|
|
230
402
|
const isAction =
|
|
231
403
|
basename(filePath) === "action.yml" || basename(filePath) === "action.yaml";
|
|
232
404
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
// Rule 2: no `${{ }}` in workflow_call input/secret description or default.
|
|
253
|
-
for (const r of records) {
|
|
254
|
-
const p = r.path.join(".");
|
|
255
|
-
const isInputMeta =
|
|
256
|
-
/^on\.workflow_call\.inputs\.[^.]+\.(description|default)$/.test(p);
|
|
257
|
-
const isSecretMeta =
|
|
258
|
-
/^on\.workflow_call\.secrets\.[^.]+\.description$/.test(p);
|
|
259
|
-
if ((isInputMeta || isSecretMeta) && EXPR.test(r.value)) {
|
|
260
|
-
const field = r.path[r.path.length - 1];
|
|
261
|
-
const name = r.path[r.path.length - 2];
|
|
262
|
-
violations.push({
|
|
263
|
-
line: r.lineNo,
|
|
264
|
-
message:
|
|
265
|
-
`\`\${{ }}\` expression in workflow_call \`${name}.${field}\` — ` +
|
|
266
|
-
`GitHub evaluates this during interface validation (where ` +
|
|
267
|
-
`runner.*/secrets.* do not exist), failing every cross-repo call. ` +
|
|
268
|
-
`Write it as plain text.`,
|
|
269
|
-
});
|
|
405
|
+
// Rules 1 & 2 (workflows) and the manifest checks (actions) on the live file.
|
|
406
|
+
if (isWorkflow) violations.push(...checkWorkflowContent(content));
|
|
407
|
+
if (isAction) violations.push(...checkActionContent(content));
|
|
408
|
+
|
|
409
|
+
// Rule 3: validate internal SHA-pinned references against their pinned blob.
|
|
410
|
+
if (pinCheck && isGitRepo()) {
|
|
411
|
+
for (const pin of collectInternalPins(content)) {
|
|
412
|
+
const manifest = resolvePinnedManifest(pin.subpath);
|
|
413
|
+
if (!manifest) continue;
|
|
414
|
+
const pinned = gitShow(pin.sha, manifest.path);
|
|
415
|
+
if (pinned === null) {
|
|
416
|
+
pinSkips.push(
|
|
417
|
+
`${relative(repoRoot, filePath)}:${pin.line} — pinned ${pin.subpath}@${pin.sha.slice(0, 7)} ` +
|
|
418
|
+
`(blob unreachable; run checkout with fetch-depth: 0 to enable Rule 3)`
|
|
419
|
+
);
|
|
420
|
+
continue;
|
|
270
421
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// A composite default is never expression-evaluated (the literal string is
|
|
277
|
-
// passed through); a description expression is misleading and needless.
|
|
278
|
-
for (const r of records) {
|
|
279
|
-
const p = r.path.join(".");
|
|
280
|
-
if (/^inputs\.[^.]+\.(default|description)$/.test(p) && EXPR.test(r.value)) {
|
|
281
|
-
const field = r.path[r.path.length - 1];
|
|
282
|
-
const name = r.path[r.path.length - 2];
|
|
422
|
+
const pinnedViolations =
|
|
423
|
+
manifest.kind === "action"
|
|
424
|
+
? checkActionContent(pinned)
|
|
425
|
+
: checkWorkflowContent(pinned);
|
|
426
|
+
for (const v of pinnedViolations) {
|
|
283
427
|
violations.push({
|
|
284
|
-
line:
|
|
428
|
+
line: pin.line,
|
|
285
429
|
message:
|
|
286
|
-
|
|
287
|
-
`
|
|
288
|
-
|
|
289
|
-
`\`runs.steps[].with\`, or write the ${field} as plain text.`,
|
|
430
|
+
`internal pin \`${pin.subpath}@${pin.sha.slice(0, 7)}\` points to a ` +
|
|
431
|
+
`manifest with a portability defect (${manifest.path}:${v.line}) — ` +
|
|
432
|
+
`${v.message} Bump the pin to a commit whose manifest is clean.`,
|
|
290
433
|
});
|
|
291
434
|
}
|
|
292
435
|
}
|
|
@@ -305,7 +448,8 @@ const allFiles = [...workflowFiles, ...actionFiles];
|
|
|
305
448
|
|
|
306
449
|
process.stdout.write(
|
|
307
450
|
`[check-workflow-portability] Workflows: ${relative(repoRoot, resolvedWorkflowsDir)}/ (${workflowFiles.length})\n` +
|
|
308
|
-
`[check-workflow-portability] Actions : ${relative(repoRoot, resolvedActionsDir)}/ (${actionFiles.length})\n`
|
|
451
|
+
`[check-workflow-portability] Actions : ${relative(repoRoot, resolvedActionsDir)}/ (${actionFiles.length})\n` +
|
|
452
|
+
`[check-workflow-portability] Pin check: ${pinCheck ? (isGitRepo() ? "on" : "on (git unavailable — skipped)") : "off"}\n`
|
|
309
453
|
);
|
|
310
454
|
|
|
311
455
|
if (allFiles.length === 0) {
|
|
@@ -327,6 +471,13 @@ for (const file of allFiles) {
|
|
|
327
471
|
}
|
|
328
472
|
}
|
|
329
473
|
|
|
474
|
+
if (pinSkips.length > 0) {
|
|
475
|
+
process.stdout.write(
|
|
476
|
+
`\n[check-workflow-portability] ⚠️ ${pinSkips.length} internal pin(s) could not be verified (Rule 3 skipped):\n`
|
|
477
|
+
);
|
|
478
|
+
for (const s of pinSkips) process.stdout.write(` ${s}\n`);
|
|
479
|
+
}
|
|
480
|
+
|
|
330
481
|
if (total > 0) {
|
|
331
482
|
process.stderr.write(
|
|
332
483
|
`\n[check-workflow-portability] ${total} portability violation${total === 1 ? "" : "s"} detected.\n` +
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Runbook Templates (copyable thin stubs)
|
|
2
|
+
|
|
3
|
+
These are **copyable thin-stub templates** — one per canonical mandrel-platform
|
|
4
|
+
runbook in [`docs/runbooks/`](https://github.com/dsj1984/mandrel-platform/tree/main/docs/runbooks).
|
|
5
|
+
They implement the MP-9 adoption model (§7.7 / F1): *replace each duplicated
|
|
6
|
+
process runbook with a thin local doc that holds project-specific values plus a
|
|
7
|
+
link to the canonical runbook.*
|
|
8
|
+
|
|
9
|
+
Each stub:
|
|
10
|
+
|
|
11
|
+
- **Links** to its canonical mandrel-platform runbook (the process source of
|
|
12
|
+
truth — do not re-author the process here).
|
|
13
|
+
- Carries **placeholders** for project-specific values in `<ANGLE_BRACKET>`
|
|
14
|
+
form (hosts, env names, dashboards, DB engine, worker names, …).
|
|
15
|
+
|
|
16
|
+
## How to adopt (downstream repo)
|
|
17
|
+
|
|
18
|
+
1. Copy the stub(s) you need into your project's `docs/runbooks/`:
|
|
19
|
+
```bash
|
|
20
|
+
cp node_modules/mandrel-platform/templates/runbooks/deploy-promotion.md \
|
|
21
|
+
docs/runbooks/deploy-promotion.md
|
|
22
|
+
```
|
|
23
|
+
2. Replace every `<PLACEHOLDER>` with your project's real values.
|
|
24
|
+
3. Fill in the **Project-Specific Notes** section.
|
|
25
|
+
4. Leave the canonical link intact — when the upstream process changes, you only
|
|
26
|
+
re-read the link, not rewrite the stub.
|
|
27
|
+
|
|
28
|
+
## Stubs
|
|
29
|
+
|
|
30
|
+
| Stub | Canonical runbook |
|
|
31
|
+
|------|-------------------|
|
|
32
|
+
| `deploy-promotion.md` | staging → production promotion |
|
|
33
|
+
| `incident-response.md` | severity, escalation, postmortem |
|
|
34
|
+
| `database-backup-restore.md` | backup, PITR, restore/rollback |
|
|
35
|
+
| `observability.md` | logs, Sentry, uptime, metrics |
|
|
36
|
+
| `post-deploy-smoke.md` | boot-smoke gate + diagnosis |
|
|
37
|
+
| `environments-provisioning.md` | env model + provisioning steps |
|
|
38
|
+
| `dependency-update.md` | Renovate, CVE gate, catalog |
|
|
39
|
+
| `branch-protection-setup.md` | aggregator required-check model |
|
|
40
|
+
|
|
41
|
+
> Placeholder convention: `<UPPER_SNAKE>` between angle brackets. Search for
|
|
42
|
+
> `<` after copying to find everything that still needs a value.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Branch Protection Setup — <PROJECT_NAME>
|
|
2
|
+
|
|
3
|
+
> **Thin local stub.** The canonical single-aggregator protection model and the
|
|
4
|
+
> `main-protection.json` contract live in the mandrel-platform repo:
|
|
5
|
+
> [`docs/runbooks/branch-protection-setup.md`](https://github.com/dsj1984/mandrel-platform/blob/main/docs/runbooks/branch-protection-setup.md).
|
|
6
|
+
> This file only holds **<PROJECT_NAME>-specific check names and reviewers**.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Project Values
|
|
11
|
+
|
|
12
|
+
| Value | Setting |
|
|
13
|
+
|-------|---------|
|
|
14
|
+
| Repo (`owner/repo`) | `<OWNER>/<REPO>` |
|
|
15
|
+
| Protected branch | `<PROTECTED_BRANCH>` (e.g. `main`) |
|
|
16
|
+
| Required aggregator check | `<AGGREGATOR_CHECK>` (e.g. `ci-required`) |
|
|
17
|
+
| Protection contract file | `<MAIN_PROTECTION_JSON>` (e.g. `docs/runbooks/main-protection.json`) |
|
|
18
|
+
| Reviewer group(s) | `<REVIEWER_GROUPS>` |
|
|
19
|
+
| GitHub plan | `<GITHUB_PLAN>` (free / pro / team) |
|
|
20
|
+
|
|
21
|
+
## Apply & Verify
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Preview / apply
|
|
25
|
+
node scripts/apply-branch-protection.mjs --dry-run
|
|
26
|
+
node scripts/apply-branch-protection.mjs --apply
|
|
27
|
+
|
|
28
|
+
# Verify
|
|
29
|
+
gh api repos/<OWNER>/<REPO>/branches/<PROTECTED_BRANCH>/protection \
|
|
30
|
+
--jq '.required_status_checks.contexts'
|
|
31
|
+
# Expected: ["<AGGREGATOR_CHECK>"]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
> **Do not add individual job names as required checks** — only the aggregator.
|
|
35
|
+
|
|
36
|
+
## Project-Specific Notes
|
|
37
|
+
|
|
38
|
+
<!-- Ruleset IDs, enforceAdmins rationale, plan-specific constraints. -->
|
|
39
|
+
|
|
40
|
+
- _TODO: fill in._
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
See also the project's `<MAIN_PROTECTION_JSON>` and the local stubs:
|
|
45
|
+
`dependency-update.md`, `environments-provisioning.md`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Database Backup & Restore — <PROJECT_NAME>
|
|
2
|
+
|
|
3
|
+
> **Thin local stub.** The canonical backup strategy, PITR procedure, and
|
|
4
|
+
> restore steps live in the mandrel-platform repo:
|
|
5
|
+
> [`docs/runbooks/database-backup-restore.md`](https://github.com/dsj1984/mandrel-platform/blob/main/docs/runbooks/database-backup-restore.md).
|
|
6
|
+
> This file only holds **<PROJECT_NAME>-specific database values**.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Project Values
|
|
11
|
+
|
|
12
|
+
| Value | Setting |
|
|
13
|
+
|-------|---------|
|
|
14
|
+
| DB engine | `<DB_ENGINE>` (e.g. Turso/libSQL) |
|
|
15
|
+
| Staging DB name | `<STAGING_DB_NAME>` |
|
|
16
|
+
| Production DB name | `<PRODUCTION_DB_NAME>` |
|
|
17
|
+
| Org / account | `<DB_ORG>` |
|
|
18
|
+
| PITR retention window | `<PITR_WINDOW>` (e.g. 7 days) |
|
|
19
|
+
| Critical tables (integrity check) | `<CRITICAL_TABLE>` |
|
|
20
|
+
| Worker secret for DB URL | `<DATABASE_URL_SECRET>` |
|
|
21
|
+
| Backup verification log | `<BACKUP_VERIFY_LOG_LOCATION>` |
|
|
22
|
+
|
|
23
|
+
## Restore from Pre-Deploy Snapshot (primary path)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
SNAPSHOT_BRANCH="<SNAPSHOT_BRANCH>" # from the deploy workflow run output
|
|
27
|
+
turso db create <PRODUCTION_DB_NAME>-restored \
|
|
28
|
+
--from-db <PRODUCTION_DB_NAME> --from-branch "$SNAPSHOT_BRANCH"
|
|
29
|
+
turso db shell <PRODUCTION_DB_NAME>-restored "SELECT COUNT(*) FROM <CRITICAL_TABLE>;"
|
|
30
|
+
# Repoint the worker secret and redeploy — see canonical Section 5.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Manual Backup
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
turso db branch create <PRODUCTION_DB_NAME> backup-$(date +%Y%m%d)
|
|
37
|
+
turso db shell <PRODUCTION_DB_NAME> ".dump" > backup-$(date +%Y%m%d).sql # store securely
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Project-Specific Notes
|
|
41
|
+
|
|
42
|
+
<!-- Snapshot cadence, who owns restores, where dumps are archived. -->
|
|
43
|
+
|
|
44
|
+
- _TODO: fill in._
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
See also the local stubs: `rollback.md`, `deploy-promotion.md`,
|
|
49
|
+
`incident-response.md`, and the project's `docs/environments.md`.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Dependency Update — <PROJECT_NAME>
|
|
2
|
+
|
|
3
|
+
> **Thin local stub.** The canonical Renovate model, CVE gate, catalog, and
|
|
4
|
+
> override conventions live in the mandrel-platform repo:
|
|
5
|
+
> [`docs/runbooks/dependency-update.md`](https://github.com/dsj1984/mandrel-platform/blob/main/docs/runbooks/dependency-update.md).
|
|
6
|
+
> This file only holds **<PROJECT_NAME>-specific configuration pointers**.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Project Values
|
|
11
|
+
|
|
12
|
+
| Value | Setting |
|
|
13
|
+
|-------|---------|
|
|
14
|
+
| Package manager | `<PACKAGE_MANAGER>` (e.g. pnpm) |
|
|
15
|
+
| Renovate config | `<RENOVATE_CONFIG_PATH>` |
|
|
16
|
+
| CVE allowlist location | `<CVE_ALLOWLIST_PATH>` |
|
|
17
|
+
| Audit command | `<AUDIT_COMMAND>` (e.g. `pnpm run audit:check`) |
|
|
18
|
+
| Renovate Dependency Dashboard | `<DASHBOARD_ISSUE_URL>` |
|
|
19
|
+
| Node version pin | `<NODE_VERSION>` (`.nvmrc`) |
|
|
20
|
+
|
|
21
|
+
## Common Commands
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Run the CVE gate locally (what CI sees)
|
|
25
|
+
<AUDIT_COMMAND>
|
|
26
|
+
|
|
27
|
+
# Out-of-band update
|
|
28
|
+
<PACKAGE_MANAGER> update <package-name>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Project-Specific Notes
|
|
32
|
+
|
|
33
|
+
<!-- Renovate preset overrides, grouped packages, manual-merge policies. -->
|
|
34
|
+
|
|
35
|
+
- _TODO: fill in._
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
See also the local stubs: `secret-rotation.md`, `incident-response.md`, and
|
|
40
|
+
the project's `docs/environments.md`.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Deploy Promotion — <PROJECT_NAME>
|
|
2
|
+
|
|
3
|
+
> **Thin local stub.** The canonical, process-level procedure lives in the
|
|
4
|
+
> mandrel-platform repo:
|
|
5
|
+
> [`docs/runbooks/deploy-promotion.md`](https://github.com/dsj1984/mandrel-platform/blob/main/docs/runbooks/deploy-promotion.md).
|
|
6
|
+
> This file only holds **<PROJECT_NAME>-specific values**. When the process
|
|
7
|
+
> changes, update the canonical runbook upstream — not this stub.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Project Values
|
|
12
|
+
|
|
13
|
+
| Value | Setting |
|
|
14
|
+
|-------|---------|
|
|
15
|
+
| Staging worker name | `<STAGING_WORKER_NAME>` |
|
|
16
|
+
| Production worker name | `<PRODUCTION_WORKER_NAME>` |
|
|
17
|
+
| Staging deploy workflow | `<STAGING_DEPLOY_WORKFLOW>` (e.g. `deploy-staging.yml`) |
|
|
18
|
+
| Production deploy workflow | `<PRODUCTION_DEPLOY_WORKFLOW>` (e.g. `deploy-production.yml`) |
|
|
19
|
+
| Production health URL | `<PRODUCTION_HEALTH_URL>` |
|
|
20
|
+
| Staging health URL | `<STAGING_HEALTH_URL>` |
|
|
21
|
+
| Promotion approvers | `<APPROVER_HANDLES>` |
|
|
22
|
+
| Deploy-window channel | `<DEPLOY_CHANNEL>` |
|
|
23
|
+
|
|
24
|
+
## Trigger a Production Promotion
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
gh workflow run <PRODUCTION_DEPLOY_WORKFLOW> --ref main --field confirm=true
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Verify
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
curl -sf <PRODUCTION_HEALTH_URL> && echo OK || echo FAIL
|
|
34
|
+
wrangler deployments list --name <PRODUCTION_WORKER_NAME> | head -3
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Project-Specific Notes
|
|
38
|
+
|
|
39
|
+
<!-- Record any promotion quirks for this project: extra pre-checks, manual
|
|
40
|
+
migration steps, stakeholder sign-off requirements, etc. -->
|
|
41
|
+
|
|
42
|
+
- _TODO: fill in._
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
See also the local stubs: `rollback.md`, `post-deploy-smoke.md`,
|
|
47
|
+
`incident-response.md`, and the project's `docs/environments.md`.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Environments Provisioning — <PROJECT_NAME>
|
|
2
|
+
|
|
3
|
+
> **Thin local stub.** The canonical environment model and step-by-step
|
|
4
|
+
> provisioning procedure live in the mandrel-platform repo:
|
|
5
|
+
> [`docs/runbooks/environments-provisioning.md`](https://github.com/dsj1984/mandrel-platform/blob/main/docs/runbooks/environments-provisioning.md).
|
|
6
|
+
> This file only holds **<PROJECT_NAME>-specific IDs and names**.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Project Values
|
|
11
|
+
|
|
12
|
+
| Value | Setting |
|
|
13
|
+
|-------|---------|
|
|
14
|
+
| Repo (`owner/repo`) | `<OWNER>/<REPO>` |
|
|
15
|
+
| Cloudflare account ID | `<CF_ACCOUNT_ID>` |
|
|
16
|
+
| Zone / domain | `<ZONE>` |
|
|
17
|
+
| Worker base name | `<WORKER_NAME>` |
|
|
18
|
+
| Staging domain | `<STAGING_DOMAIN>` |
|
|
19
|
+
| Production domain | `<PRODUCTION_DOMAIN>` |
|
|
20
|
+
| Secrets manager project | `<SECRETS_PROJECT_ID>` (e.g. Infisical) |
|
|
21
|
+
| Staging DB name | `<STAGING_DB_NAME>` |
|
|
22
|
+
| Production DB name | `<PRODUCTION_DB_NAME>` |
|
|
23
|
+
|
|
24
|
+
## Environment Map
|
|
25
|
+
|
|
26
|
+
| Environment | Branch | Deployed by |
|
|
27
|
+
|-------------|--------|-------------|
|
|
28
|
+
| `local` | any | developer (`wrangler dev`) |
|
|
29
|
+
| `staging` | `main` (auto) | CI/CD after CI-green |
|
|
30
|
+
| `production` | `main` (manual) | `workflow_dispatch` |
|
|
31
|
+
|
|
32
|
+
## Project-Specific Notes
|
|
33
|
+
|
|
34
|
+
<!-- Custom domains, reviewer requirements, secret-sync specifics. -->
|
|
35
|
+
|
|
36
|
+
- _TODO: fill in._
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
The authoritative environment inventory (URLs, secret names, DB names) is the
|
|
41
|
+
project's own `docs/environments.md`. See also the local stubs:
|
|
42
|
+
`branch-protection-setup.md`, `secret-rotation.md`, `deploy-promotion.md`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Incident Response — <PROJECT_NAME>
|
|
2
|
+
|
|
3
|
+
> **Thin local stub.** The canonical severity model, escalation flow, response
|
|
4
|
+
> steps, and postmortem template live in the mandrel-platform repo:
|
|
5
|
+
> [`docs/runbooks/incident-response.md`](https://github.com/dsj1984/mandrel-platform/blob/main/docs/runbooks/incident-response.md).
|
|
6
|
+
> This file only holds **<PROJECT_NAME>-specific contacts and links**.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Escalation Contacts
|
|
11
|
+
|
|
12
|
+
| Role | Who | Channel |
|
|
13
|
+
|------|-----|---------|
|
|
14
|
+
| First responder / on-call | `<ONCALL_HANDLE>` | `<ONCALL_CHANNEL>` |
|
|
15
|
+
| On-call lead (P1/P2) | `<LEAD_HANDLE>` | `<LEAD_CHANNEL>` |
|
|
16
|
+
| Stakeholder notify (P1) | `<STAKEHOLDER_LIST>` | `<STAKEHOLDER_CHANNEL>` |
|
|
17
|
+
|
|
18
|
+
## Tooling Links
|
|
19
|
+
|
|
20
|
+
| Tool | URL |
|
|
21
|
+
|------|-----|
|
|
22
|
+
| Error tracking (Sentry) | `<SENTRY_PROJECT_URL>` |
|
|
23
|
+
| Uptime (Better Stack) | `<BETTERSTACK_URL>` |
|
|
24
|
+
| Status page | `<STATUS_PAGE_URL>` |
|
|
25
|
+
| Incident issue label | `incident`, `severity::P1` … |
|
|
26
|
+
|
|
27
|
+
## Severity SLAs (from canonical — confirm or override)
|
|
28
|
+
|
|
29
|
+
| Severity | Target response |
|
|
30
|
+
|----------|-----------------|
|
|
31
|
+
| P1 — Critical | < 15 min |
|
|
32
|
+
| P2 — High | < 1 hour |
|
|
33
|
+
| P3 — Medium | < 4 hours |
|
|
34
|
+
| P4 — Low | next business day |
|
|
35
|
+
|
|
36
|
+
## First Moves
|
|
37
|
+
|
|
38
|
+
1. Acknowledge the alert in `<BETTERSTACK_URL>` / `<SENTRY_PROJECT_URL>`.
|
|
39
|
+
2. Open an incident issue (`incident` + `severity::*`).
|
|
40
|
+
3. **Recent deploy? Rollback first** — see `rollback.md`.
|
|
41
|
+
4. Follow the full response steps in the canonical runbook.
|
|
42
|
+
|
|
43
|
+
## Project-Specific Notes
|
|
44
|
+
|
|
45
|
+
<!-- Paging procedures, escalation timeouts, known fragile subsystems. -->
|
|
46
|
+
|
|
47
|
+
- _TODO: fill in._
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
See also the local stubs: `rollback.md`, `observability.md`,
|
|
52
|
+
`secret-rotation.md`, and the project's `docs/environments.md`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Observability — <PROJECT_NAME>
|
|
2
|
+
|
|
3
|
+
> **Thin local stub.** The canonical observability stack, query patterns, and
|
|
4
|
+
> on-call response flow live in the mandrel-platform repo:
|
|
5
|
+
> [`docs/runbooks/observability.md`](https://github.com/dsj1984/mandrel-platform/blob/main/docs/runbooks/observability.md).
|
|
6
|
+
> This file only holds **<PROJECT_NAME>-specific endpoints and dataset names**.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Project Values
|
|
11
|
+
|
|
12
|
+
| Value | Setting |
|
|
13
|
+
|-------|---------|
|
|
14
|
+
| Worker name(s) | `<WORKER_NAME>` |
|
|
15
|
+
| Cloudflare account ID | `<CF_ACCOUNT_ID>` |
|
|
16
|
+
| Analytics Engine dataset | `<AE_DATASET>` |
|
|
17
|
+
| Sentry dashboard | `<SENTRY_PROJECT_URL>` |
|
|
18
|
+
| Sentry DSN secret | `<SENTRY_DSN_SECRET>` |
|
|
19
|
+
| Better Stack dashboard | `<BETTERSTACK_URL>` |
|
|
20
|
+
| Logpush destination | `<LOGPUSH_DESTINATION>` |
|
|
21
|
+
|
|
22
|
+
## Quick Commands
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Live tail
|
|
26
|
+
wrangler tail --name <WORKER_NAME> --format pretty
|
|
27
|
+
|
|
28
|
+
# Errors only
|
|
29
|
+
wrangler tail --name <WORKER_NAME> --format json | jq 'select(.outcome != "ok")'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Alert Thresholds (from canonical — confirm or override)
|
|
33
|
+
|
|
34
|
+
| Metric | Action threshold |
|
|
35
|
+
|--------|------------------|
|
|
36
|
+
| 5xx rate | > 1% sustained → consider rollback |
|
|
37
|
+
| P99 response time | > 2× baseline → investigate |
|
|
38
|
+
| Uptime probe failing | any failure → page on-call |
|
|
39
|
+
|
|
40
|
+
## Project-Specific Notes
|
|
41
|
+
|
|
42
|
+
<!-- Custom dashboards, known noisy alerts, dataset schema notes. -->
|
|
43
|
+
|
|
44
|
+
- _TODO: fill in._
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
See also the local stubs: `incident-response.md`, `rollback.md`, `slo.md`,
|
|
49
|
+
and the project's `docs/environments.md`.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Post-Deploy Smoke — <PROJECT_NAME>
|
|
2
|
+
|
|
3
|
+
> **Thin local stub.** The canonical smoke contract, failure diagnosis, and
|
|
4
|
+
> auto-rollback behavior live in the mandrel-platform repo:
|
|
5
|
+
> [`docs/runbooks/post-deploy-smoke.md`](https://github.com/dsj1984/mandrel-platform/blob/main/docs/runbooks/post-deploy-smoke.md).
|
|
6
|
+
> This file only holds **<PROJECT_NAME>-specific health URLs and parameters**.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Project Values
|
|
11
|
+
|
|
12
|
+
| Value | Setting |
|
|
13
|
+
|-------|---------|
|
|
14
|
+
| Staging health URL | `<STAGING_HEALTH_URL>` |
|
|
15
|
+
| Production health URL | `<PRODUCTION_HEALTH_URL>` |
|
|
16
|
+
| Health route | `<HEALTH_ROUTE>` (e.g. `GET /health`) |
|
|
17
|
+
| Smoke script | `<SMOKE_SCRIPT>` (e.g. `./scripts/smoke-deploy.sh`) |
|
|
18
|
+
| Max attempts | `<SMOKE_MAX_ATTEMPTS>` (default 5) |
|
|
19
|
+
| Delay between retries | `<SMOKE_DELAY_SECONDS>` (default 10s) |
|
|
20
|
+
|
|
21
|
+
## Run the Smoke Manually
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
<SMOKE_SCRIPT> <PRODUCTION_HEALTH_URL> <SMOKE_MAX_ATTEMPTS> <SMOKE_DELAY_SECONDS>
|
|
25
|
+
|
|
26
|
+
# One-shot check
|
|
27
|
+
curl -sf <PRODUCTION_HEALTH_URL> && echo OK || echo FAIL
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Project-Specific Notes
|
|
31
|
+
|
|
32
|
+
<!-- Cold-start tuning, non-standard health checks, auth exceptions. -->
|
|
33
|
+
|
|
34
|
+
- _TODO: fill in._
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
See also the local stubs: `rollback.md`, `deploy-promotion.md`,
|
|
39
|
+
`observability.md`, and the project's `docs/environments.md`.
|
|
File without changes
|