mandrel-platform 0.18.0 → 0.19.1

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.
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * uses-pins.test.mjs — node:test suite for the shared `uses:`-line / SHA-pin
4
+ * primitives (`scripts/lib/uses-pins.mjs`, Story #203).
5
+ *
6
+ * Covers value stripping, line parsing, reference classification (incl. the
7
+ * new `subpath` field), the 40-char-SHA predicate, and the intra-repo
8
+ * single-pin invariant that `check-action-pins.mjs` now enforces. Pure — no
9
+ * temp dirs, no git, fully offline.
10
+ *
11
+ * Run: node --test scripts/lib/uses-pins.test.mjs
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { test } from "node:test";
16
+
17
+ import {
18
+ DEFAULT_FIRST_PARTY_OWNER,
19
+ stripUsesValue,
20
+ parseUsesLine,
21
+ classifyUses,
22
+ isSha40,
23
+ collectFirstPartyPins,
24
+ findSinglePinViolations,
25
+ } from "./uses-pins.mjs";
26
+
27
+ const SHA = "11bd71901bbe5b1630ceea73d27597364c9af683"; // 40 hex
28
+ const SHA2 = "0000000000000000000000000000000000000000"; // 40 hex, distinct
29
+ const SHORT = "11bd719"; // 7 hex
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // isSha40
33
+ // ---------------------------------------------------------------------------
34
+
35
+ test("isSha40 accepts exactly 40 hex chars (any case)", () => {
36
+ assert.equal(isSha40(SHA), true);
37
+ assert.equal(isSha40(SHA.toUpperCase()), true);
38
+ });
39
+
40
+ test("isSha40 rejects tags, short SHAs, branches, and off-by-one", () => {
41
+ assert.equal(isSha40("v4"), false);
42
+ assert.equal(isSha40(SHORT), false);
43
+ assert.equal(isSha40("main"), false);
44
+ assert.equal(isSha40(SHA + "0"), false); // 41 chars
45
+ assert.equal(isSha40("g".repeat(40)), false); // non-hex
46
+ });
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // stripUsesValue
50
+ // ---------------------------------------------------------------------------
51
+
52
+ test("stripUsesValue drops the trailing # tag comment", () => {
53
+ assert.equal(
54
+ stripUsesValue(`actions/checkout@${SHA} # v4.2.2`),
55
+ `actions/checkout@${SHA}`
56
+ );
57
+ });
58
+
59
+ test("stripUsesValue unwraps surrounding quotes and no-comment inputs", () => {
60
+ assert.equal(stripUsesValue(`actions/checkout@${SHA}`), `actions/checkout@${SHA}`);
61
+ assert.equal(stripUsesValue(`"actions/checkout@${SHA}"`), `actions/checkout@${SHA}`);
62
+ assert.equal(stripUsesValue(`'actions/checkout@${SHA}'`), `actions/checkout@${SHA}`);
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // parseUsesLine
67
+ // ---------------------------------------------------------------------------
68
+
69
+ test("parseUsesLine returns the bare ref for a mapping-key uses line", () => {
70
+ assert.equal(parseUsesLine(` - uses: actions/checkout@${SHA} # v4`), `actions/checkout@${SHA}`);
71
+ assert.equal(parseUsesLine(` uses: actions/checkout@${SHA}`), `actions/checkout@${SHA}`);
72
+ });
73
+
74
+ test("parseUsesLine returns null for comments and non-uses lines", () => {
75
+ assert.equal(parseUsesLine("# uses: actions/checkout@v4"), null);
76
+ assert.equal(parseUsesLine(" steps:"), null);
77
+ assert.equal(parseUsesLine(" run: echo uses: not-a-key"), null);
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // classifyUses (incl. subpath)
82
+ // ---------------------------------------------------------------------------
83
+
84
+ test("classifyUses flags external owner/repo as third-party with subpath", () => {
85
+ const c = classifyUses(`github/codeql-action/analyze@${SHA}`);
86
+ assert.equal(c.kind, "third-party");
87
+ assert.equal(c.owner, "github/codeql-action");
88
+ assert.equal(c.subpath, "analyze");
89
+ assert.equal(c.ref, SHA);
90
+ });
91
+
92
+ test("classifyUses treats the default first-party owner as exempt and exposes subpath", () => {
93
+ const c = classifyUses(
94
+ `dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`
95
+ );
96
+ assert.equal(c.kind, "first-party");
97
+ assert.equal(c.owner, DEFAULT_FIRST_PARTY_OWNER);
98
+ assert.equal(c.subpath, ".github/actions/setup-toolchain");
99
+ assert.equal(c.ref, SHA);
100
+ });
101
+
102
+ test("classifyUses honours a custom first-party owner", () => {
103
+ const c = classifyUses(`my-org/my-repo/.github/workflows/x.yml@v1`, "my-org/my-repo");
104
+ assert.equal(c.kind, "first-party");
105
+ assert.equal(c.subpath, ".github/workflows/x.yml");
106
+ });
107
+
108
+ test("classifyUses exempts local and docker refs", () => {
109
+ assert.equal(classifyUses("./.github/actions/foo").kind, "local");
110
+ assert.equal(classifyUses("../shared/action").kind, "local");
111
+ assert.equal(classifyUses("docker://alpine:3.19").kind, "docker");
112
+ });
113
+
114
+ test("classifyUses reports empty subpath for a bare owner/repo self-ref", () => {
115
+ const c = classifyUses(`dsj1984/mandrel-platform@${SHA}`);
116
+ assert.equal(c.kind, "first-party");
117
+ assert.equal(c.subpath, "");
118
+ });
119
+
120
+ test("classifyUses returns unparseable for a ref with no @ or too few segments", () => {
121
+ assert.equal(classifyUses("").kind, "unparseable");
122
+ assert.equal(classifyUses("actions/checkout").kind, "unparseable");
123
+ assert.equal(classifyUses(`justowner@${SHA}`).kind, "unparseable");
124
+ });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // collectFirstPartyPins
128
+ // ---------------------------------------------------------------------------
129
+
130
+ test("collectFirstPartyPins keys first-party subpath refs by target", () => {
131
+ const content = [
132
+ " steps:",
133
+ ` - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`,
134
+ ` - uses: actions/checkout@${SHA}`, // third-party → ignored
135
+ ` - uses: dsj1984/mandrel-platform@${SHA}`, // bare self-ref, no subpath → ignored
136
+ ].join("\n");
137
+ const byTarget = collectFirstPartyPins(content, "wf.yml");
138
+ assert.equal(byTarget.size, 1);
139
+ const occs = byTarget.get("dsj1984/mandrel-platform/.github/actions/setup-toolchain");
140
+ assert.equal(occs.length, 1);
141
+ assert.equal(occs[0].ref, SHA);
142
+ assert.equal(occs[0].line, 2);
143
+ assert.equal(occs[0].file, "wf.yml");
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // findSinglePinViolations (the single-pin invariant)
148
+ // ---------------------------------------------------------------------------
149
+
150
+ test("findSinglePinViolations is clean when a target is pinned consistently", () => {
151
+ const files = [
152
+ {
153
+ file: "a.yml",
154
+ content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
155
+ },
156
+ {
157
+ file: "b.yml",
158
+ content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
159
+ },
160
+ ];
161
+ assert.deepEqual(findSinglePinViolations(files), []);
162
+ });
163
+
164
+ test("findSinglePinViolations flags a target pinned to two different SHAs", () => {
165
+ const files = [
166
+ {
167
+ file: "a.yml",
168
+ content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
169
+ },
170
+ {
171
+ file: "b.yml",
172
+ content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA2}`,
173
+ },
174
+ ];
175
+ const v = findSinglePinViolations(files);
176
+ assert.equal(v.length, 1);
177
+ assert.equal(v[0].target, "dsj1984/mandrel-platform/.github/actions/foo");
178
+ assert.equal(v[0].shas.length, 2);
179
+ assert.ok(v[0].shas.includes(SHA));
180
+ assert.ok(v[0].shas.includes(SHA2));
181
+ assert.equal(v[0].occurrences.length, 2);
182
+ assert.deepEqual(
183
+ v[0].occurrences.map((o) => o.file).sort(),
184
+ ["a.yml", "b.yml"]
185
+ );
186
+ });
187
+
188
+ test("findSinglePinViolations catches drift within a single file too", () => {
189
+ const files = [
190
+ {
191
+ file: "a.yml",
192
+ content: [
193
+ ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
194
+ ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA2}`,
195
+ ].join("\n"),
196
+ },
197
+ ];
198
+ const v = findSinglePinViolations(files);
199
+ assert.equal(v.length, 1);
200
+ assert.equal(v[0].shas.length, 2);
201
+ });
202
+
203
+ test("findSinglePinViolations ignores third-party targets (cross-repo dashboard owns those)", () => {
204
+ const files = [
205
+ { file: "a.yml", content: ` - uses: github/codeql-action/analyze@${SHA}` },
206
+ { file: "b.yml", content: ` - uses: github/codeql-action/analyze@${SHA2}` },
207
+ ];
208
+ assert.deepEqual(findSinglePinViolations(files), []);
209
+ });
210
+
211
+ test("findSinglePinViolations honours a custom first-party owner", () => {
212
+ const files = [
213
+ { file: "a.yml", content: ` - uses: my-org/my-repo/actions/x@${SHA}` },
214
+ { file: "b.yml", content: ` - uses: my-org/my-repo/actions/x@${SHA2}` },
215
+ ];
216
+ const v = findSinglePinViolations(files, "my-org/my-repo");
217
+ assert.equal(v.length, 1);
218
+ assert.equal(v[0].target, "my-org/my-repo/actions/x");
219
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * scripts/lib/walk.mjs
3
+ *
4
+ * The single directory-discovery seam for the pin-tooling scripts. Both
5
+ * `check-action-pins.mjs` and `check-workflow-portability.mjs` had grown their
6
+ * own `listWorkflowFiles` / `listActionFiles` pair — same intent, subtly
7
+ * different code (one sorted, one didn't; one guarded `statSync`, one didn't).
8
+ * Story #203 consolidates them here.
9
+ *
10
+ * All discovery is best-effort: a missing directory or an unreadable entry
11
+ * yields `[]` / is skipped rather than throwing, so a repo without a
12
+ * `.github/actions/` tree lints cleanly. Results are sorted for deterministic
13
+ * output.
14
+ */
15
+
16
+ import { readdirSync, statSync, existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ /**
20
+ * List `*.yml` / `*.yaml` files directly under a workflows dir
21
+ * (non-recursive — GitHub only runs top-level workflow files).
22
+ *
23
+ * @param {string} dir
24
+ * @returns {string[]} Sorted absolute/relative paths (as joined from `dir`).
25
+ */
26
+ export function listWorkflowFiles(dir) {
27
+ if (!existsSync(dir)) return [];
28
+ let entries;
29
+ try {
30
+ entries = readdirSync(dir);
31
+ } catch {
32
+ return [];
33
+ }
34
+ return entries
35
+ .filter((f) => /\.ya?ml$/.test(f))
36
+ .map((f) => join(dir, f))
37
+ .filter((p) => {
38
+ try {
39
+ return statSync(p).isFile();
40
+ } catch {
41
+ return false;
42
+ }
43
+ })
44
+ .sort();
45
+ }
46
+
47
+ /**
48
+ * Recursively list composite `action.yml` / `action.yaml` files under a dir.
49
+ *
50
+ * @param {string} dir
51
+ * @returns {string[]} Sorted paths.
52
+ */
53
+ export function listActionFiles(dir) {
54
+ const out = [];
55
+ if (!existsSync(dir)) return out;
56
+ const walk = (d) => {
57
+ let entries;
58
+ try {
59
+ entries = readdirSync(d, { withFileTypes: true });
60
+ } catch {
61
+ return;
62
+ }
63
+ for (const e of entries) {
64
+ const full = join(d, e.name);
65
+ if (e.isDirectory()) {
66
+ walk(full);
67
+ } else if (/^action\.ya?ml$/.test(e.name)) {
68
+ out.push(full);
69
+ }
70
+ }
71
+ };
72
+ walk(dir);
73
+ return out.sort();
74
+ }
@@ -68,7 +68,9 @@ import { tmpdir } from "node:os";
68
68
  import { dirname, join, resolve } from "node:path";
69
69
  import { fileURLToPath } from "node:url";
70
70
 
71
- import { buildReport, defaultGhRunner, isFullSha } from "./check-pin-drift.mjs";
71
+ import { buildReport, isFullSha } from "./check-pin-drift.mjs";
72
+ import { defaultGhRunner } from "./lib/gh-json.mjs";
73
+ import { parseSemver } from "./lib/semver-duration.mjs";
72
74
 
73
75
  const __dirname = dirname(fileURLToPath(import.meta.url));
74
76
 
@@ -188,12 +190,16 @@ export function describeDrift(result) {
188
190
  const short = v.pinnedSha ? v.pinnedSha.slice(0, 7) : "?";
189
191
  out.push(`**Release lag** — workflow \`uses:\` pins \`${short}\`, behind the latest release.`);
190
192
  }
193
+ // Normalize the npm version through the shared parser (Story #198): the
194
+ // detector already emits a dotted triple, but re-parsing keeps the repair
195
+ // PR body robust to a raw spec ever reaching here (`^0.11.7` → `0.11.7`).
196
+ const npmVersion = parseSemver(result.npm?.version ?? null) ?? result.npm?.version ?? "?";
191
197
  if (result.surfaceSkew === true) {
192
198
  out.push(
193
- `**Surface skew** — the npm \`mandrel-platform\` dependency (\`${result.npm?.version ?? "?"}\`) and the workflow \`uses:\` pins are on different releases.`,
199
+ `**Surface skew** — the npm \`mandrel-platform\` dependency (\`${npmVersion}\`) and the workflow \`uses:\` pins are on different releases.`,
194
200
  );
195
201
  } else if (result.npm?.npmState === "lagging") {
196
- out.push(`**npm lag** — \`mandrel-platform@${result.npm.version}\` is behind the latest release.`);
202
+ out.push(`**npm lag** — \`mandrel-platform@${npmVersion}\` is behind the latest release.`);
197
203
  }
198
204
  return out;
199
205
  }
@@ -50,16 +50,18 @@
50
50
  * node scripts/update-semgrep-rules.mjs --semgrep-pin semgrep==1.97.0
51
51
  * node scripts/update-semgrep-rules.mjs --out .semgrep/rules.json --dry-run
52
52
  *
53
- * Requires network egress to PyPI (to install the pinned `semgrep` package)
54
- * and to the Semgrep registry (to resolve `p/default`) this script is run
55
- * by a human/agent deliberately bumping the ruleset, NOT by CI on every PR.
53
+ * Requires network egress to PyPI (to install the pinned `semgrep` package,
54
+ * whose artifact is verified against the recorded SHA-256 hashes via pip's
55
+ * `--require-hashes`) and to the Semgrep registry (to resolve `p/default`)
56
+ * this script is run by a human/agent deliberately bumping the ruleset, NOT
57
+ * by CI on every PR.
56
58
  *
57
59
  * Exit codes:
58
60
  * 0 — rules file written (or, with --dry-run, would-write reported).
59
61
  * 1 — semgrep install or rule resolution failed.
60
62
  */
61
63
 
62
- import { execFileSync, spawnSync } from "node:child_process";
64
+ import { spawnSync } from "node:child_process";
63
65
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
64
66
  import { tmpdir } from "node:os";
65
67
  import { dirname, join, resolve } from "node:path";
@@ -75,6 +77,26 @@ const REPO_ROOT = resolve(__dirname, "..");
75
77
  // generate the file is a (harmless but inconsistent) version skew.
76
78
  const DEFAULT_SEMGREP_PIN = "semgrep==1.97.0";
77
79
 
80
+ // SHA-256 hashes for every `DEFAULT_SEMGREP_PIN` distribution published on
81
+ // PyPI (the four platform wheels + the sdist). pip's `--require-hashes` mode
82
+ // verifies the downloaded `semgrep` artifact against this set before it is
83
+ // installed, so a compromised or swapped PyPI artifact for this exact version
84
+ // is rejected at install time — the same "pin the supply-chain input" posture
85
+ // the action-pin ratchet (SHA-pinned Actions) and the OSV advisory pin apply
86
+ // to their inputs. When bumping `DEFAULT_SEMGREP_PIN`, refresh this map from
87
+ // `https://pypi.org/pypi/semgrep/<version>/json` (the `urls[].digests.sha256`
88
+ // values) — a version with no hash entry here fails fast rather than
89
+ // installing unverified.
90
+ const SEMGREP_HASHES = {
91
+ "1.97.0": [
92
+ "sha256:0ddaa25ee45e669e1fef87e88dcef73b2aee0874b507e09f618862c42452a205",
93
+ "sha256:9184500bf8c49ad19d0fb2d84923abb4aa53058b0ece7008b57a3b0b5e6ce3ee",
94
+ "sha256:f7d21d6499d4e6fafb4c0b04b1750e9f4b704a26bd78f0aff19f1c73d44843b4",
95
+ "sha256:996fe0b2bfac3a4d4511e470fdf5f3bca96b1f794f398e0336c8388802c218de",
96
+ "sha256:c585164358e03cd7868e1f0d38fbcb422c88dbe08795b83caeb5e36cf18874aa",
97
+ ],
98
+ };
99
+
78
100
  const DEFAULT_OUT = join(REPO_ROOT, ".semgrep", "rules.json");
79
101
 
80
102
  // Languages this platform's reusable workflows + consumer trees actually
@@ -143,19 +165,67 @@ function resolveRegistryRules(semgrepPin) {
143
165
  const venvDir = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-")), "venv");
144
166
  const targetDir = mkdtempSync(join(tmpdir(), "semgrep-vendor-target-"));
145
167
  const semgrepHome = mkdtempSync(join(tmpdir(), "semgrep-vendor-home-"));
168
+ const reqsFile = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-reqs-")), "semgrep.txt");
146
169
 
147
170
  try {
148
171
  spawnSync("python3", ["-m", "venv", venvDir], { stdio: "inherit" });
149
172
  const pip = join(venvDir, "bin", "pip");
150
173
  const semgrep = join(venvDir, "bin", "semgrep");
151
174
 
175
+ // Resolve the exact version from the pin (`semgrep==<version>`) so we can
176
+ // look up its published artifact hashes. Only the `==` form is hashable;
177
+ // an unpinned or range pin cannot be verified.
178
+ const versionMatch = /^semgrep==(.+)$/.exec(semgrepPin.trim());
179
+ if (!versionMatch) {
180
+ throw new Error(
181
+ `cannot hash-pin ${semgrepPin}: only an exact 'semgrep==<version>' pin is supported`
182
+ );
183
+ }
184
+ const version = versionMatch[1];
185
+ const hashes = SEMGREP_HASHES[version];
186
+ if (!hashes || hashes.length === 0) {
187
+ throw new Error(
188
+ `no published artifact hashes recorded for ${semgrepPin} in SEMGREP_HASHES — ` +
189
+ `refresh from https://pypi.org/pypi/semgrep/${version}/json before pinning`
190
+ );
191
+ }
192
+
193
+ // Hash-pinned install: write a requirements file that pins `semgrep` to
194
+ // the exact version with every published artifact hash, then install it
195
+ // with `--require-hashes --no-deps`. pip verifies the downloaded semgrep
196
+ // artifact against this set before installing — a swapped/compromised
197
+ // artifact for this version is rejected. Dependencies are resolved in a
198
+ // separate, non-hashed step (semgrep is already satisfied), keeping the
199
+ // supply-chain-critical `semgrep` binary itself hash-verified.
200
+ const hashFlags = hashes.map((h) => ` --hash=${h}`).join(" \\\n");
201
+ writeFileSync(reqsFile, `semgrep==${version} \\\n${hashFlags}\n`, "utf8");
202
+
203
+ const installSemgrep = spawnSync(
204
+ pip,
205
+ [
206
+ "install",
207
+ "--quiet",
208
+ "--disable-pip-version-check",
209
+ "--require-hashes",
210
+ "--no-deps",
211
+ "-r",
212
+ reqsFile,
213
+ ],
214
+ { stdio: "inherit" }
215
+ );
216
+ if (installSemgrep.status !== 0) {
217
+ throw new Error(
218
+ `hash-pinned pip install ${semgrepPin} failed (exit ${installSemgrep.status})`
219
+ );
220
+ }
221
+
152
222
  const install = spawnSync(
153
223
  pip,
154
224
  ["install", "--quiet", "--disable-pip-version-check", "setuptools", semgrepPin],
155
225
  { stdio: "inherit" }
156
226
  );
157
227
  if (install.status !== 0) {
158
- throw new Error(`pip install ${semgrepPin} failed (exit ${install.status})`);
228
+ throw new Error(`pip install ${semgrepPin} (dependencies) failed (exit ${install.status})`);
159
229
  }
160
230
 
161
231
  // A throwaway target file gives semgrep something to "scan" so it
@@ -196,6 +266,7 @@ function resolveRegistryRules(semgrepPin) {
196
266
  rmSync(venvDir, { recursive: true, force: true });
197
267
  rmSync(targetDir, { recursive: true, force: true });
198
268
  rmSync(semgrepHome, { recursive: true, force: true });
269
+ rmSync(dirname(reqsFile), { recursive: true, force: true });
199
270
  }
200
271
  }
201
272
 
@@ -1,10 +1,14 @@
1
1
  # Runbook Templates (copyable thin stubs)
2
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.*
3
+ These are **copyable thin-stub templates** for the eight most commonly-adopted
4
+ canonical mandrel-platform runbooks in
5
+ [`docs/runbooks/`](https://github.com/dsj1984/mandrel-platform/tree/main/docs/runbooks)
6
+ (listed in the table below). It is **not** a stub-per-canonical-runbook set
7
+ several canonical runbooks (`rollback.md`, `slo.md`, `secret-rotation.md`,
8
+ `pin-drift-dashboard.md`) are platform-process docs a consumer reads directly
9
+ and ships no local stub for. They implement the MP-9 adoption model
10
+ (§7.7 / F1): *replace each duplicated process runbook with a thin local doc
11
+ that holds project-specific values plus a link to the canonical runbook.*
8
12
 
9
13
  Each stub:
10
14
 
@@ -21,9 +21,15 @@
21
21
  ## Apply & Verify
22
22
 
23
23
  ```bash
24
- # Preview / apply
25
- node scripts/apply-branch-protection.mjs --dry-run
26
- node scripts/apply-branch-protection.mjs --apply
24
+ # Apply PUT the protection with the aggregator as the only required context.
25
+ # (There is no apply-branch-protection script; use gh api directly — see the
26
+ # canonical runbook § 3.)
27
+ gh api repos/<OWNER>/<REPO>/branches/<PROTECTED_BRANCH>/protection \
28
+ --method PUT \
29
+ --raw-field required_status_checks='{"strict":false,"contexts":["<AGGREGATOR_CHECK>"]}' \
30
+ --field enforce_admins=false \
31
+ --raw-field required_pull_request_reviews=null \
32
+ --raw-field restrictions=null
27
33
 
28
34
  # Verify
29
35
  gh api repos/<OWNER>/<REPO>/branches/<PROTECTED_BRANCH>/protection \