docs/assignments: Add table generator
Render site tables from the aggregated assignments, group rows by assignment name, preserve handwritten notes, and expose the updater as a flake package.
This commit is contained in:
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update the consolidated network-assignment tables in docs/networking.md.
|
||||
|
||||
Reads nixos.allAssignments from the flake and renders assignment-key subgroups for each site
|
||||
(colony / home / remote / other) between per-site `<!-- assignments: <site> -->` markers. The
|
||||
hand-written Notes column is preserved, keyed by (box, assignment).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DOCS = Path("docs")
|
||||
TARGET = DOCS / "networking.md"
|
||||
HEADER = "| Box | IPv4 | IPv6 | Domain | Notes |"
|
||||
SEP = "|---|---|---|---|---|"
|
||||
|
||||
# Table order; also the set of valid `<!-- assignments: <site> -->` tokens.
|
||||
SITE_ORDER = ["colony", "home", "remote", "other"]
|
||||
|
||||
# Per-site internal domains of the remote boxes (britway=lon1, britnet=bhx1, kelder=hentai).
|
||||
REMOTE_DOMAINS = ("lon1.int.nul.ie", "bhx1.int.nul.ie", "hentai.engineer")
|
||||
|
||||
|
||||
def site_of(assignments: dict) -> str:
|
||||
"""Classify a box into a site table by its assignment domain.
|
||||
|
||||
Prefer the `internal` assignment's domain, else the first assignment that has one.
|
||||
"""
|
||||
dom = (assignments.get("internal") or {}).get("domain")
|
||||
if not dom:
|
||||
for a in assignments.values():
|
||||
if a.get("domain"):
|
||||
dom = a["domain"]
|
||||
break
|
||||
if not dom:
|
||||
return "other"
|
||||
if dom.endswith("ams1.int.nul.ie"):
|
||||
return "colony"
|
||||
if dom == "h.nul.ie" or dom.endswith(".h.nul.ie"):
|
||||
return "home"
|
||||
if any(dom.endswith(s) for s in REMOTE_DOMAINS):
|
||||
return "remote"
|
||||
return "other"
|
||||
|
||||
|
||||
def fmt_ip(ip: dict) -> str:
|
||||
addr = ip.get("address")
|
||||
if addr is None:
|
||||
return "—"
|
||||
parts = [f"{addr}/{ip.get('mask')}"]
|
||||
if ip.get("gateway") is not None:
|
||||
parts.append(f"gw {ip['gateway']}")
|
||||
return f"`{' '.join(parts)}`"
|
||||
|
||||
|
||||
def find_page(box: str) -> str | None:
|
||||
"""Doc page for a box (relative to docs/), if one exists."""
|
||||
for pattern in (f"{box}.md", f"{box}/README.md"):
|
||||
for p in sorted(DOCS.rglob(pattern)):
|
||||
return p.relative_to(DOCS).as_posix()
|
||||
return None
|
||||
|
||||
|
||||
def box_cell(box: str) -> str:
|
||||
page = find_page(box)
|
||||
return f"[`{box}`]({page})" if page else f"`{box}`"
|
||||
|
||||
|
||||
def box_name_from_cell(cell: str) -> str:
|
||||
m = re.match(r"\[`?([^`\]]+)`?\]", cell)
|
||||
return m.group(1) if m else cell.strip("`")
|
||||
|
||||
|
||||
def parse_notes(lines: list[str]) -> dict[tuple[str, str], str]:
|
||||
"""Existing Notes indexed by (box, assignment), from either table layout."""
|
||||
notes: dict[tuple[str, str], str] = {}
|
||||
assignment = None
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
heading = re.match(r"^####\s+`([^`]+)`$", stripped)
|
||||
if heading:
|
||||
assignment = heading.group(1)
|
||||
continue
|
||||
if not stripped.startswith("|"):
|
||||
continue
|
||||
cells = [c.strip() for c in stripped.split("|")]
|
||||
# "| a | b | ... |" splits to ['', 'a', 'b', ..., '']
|
||||
if len(cells) >= 7 and assignment is not None:
|
||||
# Current layout: one table under each assignment heading.
|
||||
box, row_assignment, note = cells[1], assignment, cells[5]
|
||||
elif len(cells) >= 8:
|
||||
# Previous layout: one site table with an Assignment column.
|
||||
box, row_assignment, note = cells[1], cells[2], cells[6]
|
||||
else:
|
||||
continue
|
||||
# Skip header and separator rows.
|
||||
if box == "Box" or set(box) <= {"-"}:
|
||||
continue
|
||||
notes[(box_name_from_cell(box), row_assignment)] = note
|
||||
return notes
|
||||
|
||||
|
||||
def render_site(boxes: list[str], all_assignments: dict, notes: dict) -> list[str]:
|
||||
groups: dict[str, list[tuple[str, dict]]] = {}
|
||||
for box in sorted(boxes):
|
||||
for key, a in all_assignments[box].items():
|
||||
groups.setdefault(key, []).append((box, a))
|
||||
|
||||
lines: list[str] = []
|
||||
group_order = sorted(groups, key=lambda key: (key != "internal", key))
|
||||
for key in group_order:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.extend([f"#### `{key}`", "", HEADER, SEP])
|
||||
for box, a in groups[key]:
|
||||
lines.append(
|
||||
"| "
|
||||
+ " | ".join(
|
||||
[
|
||||
box_cell(box),
|
||||
fmt_ip(a.get("ipv4", {})),
|
||||
fmt_ip(a.get("ipv6", {})),
|
||||
a.get("domain") or "—",
|
||||
notes.get((box, key), ""),
|
||||
]
|
||||
)
|
||||
+ " |"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def update_target(all_assignments: dict) -> bool:
|
||||
by_site: dict[str, list[str]] = {s: [] for s in SITE_ORDER}
|
||||
for box, assignments in all_assignments.items():
|
||||
if not assignments:
|
||||
continue
|
||||
by_site[site_of(assignments)].append(box)
|
||||
|
||||
text = TARGET.read_text()
|
||||
lines = text.splitlines()
|
||||
marker_re = re.compile(r"^<!--\s*assignments:\s*(\S+)\s*-->$")
|
||||
|
||||
# Walk the file line by line; at each `<!-- assignments: <site> -->` marker, replace
|
||||
# everything up to the matching `<!-- assignments-end -->` with a freshly rendered table
|
||||
# (carrying the hand-written Notes over), leaving all other lines untouched.
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
m = marker_re.match(lines[i].strip())
|
||||
if not m:
|
||||
out.append(lines[i])
|
||||
i += 1
|
||||
continue
|
||||
site = m.group(1)
|
||||
end = i + 1
|
||||
while end < len(lines) and lines[end].strip() != "<!-- assignments-end -->":
|
||||
end += 1
|
||||
if end >= len(lines):
|
||||
out.append(lines[i])
|
||||
i += 1
|
||||
continue
|
||||
notes = parse_notes(lines[i + 1 : end])
|
||||
out.append(lines[i])
|
||||
out.append("<!-- assignments-start -->")
|
||||
out.extend(render_site(by_site.get(site, []), all_assignments, notes))
|
||||
out.append("<!-- assignments-end -->")
|
||||
i = end + 1
|
||||
|
||||
new_text = "\n".join(out) + "\n"
|
||||
if new_text != text:
|
||||
TARGET.write_text(new_text)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
result = subprocess.run(
|
||||
["nix", "eval", ".#nixfiles.config.nixos.allAssignments", "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
all_assignments = json.loads(result.stdout)
|
||||
|
||||
if update_target(all_assignments):
|
||||
print(f"updated {TARGET}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -52,18 +52,183 @@ edit prose there, never the other generated cells.
|
||||
|
||||
<!-- assignments: colony -->
|
||||
<!-- assignments-start -->
|
||||
#### `internal`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`chatterbox`](sites/colony/shill/containers/chatterbox.md) | `10.100.2.5/24 gw 10.100.2.1` | `2a0e:97c0:4d2:12::5/64` | ams1.int.nul.ie | |
|
||||
| [`colony`](sites/colony/colony.md) | `94.142.241.224/32` | `2a0e:97c0:4d2:10::2/64` | ams1.int.nul.ie | |
|
||||
| [`colony-psql`](sites/colony/shill/containers/colony-psql.md) | `10.100.2.4/24 gw 10.100.2.1` | `2a0e:97c0:4d2:12::4/64` | ams1.int.nul.ie | |
|
||||
| `enshrouded-oci` | `10.100.3.5/24 gw 10.100.3.1` | `2a0e:97c0:4d2:13::5/64` | ams1.int.nul.ie | Enshrouded OCI container on [`whale2`](sites/colony/whale2.md#game-servers); disabled |
|
||||
| [`estuary`](sites/colony/estuary.md) | `94.142.240.44/24 gw 94.142.240.254` | `2a02:898:0:20::329:1/64 gw 2a02:898:0:20::1` | ams1.int.nul.ie | |
|
||||
| [`gam`](sites/colony/shill/containers/gam.md) | `10.100.2.11/24 gw 10.100.2.1` | `2a0e:97c0:4d2:12::b/64` | ams1.int.nul.ie | |
|
||||
| [`git`](sites/colony/git.md) | `94.142.241.117/32` | `2a0e:97c0:4d2:11::4/64` | ams1.int.nul.ie | |
|
||||
| `graeme-oci` | `10.100.3.8/24 gw 10.100.3.1` | `2a0e:97c0:4d2:13::8/64` | ams1.int.nul.ie | Minecraft OCI container on [`whale2`](sites/colony/whale2.md#game-servers) |
|
||||
| [`jackflix`](sites/colony/shill/containers/jackflix.md) | `10.100.2.6/24 gw 10.100.2.1` | `2a0e:97c0:4d2:12::6/64` | ams1.int.nul.ie | |
|
||||
| `kevcraft-oci` | `10.100.3.6/24 gw 10.100.3.1` | `2a0e:97c0:4d2:13::6/64` | ams1.int.nul.ie | Minecraft OCI container on [`whale2`](sites/colony/whale2.md#game-servers) |
|
||||
| `kinkcraft-oci` | `10.100.3.7/24 gw 10.100.3.1` | `2a0e:97c0:4d2:13::7/64` | ams1.int.nul.ie | Minecraft OCI container on [`whale2`](sites/colony/whale2.md#game-servers) |
|
||||
| [`middleman`](sites/colony/shill/containers/middleman.md) | `10.100.2.2/24 gw 10.100.2.1` | `2a0e:97c0:4d2:12::2/64` | ams1.int.nul.ie | |
|
||||
| [`object`](sites/colony/shill/containers/object.md) | `10.100.2.7/24 gw 10.100.2.1` | `2a0e:97c0:4d2:12::7/64` | ams1.int.nul.ie | |
|
||||
| [`qclk`](sites/colony/shill/containers/qclk.md) | `10.100.2.10/24 gw 10.100.2.1` | `2a0e:97c0:4d2:12::a/64` | ams1.int.nul.ie | |
|
||||
| [`shill`](sites/colony/shill/README.md) | `94.142.241.225/32` | `2a0e:97c0:4d2:11::2/64` | ams1.int.nul.ie | |
|
||||
| `simpcraft-oci` | `10.100.3.3/24 gw 10.100.3.1` | `2a0e:97c0:4d2:13::3/64` | ams1.int.nul.ie | Minecraft OCI container on [`whale2`](sites/colony/whale2.md#game-servers) |
|
||||
| `simpcraft-staging-oci` | `10.100.3.4/24 gw 10.100.3.1` | `2a0e:97c0:4d2:13::4/64` | ams1.int.nul.ie | Minecraft staging OCI container on [`whale2`](sites/colony/whale2.md#game-servers); disabled |
|
||||
| [`toot`](sites/colony/shill/containers/toot.md) | `10.100.2.8/24 gw 10.100.2.1` | `2a0e:97c0:4d2:12::8/64` | ams1.int.nul.ie | |
|
||||
| `valheim-oci` | `10.100.3.2/24 gw 10.100.3.1` | `2a0e:97c0:4d2:13::2/64` | ams1.int.nul.ie | Valheim OCI container on [`whale2`](sites/colony/whale2.md#game-servers) |
|
||||
| [`vaultwarden`](sites/colony/shill/containers/vaultwarden.md) | `10.100.2.3/24 gw 10.100.2.1` | `2a0e:97c0:4d2:12::3/64` | ams1.int.nul.ie | |
|
||||
| [`waffletail`](sites/colony/shill/containers/waffletail.md) | `10.100.2.9/24 gw 10.100.2.1` | `2a0e:97c0:4d2:12::9/64` | ams1.int.nul.ie | |
|
||||
| [`whale2`](sites/colony/whale2.md) | `94.142.241.226/32` | `2a0e:97c0:4d2:11::3/64` | ams1.int.nul.ie | |
|
||||
|
||||
#### `as211024`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`estuary`](sites/colony/estuary.md) | `10.100.50.1/24` | `2a0e:97c0:4df::1/64` | — | |
|
||||
|
||||
#### `base`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`estuary`](sites/colony/estuary.md) | `10.100.0.1/24` | `2a0e:97c0:4d2:10::1/64` | ams1.int.nul.ie | |
|
||||
|
||||
#### `ctrs`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`shill`](sites/colony/shill/README.md) | `10.100.2.1/24` | `2a0e:97c0:4d2:12::1/64` | ams1.int.nul.ie | |
|
||||
|
||||
#### `oci`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`whale2`](sites/colony/whale2.md) | `10.100.3.1/24` | `2a0e:97c0:4d2:13::1/64` | ams1.int.nul.ie | |
|
||||
|
||||
#### `qclk`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`qclk`](sites/colony/shill/containers/qclk.md) | `10.100.4.1/24` | — | — | |
|
||||
|
||||
#### `routing`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`colony`](sites/colony/colony.md) | `10.100.0.2/24 gw 10.100.0.1` | — | ams1.int.nul.ie | |
|
||||
| [`git`](sites/colony/git.md) | `10.100.1.4/24 gw 10.100.1.1` | — | ams1.int.nul.ie | |
|
||||
| [`shill`](sites/colony/shill/README.md) | `10.100.1.2/24 gw 10.100.1.1` | — | ams1.int.nul.ie | |
|
||||
| [`whale2`](sites/colony/whale2.md) | `10.100.1.3/24 gw 10.100.1.1` | — | ams1.int.nul.ie | |
|
||||
|
||||
#### `tailscale`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`waffletail`](sites/colony/shill/containers/waffletail.md) | `100.64.0.5/32` | `fd7a:115c:a1e0::5/128` | — | |
|
||||
|
||||
#### `vms`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`colony`](sites/colony/colony.md) | `10.100.1.1/24` | `2a0e:97c0:4d2:11::1/64` | ams1.int.nul.ie | |
|
||||
<!-- assignments-end -->
|
||||
|
||||
### home
|
||||
|
||||
<!-- assignments: home -->
|
||||
<!-- assignments-start -->
|
||||
#### `as211024`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`river`](sites/home/river.md) | `10.100.50.2/24` | `2a0e:97c0:4df:0:1::1/64 gw 2a0e:97c0:4df:0:2::1` | — | |
|
||||
| [`stream`](sites/home/stream.md) | `10.100.50.3/24` | `2a0e:97c0:4df:0:1::2/64 gw 2a0e:97c0:4df:0:2::1` | — | |
|
||||
|
||||
#### `core`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`palace`](sites/home/palace.md) | `192.168.64.20/24` | — | h.nul.ie | |
|
||||
| [`river`](sites/home/river.md) | `192.168.64.1/24` | — | h.nul.ie | |
|
||||
| [`stream`](sites/home/stream.md) | `192.168.64.2/24` | — | h.nul.ie | |
|
||||
| [`unifi`](sites/home/sfh/containers/unifi.md) | `192.168.64.21/24` | — | h.nul.ie | |
|
||||
|
||||
#### `hi`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`castle`](sites/home/castle.md) | `192.168.68.40/22 gw 192.168.71.254` | `2a0e:97c0:4d0:1::3:1/64` | h.nul.ie | |
|
||||
| [`cellar`](sites/home/cellar.md) | `192.168.68.80/22 gw 192.168.71.254` | `2a0e:97c0:4d0:1::4:1/64` | h.nul.ie | |
|
||||
| [`hass`](sites/home/sfh/containers/hass.md) | `192.168.68.103/22 gw 192.168.71.254` | `2a0e:97c0:4d0:1::5:3/64` | h.nul.ie | |
|
||||
| [`palace`](sites/home/palace.md) | `192.168.68.22/22 gw 192.168.71.254` | `2a0e:97c0:4d0:1::2:1/64` | h.nul.ie | |
|
||||
| [`river`](sites/home/river.md) | `192.168.68.1/22` | `2a0e:97c0:4d0:1::1/64` | h.nul.ie | |
|
||||
| `router-hi` | `192.168.71.254/22 gw 192.168.68.1` | `2a0e:97c0:4d0:1::ffff/64` | h.nul.ie | Floating VIP shared by [`river`](sites/home/river.md) and [`stream`](sites/home/stream.md) |
|
||||
| [`sfh`](sites/home/sfh/README.md) | `192.168.68.81/22 gw 192.168.71.254` | `2a0e:97c0:4d0:1::4:2/64` | h.nul.ie | |
|
||||
| [`stream`](sites/home/stream.md) | `192.168.68.2/22` | `2a0e:97c0:4d0:1::2/64` | h.nul.ie | |
|
||||
| [`unifi`](sites/home/sfh/containers/unifi.md) | `192.168.68.100/22 gw 192.168.71.254` | `2a0e:97c0:4d0:1::5:1/64` | h.nul.ie | |
|
||||
|
||||
#### `lo`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`hass`](sites/home/sfh/containers/hass.md) | `192.168.72.103/21` | `2a0e:97c0:4d0:2::5:3/64` | h.nul.ie | |
|
||||
| [`river`](sites/home/river.md) | `192.168.72.1/21` | `2a0e:97c0:4d0:2::1/64` | h.nul.ie | |
|
||||
| `router-lo` | `192.168.79.254/21 gw 192.168.72.1` | `2a0e:97c0:4d0:2::ffff/64` | h.nul.ie | Floating VIP shared by [`river`](sites/home/river.md) and [`stream`](sites/home/stream.md) |
|
||||
| [`stream`](sites/home/stream.md) | `192.168.72.2/21` | `2a0e:97c0:4d0:2::2/64` | h.nul.ie | |
|
||||
|
||||
#### `untrusted`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`river`](sites/home/river.md) | `192.168.80.1/24` | `2a0e:97c0:4d0:3::1/64` | h.nul.ie | |
|
||||
| `router-ut` | `192.168.80.254/24 gw 192.168.80.1` | `2a0e:97c0:4d0:3::ffff/64` | h.nul.ie | Floating VIP shared by [`river`](sites/home/river.md) and [`stream`](sites/home/stream.md) |
|
||||
| [`stream`](sites/home/stream.md) | `192.168.80.2/24` | `2a0e:97c0:4d0:3::2/64` | h.nul.ie | |
|
||||
<!-- assignments-end -->
|
||||
|
||||
### remote
|
||||
|
||||
<!-- assignments: remote -->
|
||||
<!-- assignments-start -->
|
||||
#### `internal`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`kelder-acquisition`](remote/kelder/containers/kelder-acquisition.md) | `172.16.64.2/24 gw 172.16.64.1` | — | hentai.engineer | |
|
||||
| [`kelder-spoder`](remote/kelder/containers/kelder-spoder.md) | `172.16.64.3/24 gw 172.16.64.1` | — | hentai.engineer | |
|
||||
|
||||
#### `allhost`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`britnet`](remote/britnet.md) | `77.74.199.67/24 gw 77.74.199.1` | `2a12:ab46:5344:99::a/64 gw 2a12:ab46:5344::1` | bhx1.int.nul.ie | |
|
||||
|
||||
#### `as211024`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`britway`](remote/britway.md) | `10.100.50.5/24` | `2a0e:97c0:4df:0:2::1/64` | — | |
|
||||
|
||||
#### `ctrs`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`kelder`](remote/kelder/README.md) | `172.16.64.1/24` | — | hentai.engineer | |
|
||||
|
||||
#### `estuary`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`kelder`](remote/kelder/README.md) | `94.142.242.254/32` | — | — | |
|
||||
|
||||
#### `vpn`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`britnet`](remote/britnet.md) | `10.200.0.1/24` | `fdfb:5ebf:6e84::1/64` | — | |
|
||||
|
||||
#### `vultr`
|
||||
|
||||
| Box | IPv4 | IPv6 | Domain | Notes |
|
||||
|---|---|---|---|---|
|
||||
| [`britway`](remote/britway.md) | `45.76.141.188/23 gw 45.76.140.1` | `2001:19f0:7402:128b::1/64` | lon1.int.nul.ie | |
|
||||
<!-- assignments-end -->
|
||||
|
||||
## Domains
|
||||
|
||||
@@ -11,4 +11,8 @@ in
|
||||
chocolate-doom2xx = callPackage ./chocolate-doom2xx { };
|
||||
windowtolayer = callPackage ./windowtolayer.nix { };
|
||||
swaylock-plugin = callPackage ./swaylock-plugin.nix { };
|
||||
|
||||
update-docs-assignments = pkgs.writeShellScriptBin "update-docs-assignments" ''
|
||||
exec ${pkgs.python3}/bin/python3 ${../ci/update-docs-assignments.py} "$@"
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user