Compare commits

...

10 Commits

Author SHA1 Message Date
lilleman 16b5e0767c File relative date bounds and exact-count choices as plan items
Tests / vet + fmt + tests (pull_request) Has been cancelled
Tests / Known vulnerabilities in this Go version and the required modules (pull_request) Has been cancelled
Tests / Release from CHANGELOG.md (pull_request) Has been cancelled
Tests / packages (pull_request) Has been cancelled
2026-10-14 22:05:26 +02:00
lilleman 7739c09163 Check the plan before filing into it, keep filed items in order, name the filer's tests in the gate, and record the merge over a worse ruling 2026-10-14 13:51:22 +02:00
lilleman 16afcff09c Test that the filer keeps its items in order and exits 2 on a plan it cannot read 2026-10-14 13:51:22 +02:00
lilleman 5aca8a1fe8 Record the comprehension check: paired ruling against f90b4d6 rules worse 2026-10-14 13:51:22 +02:00
lilleman 5a6928750c Give the vulnerability filer a folder of its own, and leave its item's score to rank_todo.py 2026-10-14 13:51:22 +02:00
lilleman 15965e7a77 State the filer's contract with the plan where it is read, name the step an outage fails, and mark an unfixed vulnerability by name 2026-10-14 13:51:22 +02:00
lilleman 6a7da08002 Answer architecture round 4: 0 🔴, 0 🟡; an entry with no summary files without a bare colon 2026-10-14 13:51:22 +02:00
lilleman 9049f90cc5 Answer architecture round 3: 0 🔴, 2 🟡; a plan-only change runs the test that pins the plan's shape, and a module record carries its own path 2026-10-14 13:51:22 +02:00
lilleman 2d86f4f0c0 Answer architecture round 2: 1 🔴, 3 🟡; read the report and edit the plan in two functions, propose only while no pull request is open, and test filing into the repository's own todo.md 2026-10-14 13:51:22 +02:00
lilleman 273dd45128 Answer architecture round 1: 0 🔴, 5 🟡; a job of its own that only releases wait for, filing each vulnerability atop the plan by a pull request, a recorded decision, one gate list, and Renovate on the go run tools 2026-10-14 13:51:22 +02:00
12 changed files with 513 additions and 168 deletions
+38 -9
View File
@@ -14,6 +14,7 @@ permissions:
jobs:
test:
name: vet + fmt + tests
if: github.event_name != 'schedule'
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
@@ -22,7 +23,6 @@ jobs:
fetch-depth: 0
- name: Relevant changes
id: changes
if: github.event_name != 'schedule'
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
git fetch --no-tags origin "$GITHUB_BASE_REF"
@@ -30,7 +30,7 @@ jobs:
else
base='${{ github.event.before }}'
fi
if git diff --quiet "$base"...HEAD -- '*.go' go.mod go.sum Dockerfile .dockerignore .github data release-tooling testdata README.md; then
if git diff --quiet "$base"...HEAD -- '*.go' go.mod go.sum Dockerfile .dockerignore .github data release-tooling testdata vulnerability-filing README.md todo.md; then
echo "code=false" >> "$GITHUB_OUTPUT"
else
echo "code=true" >> "$GITHUB_OUTPUT"
@@ -42,7 +42,6 @@ jobs:
fi
echo "base=$base" >> "$GITHUB_OUTPUT"
- name: CHANGELOG entry for data changes
if: github.event_name != 'schedule'
run: |
base='${{ steps.changes.outputs.base }}'
if ! git diff --quiet "$base"...HEAD -- data testdata/shipped_shape.txt && git diff --quiet "$base"...HEAD -- CHANGELOG.md; then
@@ -59,11 +58,6 @@ jobs:
- name: Lowest supported Go
if: ${{ !cancelled() && steps.changes.outputs.code == 'true' }}
run: docker build --build-arg GO_VERSION=1.22.12 --target portable .
# Runs on every event, and daily, since a vulnerability is published without a change
# here; `--no-cache-filter` keeps a cached pass from hiding one.
- name: Known vulnerabilities in this Go release and the module
if: ${{ !cancelled() }}
run: docker build --no-cache-filter vulnerabilities --target vulnerabilities .
- name: Package repositories, signed with throwaway keys
if: ${{ !cancelled() && steps.changes.outputs.packaging == 'true' }}
run: |
@@ -72,9 +66,44 @@ jobs:
docker build -f release-tooling/package-repos.Dockerfile --target checks \
--secret id=gpg,env=PACKAGES_GPG_KEY --secret id=rsa,env=PACKAGES_RSA_KEY .
vulnerabilities:
name: Known vulnerabilities in this Go version and the required modules
runs-on: ubuntu-26.04
timeout-minutes: 10
permissions:
actions: write
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Fetch govulncheck's report from vuln.go.dev
run: docker build --no-cache-filter vulnerabilities --target vulnerability-report --output "$RUNNER_TEMP/vulnerabilities" .
- name: File each one in todo.md
id: file
run: python3 vulnerability-filing/file_vulnerabilities.py "$RUNNER_TEMP/vulnerabilities/report.json" todo.md
- name: Propose the filed items
if: failure() && steps.file.outcome == 'failure' && github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ github.token }}
run: |
if git diff --quiet todo.md || [ "$(gh pr list --head vulnerabilities --state open --json number --jq length)" != 0 ]; then
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git switch -C vulnerabilities
git commit -qam "File the known vulnerabilities govulncheck reports"
git push -qf "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" vulnerabilities
gh pr create --head vulnerabilities --title "File the known vulnerabilities govulncheck reports" \
--body "govulncheck reports known vulnerabilities in the Go version or a required module, and no release is cut until they are gone. This files them atop the next release in todo.md, scored 0.0 until rank_todo.py scores them; each item names the bump that fixes it."
# A pull request the workflow's token opens starts no workflow.
gh workflow run test.yml --ref vulnerabilities
release:
name: Release from CHANGELOG.md
needs: test
needs: [test, vulnerabilities]
if: github.event_name == 'push'
runs-on: ubuntu-26.04
permissions:
+2 -1
View File
@@ -1,6 +1,6 @@
# Rules
- Go runs only through `docker compose run --rm <test|vet|fmt|build|cyclo|generate>`; the merge gate is `docker build .`, CI's changelog check, its known-vulnerabilities check and, for a change the README's Development section lists, its package-repository checks.
- Go runs only through `docker compose run --rm <test|vet|fmt|build|cyclo|generate|vulncheck>`; the merge gate is what the README's Development section lists.
- Tests first, in their own commit; the implementation follows in the next. A re-pin of seeded output or of `testdata/shipped_shape.txt` is its own commit.
- A change under `data/` or to the shape pin adds its `CHANGELOG.md` entry under `Unreleased` in the same PR, and so does a change to a flag, an exit code, an exported name, a fence, a builtin or the lowest Go; what is major is the README's Versioning table.
- One-line commit messages: no ticket prefix, no repo name, no authorship trailers.
@@ -124,6 +124,7 @@ In [docs/decisions.md](docs/decisions.md):
- A country with no open address register covering it whole ships its places and postal codes without streets
- The apt, dnf and apk repositories are static files on GitHub Pages, holding the latest release only
- The image is pushed to GHCR and copied to Docker Hub, whose name the README shows
- A known vulnerability in the Go version or a required module blocks a release, even in code fejkdata never calls, and blocks no merge
# Scoring run
+11 -1
View File
@@ -14,12 +14,21 @@ RUN go vet ./... && \
FROM golang:${GO_VERSION} AS vulnerabilities
WORKDIR /app
COPY . .
RUN go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 -scan module
RUN go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 -scan module -format json > /report.json
FROM scratch AS vulnerability-report
COPY --from=vulnerabilities /report.json /
FROM python:3.14.8-slim AS release-tooling
COPY release-tooling/ /release-tooling/
RUN python3 -m unittest discover -s /release-tooling -p '*_test.py' && touch /tested
FROM python:3.14.8-slim AS vulnerability-filing
COPY vulnerability-filing/ /vulnerability-filing/
# The filer's test files an item into the plan itself.
COPY todo.md /todo.md
RUN python3 -m unittest discover -s /vulnerability-filing -p '*_test.py' && touch /tested
FROM goreleaser/goreleaser:v2.18.3 AS packaging
WORKDIR /check
COPY release-tooling/goreleaser.yaml ./
@@ -28,6 +37,7 @@ RUN git init -q && git remote add origin https://github.com/larvit/fejkdata.git
FROM portable
# BuildKit skips a stage nothing copies from.
COPY --from=release-tooling /tested /tmp/release-tooling-tested
COPY --from=vulnerability-filing /tested /tmp/vulnerability-filing-tested
COPY --from=packaging /tested /tmp/packaging-tested
RUN unformatted="$(gofmt -l .)"; test -z "$unformatted" || \
{ echo "unformatted files:"; echo "$unformatted"; exit 1; }
+21 -13
View File
@@ -1172,6 +1172,7 @@ data-import/ the scripts that rebuild each sourced table (see DATA-LICENSES.m
docs/ the decision log, the register research behind the shipped data, the survey of other libraries, panels' scores and rulings on how readable the code is, and what each chunk cost an agent to read
release-tooling/ the release CI publishes from the changelog heading, and the binaries, packages, image and package repositories built for it
testdata/ the pinned shipped shape (see Versioning)
vulnerability-filing/ the script CI runs to file each known vulnerability govulncheck reports as a `todo.md` item
```
## Development
@@ -1207,26 +1208,33 @@ docker compose run --rm --user root --entrypoint chown test -R "$(id -u):$(id -g
```
Every pull request runs `docker build .` against both the latest and the lowest
supported Go, and must pass before it can be merged — unless it changes none of
the files the build and its tests read, nor the workflow itself, in which case
it's skipped. That build runs vet, complexity, the format check, the Go tests and the
release tooling's tests; the rest of the gate is the changelog check, which CI runs
against the PR base, and the package-repository checks below. Run `docker build .`
locally before pushing. The lowest Go builds the `portable` stage, which skips the format
check and the release tooling's tests:
supported Go, and must pass before it can be merged — unless it changes none of the
files the build and its tests read, nor the workflow itself, in which case it's skipped.
That build runs vet, complexity, the format check, the Go tests, the release tooling's
tests and the vulnerability filer's tests, which file an item into `todo.md` itself, so
a change to the plan's shape can fail it; the rest of the gate is the changelog check,
which CI runs against the PR base, and the package-repository checks below. Run `docker
build .` locally before pushing. The lowest Go builds the `portable` stage, which skips
the format check and both Python test suites:
```sh
docker build . # latest
docker build --build-arg GO_VERSION=1.22.12 --target portable . # lowest supported
GO_VERSION=1.22.12 docker compose run --rm test # the same tests, without the image build
docker build --no-cache-filter vulnerabilities --target vulnerabilities . # known vulnerabilities
```
The last line runs `govulncheck` over the Go release the gate builds with and the module's
requirements, and fails on any known vulnerability, whether or not fejkdata calls it: the
release binaries carry that Go release, and image scanners flag it. CI runs it on every pull
request and push, and daily, and a release waits for it. A failure is fixed by raising
`GO_VERSION`, which Renovate opens a pull request for.
A known vulnerability in the Go version the gate builds with, or in a module fejkdata
requires, holds the release and no merge. CI checks on every pull request, every push to
`main`, and daily. On `main` and in the daily run, CI files each vulnerability as a
`security` item atop the next release in `todo.md`, through a pull request from the branch
`vulnerabilities` unless one is open; each item names the version to raise to. When the
step "Fetch govulncheck's report from vuln.go.dev" fails, vuln.go.dev did not answer:
rerun it. When GitHub pauses the daily run after 60 days with no activity, re-enable it
under Actions. The same check:
```sh
docker compose run --rm vulncheck
```
A change to Go code, `release-tooling/`, the `Dockerfile`, `.dockerignore`, the licence
files or either workflow also runs the package-repository checks. They build the release
+4
View File
@@ -35,6 +35,10 @@ services:
<<: *go
command: go run github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 -over 14 -ignore _test .
vulncheck:
<<: *go
command: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 -scan module
build:
<<: *go
command: go build ./...
+38
View File
@@ -682,3 +682,41 @@ Ruling: same
|---|---|---|---|---|---|
| Mid A | same | same | same | same | same |
| Inherited architect | same | same | same | same | same |
## 2026-10-14T11:34:34Z, PR #225 at f734c9e, against f90b4d6
Ruling: worse
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | worse | same | same | same |
| Inherited architect | same | worse | same | same | worse |
| Maintainability senior | same | worse | same | worse | worse |
| Junior A | same | worse | same | worse | worse |
Mid A, decided by:
- Locality: `Dockerfile:26` `vulnerability-filing`: the filer's test reads the real todo.md inside docker build ., so a plan edit can turn the Go gate red
- Locality: `vulnerability-filing/file_vulnerabilities.py:24` `ROW`: the filer splices todo.md as text, assuming its row shape, the Next ID marker and the Details heading
- Locality: `.github/workflows/test.yml:106` `release`: a release now also waits on vuln.go.dev's answer, visible only in a needs list
Inherited architect, decided by:
- Locality, Overall: `Dockerfile:26` `vulnerability-filing`: the plan is copied into the merge-gate build, so a planning edit that changes the table's shape fails docker build .
- Locality: `vulnerability-filing/file_vulnerabilities.py:101` `file_items`: edits todo.md as text; a missing Next ID crashes with exit 1, which reads like a vulnerability already planned
- Locality: `.github/workflows/test.yml:69` `vulnerabilities`: whether a release happens depends on vuln.go.dev that day, and every PR shows a red job while one is known
Maintainability senior, decided by:
- Locality: `vulnerability-filing/file_vulnerabilities.py:101` `file_items`: finds the release by an unnamed regex group, never checks the Next ID match, and splices Details by a replace that does nothing when the heading is missing
- Locality, Self-sufficiency: `.github/workflows/test.yml:83` `File each one in todo.md`: one exit code holds the release and triggers the proposal, which must tell filed, planned and crashed apart by a git diff
- Locality: `Dockerfile:26` `vulnerability-filing`: copies todo.md into the gate build, and the path filter lists todo.md
- Self-sufficiency: `README.md:1213` "Development": says the build runs the release tooling's tests and leaves out the filer's
- Self-sufficiency: `vulnerability-filing/file_vulnerabilities.py:2` module docstring: builds on rank_todo.py, which the repository does not hold
Junior A, decided by:
- Locality, Overall: `Dockerfile:26` `vulnerability-filing`: the Go merge gate copies todo.md and files an item into it, so editing the plan can turn the build red
- Locality: `.github/workflows/test.yml:106` `release`: whether a push to main releases also depends on vuln.go.dev's answer that day
- Self-sufficiency: `vulnerability-filing/file_vulnerabilities.py:24` `ROW`: the regex and the Details splice encode todo.md's layout, which the script never shows
- Self-sufficiency: `README.md:1213` "Development": the gate paragraph leaves out the filer's tests while AGENTS.md now defers to it
+22 -1
View File
@@ -988,4 +988,25 @@ change of path. GoReleaser pushes there, and a job of its own copies the image t
Hub, so a Docker Hub failure holds up no other channel. GHCR also serves CI on shared
runners, which Docker Hub's per-IP limit on anonymous pulls blocks. Leaving Docker Hub
costs each user an edit of every `docker run` line and pinned script that names the bare
`larvit/fejkdata`.
`larvit/fejkdata`.
## A known vulnerability in the Go version or a required module blocks a release, even in code fejkdata never calls, and blocks no merge
2026-10-14, decided by the maintainer after Docker Hub flagged v0.1.0's image. Serves goal
1.1. `govulncheck -scan module` fails on any known vulnerability in the Go version the gate
builds with or in a module fejkdata requires, even one fejkdata never calls: the release
binaries carry that Go version, and image scanners flag it whatever the code reaches. It
runs on every pull request and push and daily, as a job the release job needs but branch
protection does not require. So a vulnerability published in Go, or an outage of
vuln.go.dev, holds the release and leaves unrelated pull requests mergeable. Each
vulnerability is filed as a `security` item atop the next release in `todo.md`, by a pull
request CI opens, so the plan names it before anyone reads a red run.
`vulnerability-filing/file_vulnerabilities.py` writes that item in `todo.md`'s shape with a
Score of 0.0, since CI cannot reach `rank_todo.py`, which scores it on the next commit to the
plan; a test files one into the repository's own `todo.md`. When the next release has no item left and waits to be cut,
`todo.md` names no version for it, so the item lands in the release after; the maintainer
moves it. Where no Go version fixes a vulnerability yet, govulncheck has no ignore list: the
maintainer drops the job from the release's `needs` until one does, and records why here.
The paired ruling against f90b4d6 found the project reads worse for it: the gate now reads
`todo.md`, and a release waits on vuln.go.dev. The maintainer merged it anyway, as the cost
of filing into the plan. Valid while the binaries are built from the gate's Go version.
@@ -1,142 +0,0 @@
import io
import json
import os
import tempfile
import unittest
from contextlib import redirect_stdout
import file_vulnerabilities
TODO = """# todo
## Scoring
`Score = -R - S/4 + 2*A + 2*G*W`
`Bar = 9`
`Next ID = 7`
| Goal | W |
|---|---|
| 1 | 1.00 |
| 1.1 | 0.90 |
## Items
| ID | Release | Exempt | Item | R | S | C | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|---|
| 5 | 0.2.0 | | **Add a thing.** | 2 | 2 | 2 | 7 | 8 | 1 | 26.5 |
## Details
### 5. Add a thing.
Why.
"""
def report(*findings, osvs=()):
messages = [{"config": {"go_version": "go1.27.1", "scan_level": "module"}}]
messages += [{"osv": osv} for osv in osvs]
messages += [
{"finding": {"osv": osv, "fixed_version": fixed, "trace": [{"module": module, "version": version}]}}
for osv, module, version, fixed in findings
]
return "\n".join(json.dumps(m, indent=2) for m in messages)
OSVS = (
{"id": "GO-2026-0002", "summary": "Denial of service in net/http", "aliases": ["CVE-2026-2"]},
{"id": "GO-2026-0001", "summary": "Cross-site scripting in html/template", "aliases": ["CVE-2026-1"]},
{"id": "GO-2026-0003", "summary": "Panic in x/text", "aliases": []},
)
STDLIB = (
("GO-2026-0002", "stdlib", "v1.27.1", "v1.27.2"),
("GO-2026-0001", "stdlib", "v1.27.1", "v1.27.10"),
("GO-2026-0001", "stdlib", "v1.27.1", "v1.27.10"),
)
class FileItems(unittest.TestCase):
def test_no_finding_files_nothing(self):
self.assertEqual(file_vulnerabilities.file_items(report(osvs=OSVS), TODO), (TODO, 0))
def test_files_one_security_item_per_module_at_the_top_of_the_next_release(self):
todo, found = file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), TODO)
self.assertEqual(found, 2)
self.assertIn("`Next ID = 8`", todo)
rows = [line for line in todo.splitlines() if line.startswith("| ") and line[2].isdigit()]
self.assertEqual(
rows[0],
"| 7 | 0.2.0 | security | **Raise Go to 1.27.10, so the release binaries carry none of"
" its known vulnerabilities.** | 1 | 1 | 1 | 8 | 8 | 1.1 | 29.1 |",
)
self.assertTrue(rows[1].startswith("| 5 |"))
detail = todo.split("## Details\n\n", 1)[1]
self.assertTrue(
detail.startswith(
"### 7. Raise Go to 1.27.10, so the release binaries carry none of its known vulnerabilities.\n\n"
"govulncheck found these in Go 1.27.1:\n\n"
"- GO-2026-0001 (CVE-2026-1): Cross-site scripting in html/template\n"
"- GO-2026-0002 (CVE-2026-2): Denial of service in net/http\n\n"
),
detail,
)
self.assertIn("### 5. Add a thing.", detail)
def test_a_module_requirement_gets_its_own_item(self):
findings = STDLIB + (("GO-2026-0003", "golang.org/x/text", "v0.3.0", "v0.3.8"),)
todo, found = file_vulnerabilities.file_items(report(*findings, osvs=OSVS), TODO)
self.assertEqual(found, 3)
self.assertIn("| 8 | 0.2.0 | security | **Raise `golang.org/x/text` to v0.3.8, so the module requires none of its known vulnerabilities.** |", todo)
self.assertIn("- GO-2026-0003: Panic in x/text\n", todo)
self.assertIn("`Next ID = 9`", todo)
def test_a_vulnerability_already_planned_is_not_filed_again(self):
filed, _ = file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), TODO)
self.assertEqual(file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), filed), (filed, 2))
def test_a_plan_with_no_items_is_refused(self):
empty = TODO.split("| 5 |")[0].rstrip("\n") + "\n\n## Details\n"
with self.assertRaises(SystemExit):
file_vulnerabilities.file_items(report(*STDLIB, osvs=OSVS), empty)
class Main(unittest.TestCase):
def setUp(self):
cwd = os.getcwd()
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
os.chdir(tmp.name)
self.addCleanup(os.chdir, cwd)
with open("todo.md", "w", encoding="utf-8") as f:
f.write(TODO)
def run_main(self, text):
with open("report.json", "w", encoding="utf-8") as f:
f.write(text)
out = io.StringIO()
with redirect_stdout(out):
code = file_vulnerabilities.main(["report.json", "todo.md"])
with open("todo.md", encoding="utf-8") as f:
return code, out.getvalue(), f.read()
def test_a_clean_report_passes_and_leaves_the_plan(self):
self.assertEqual(self.run_main(report(osvs=OSVS)), (0, "No known vulnerabilities.\n", TODO))
def test_a_finding_fails_and_files_it(self):
code, out, todo = self.run_main(report(*STDLIB, osvs=OSVS))
self.assertEqual(code, 1)
self.assertEqual(out, "2 known vulnerabilities; todo.md item 7 plans the fix.\n")
self.assertIn("| 7 | 0.2.0 | security |", todo)
def test_a_finding_already_planned_still_fails(self):
self.run_main(report(*STDLIB, osvs=OSVS))
code, out, _ = self.run_main(report(*STDLIB, osvs=OSVS))
self.assertEqual(code, 1)
self.assertEqual(out, "2 known vulnerabilities, already planned in todo.md.\n")
if __name__ == "__main__":
unittest.main()
+12
View File
@@ -23,6 +23,18 @@
"matchStrings": [
"goreleaser/goreleaser-action@\\S+\\s+with:\\s+version: (?<currentValue>\\S+)"
]
},
{
"customType": "regex",
"datasourceTemplate": "go",
"description": "Go tools the Dockerfile and compose.yaml run with go run, by their module path",
"managerFilePatterns": [
"/^Dockerfile$/",
"/^compose\\.yaml$/"
],
"matchStrings": [
"go run (?<depName>[^@\\s]+?)(/cmd/[^@\\s]+)?@(?<currentValue>v\\S+)"
]
}
],
"extends": [
+11 -1
View File
@@ -6,7 +6,7 @@
`Bar = 9`
`Next ID = 253`
`Next ID = 255`
| Goal | W |
|---|---|
@@ -178,6 +178,8 @@
| 174 | 0.2.0 | | **Spell once the fact that a walk's next step leaves a selected row, which `pathPos.fromRow` and `tableRoute.nextLinked` each spell today.** | 1 | 1 | 3 | 1 | 5 | 3.2 | 9.8 |
| 108 | 0.2.0 | defect | **Name item 26 in the decision "No builtin reads the clock, so a date is bounded by days, never by an age", as the item that ends its conflict with goal 4.** | 1 | 1 | 1 | 1 | 4 | 3.4 | 8.0 |
| 169 | 0.2.0 | question | **Reword the decision "A parent row with no child row is a load error" to say what each geo import does with such a row: drop it, or exit where `geo-se.py` would lose a kommun or a län.** | 1 | 1 | 2 | 1 | 4 | 3 | 8.0 |
| 253 | 0.3.0 | | **Let `date()` take bounds relative to the date the caller gives, such as 30 days before it to 14 days after it.** | 2 | 3 | 4 | 6 | 6 | 7.3, 10.1 | 17.6 |
| 254 | 0.3.0 | | **Let a choice deal its items in exact counts across a run's renders, so 200 issues hold exactly 4 in progress, where weights only approach it.** | 3 | 4 | 6 | 6 | 6 | 5, 10 | 17.6 |
| 38 | 0.3.0 | defect | **Compare `calcParser.binary`'s operator as a rune.** | 1 | 1 | 2 | 3 | 6 | 6 | 13.8 |
| 45 | 0.3.0 | | **Name the node a selector follows in `childNamed`'s refusal.** | 1 | 1 | 2 | 3 | 6 | 6 | 13.8 |
| 49 | 0.3.0 | | **Report a CRLF rows file holding only its header as having no rows.** | 1 | 1 | 2 | 3 | 6 | 6 | 13.8 |
@@ -671,6 +673,14 @@ Item 26 revises the decision, but the decision does not point back to it.
Its entry says "The import script drops or fills such rows". There are two geo imports, and `geo-se.py` exits when `geo.fence` drops a kommun or a län, while `geo-us.py` drops counties and states. Rewording a decision is the maintainer's call.
### 253. Let `date()` take bounds relative to the date the caller gives, such as 30 days before it to 14 days after it.
Needs item 26, which makes the caller give the date and revises the decision "No builtin reads the clock, so a date is bounded by days, never by an age". Today a due date or a sprint window "around now" is two fixed days, re-pinned whenever the fixture should look current. A relative bound reads the caller's date, never the clock, so the same seed and date still give the same output (goal 10). Bench the spelling with the hand fixture author and the bulk fixture author, such as `date(-30d,+14d,'2006-01-02')`; the README's Functions row and the clock decision change with it.
### 254. Let a choice deal its items in exact counts across a run's renders, so 200 issues hold exactly 4 in progress, where weights only approach it.
A fixture often needs a fixed mix: of 200 issues, 4 in progress, 30 done and the rest to do. A weighted choice gives about 4, and the count shifts with the seed, so a test asserting on it, or a board built from it, needs a loader to fix the counts. A dealt choice is a deck, drawn without replacement until it runs out. Decide what a render past the last card does: refuse at load where `--repeat` is known to exceed the deck, start a fresh deck, or fall back to the weights. Decide too which renders share one deck: the run, as `{seq()}` shares one counter, or each `repeat`. Bench the spelling with the bulk fixture author. The deck is per-generator state beside `seq()`'s counters, so it must stay reproducible from one goroutine (goal 10). Where the change touches `render.go`'s `readField` or the pick state in `pick.go`, places the comprehension seats found hard, make it easier to read.
### 38. Compare `calcParser.binary`'s operator as a rune.
`byte(p.rs[p.pos])` reads U+012B `ī` as `+`, so `{calc(a ī b)}` compiles as `a + b`.
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""File each module's known vulnerabilities as one security item atop todo.md's next release.
Usage: file_vulnerabilities.py <govulncheck -format json report> <todo.md>
Exits 1 while any vulnerability is known, filed now or before, 0 on a clean report, and 2 on
a todo.md it cannot read.
todo.md's `Next ID = N` gives the item's ID, and the release of the plan's top row, the next
release, gives its release. Its Score is 0.0 until rank_todo.py, which CI cannot run, scores
it; a security item ranks first in its release whatever it scores. The test that files into
the repository's own todo.md fails the gate when the plan's shape drifts from what this
script reads.
Scores, as todo.md's table spells them: R (risk), S (size) and C (difficulty) are 1, since
the fix is a version bump; A (value to the audience) and G (value to the goal) are 8, the goal
being README goal 1.1, installing fejkdata the way one installs other tools, which a flagged
binary works against.
"""
import json
import re
import sys
from dataclasses import dataclass, field
ROW = re.compile(r"^\| (?P<id>\d+) \| (?P<release>[^|]+?) \| [^|]*\| \*\*", re.M)
NEXT_ID = re.compile(r"`Next ID = (\d+)`")
DETAILS = "## Details\n\n"
SCORES = {"R": 1, "S": 1, "C": 1, "A": 8, "G": 8}
GOAL = "1.1"
@dataclass
class Module:
path: str
found_in: str
fixed: list[str] = field(default_factory=list)
unfixed: bool = False
osvs: dict[str, dict] = field(default_factory=dict)
@property
def name(self) -> str:
return "Go" if self.path == "stdlib" else f"`{self.path}`"
def version(self, version: str) -> str:
return version.removeprefix("v") if self.path == "stdlib" else version
@property
def carried(self) -> str:
return f"{self.name} {self.version(self.found_in)}"
def messages(report: str):
decoder, at = json.JSONDecoder(), 0
while at < len(report):
if report[at].isspace():
at += 1
continue
message, at = decoder.raw_decode(report, at)
yield message
def vulnerable_modules(report: str) -> list[Module]:
"""Each module govulncheck found a vulnerability in, Go (stdlib) first, then by path."""
osvs, modules = {}, {}
for message in messages(report):
if "osv" in message:
osv = message["osv"]
osvs[osv["id"]] = {"id": osv["id"], "aliases": osv.get("aliases", []), "summary": osv.get("summary", "")}
elif "finding" in message:
finding = message["finding"]
frame = finding["trace"][0]
module = modules.setdefault(frame["module"], Module(frame["module"], frame["version"]))
module.osvs[finding["osv"]] = osvs[finding["osv"]]
if "fixed_version" in finding:
module.fixed.append(finding["fixed_version"])
else:
module.unfixed = True
return sorted(modules.values(), key=lambda m: (m.path != "stdlib", m.path))
def version_key(version: str):
return [int(part) for part in re.findall(r"\d+", version)]
def tagline(module: Module) -> str:
if module.unfixed:
return f"Ship no release while fejkdata carries {module.carried}, some of whose known vulnerabilities no version fixes yet."
fixed = module.version(max(module.fixed, key=version_key))
if module.path == "stdlib":
return f"Raise Go to {fixed}, so the release binaries carry none of {module.carried}'s known vulnerabilities."
return f"Raise {module.name} to {fixed}, so fejkdata requires no version of it with a known vulnerability."
def detail(module: Module) -> str:
lines = []
for osv in sorted(module.osvs.values(), key=lambda o: o["id"]):
aliases = f" ({', '.join(osv['aliases'])})" if osv["aliases"] else ""
summary = f": {osv['summary']}" if osv["summary"] else ""
lines.append(f"- {osv['id']}{aliases}{summary}")
return f"govulncheck found these in {module.carried}:\n\n" + "\n".join(lines) + "\n"
def file_items(modules: list[Module], todo: str) -> tuple[str, list[int]]:
"""Return todo.md with an item for each module whose vulnerabilities it does not yet name, and the items' IDs."""
first = ROW.search(todo)
if first is None:
raise ValueError("todo.md has no item to take the next release from")
if NEXT_ID.search(todo) is None:
raise ValueError("todo.md has no `Next ID = N` line to number the item by")
if DETAILS not in todo:
raise ValueError(f"todo.md has no {DETAILS.strip()!r} heading to put the item's detail under")
release, rows_at = first.group("release"), first.start()
details_at = todo.index(DETAILS) + len(DETAILS)
filed = []
for module in modules:
if all(osv in todo for osv in module.osvs):
continue
next_id = int(NEXT_ID.search(todo).group(1))
line = tagline(module)
scores = " | ".join(str(SCORES[k]) for k in "RSCAG")
row = f"| {next_id} | {release} | security | **{line}** | {scores} | {GOAL} | 0.0 |\n"
text = f"### {next_id}. {line}\n\n{detail(module)}\n"
todo = todo[:rows_at] + row + todo[rows_at:]
rows_at += len(row)
details_at += len(row)
todo = todo[:details_at] + text + todo[details_at:]
details_at += len(text)
todo = NEXT_ID.sub(f"`Next ID = {next_id + 1}`", todo, count=1)
filed.append(next_id)
return todo, filed
def main(argv: list[str]) -> int:
report_path, todo_path = argv
with open(report_path, encoding="utf-8") as f:
modules = vulnerable_modules(f.read())
if not modules:
print("No known vulnerabilities.")
return 0
count = sum(len(m.osvs) for m in modules)
known = f"{count} known {'vulnerability' if count == 1 else 'vulnerabilities'}"
with open(todo_path, encoding="utf-8") as f:
try:
todo, filed = file_items(modules, f.read())
except ValueError as err:
print(err, file=sys.stderr)
return 2
if not filed:
print(f"{known}, already planned in todo.md.")
return 1
with open(todo_path, "w", encoding="utf-8") as f:
f.write(todo)
items = ", ".join(map(str, filed))
print(f"{known}; todo.md {'item ' + items + ' plans' if len(filed) == 1 else 'items ' + items + ' plan'} the fix.")
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,195 @@
import io
import json
import os
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
import file_vulnerabilities
TODO = """# todo
## Scoring
`Score = -R - S/4 + 2*A + 2*G*W`
`Bar = 9`
`Next ID = 7`
| Goal | W |
|---|---|
| 1 | 1.00 |
| 1.1 | 0.90 |
## Items
| ID | Release | Exempt | Item | R | S | C | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|---|
| 5 | 0.2.0 | | **Add a thing.** | 2 | 2 | 2 | 7 | 8 | 1 | 26.5 |
## Details
### 5. Add a thing.
Why.
"""
OSVS = (
{"id": "GO-2026-0002", "summary": "Denial of service in net/http", "aliases": ["GHSA-2222", "CVE-2026-2"]},
{"id": "GO-2026-0001", "summary": "Cross-site scripting in html/template", "aliases": ["CVE-2026-1"]},
{"id": "GO-2026-0003", "summary": "Panic in x/text"},
{"id": "GO-2026-0004"},
)
STDLIB = (
("GO-2026-0002", "stdlib", "v1.27.1", "v1.27.2"),
("GO-2026-0001", "stdlib", "v1.27.1", "v1.27.10"),
("GO-2026-0001", "stdlib", "v1.27.1", "v1.27.10"),
)
TEXT = (("GO-2026-0003", "golang.org/x/text", "v0.3.0", "v0.3.8"),)
GO_TAGLINE = "Raise Go to 1.27.10, so the release binaries carry none of Go 1.27.1's known vulnerabilities."
def report(*findings):
messages = [{"config": {"go_version": "go1.27.1", "scan_level": "module"}}]
messages += [{"osv": osv} for osv in OSVS]
for osv, module, version, fixed in findings:
finding = {"osv": osv, "trace": [{"module": module, "version": version}]}
if fixed:
finding["fixed_version"] = fixed
messages.append({"finding": finding})
return "\n".join(json.dumps(m, indent=2) for m in messages)
def file(todo, *findings):
return file_vulnerabilities.file_items(file_vulnerabilities.vulnerable_modules(report(*findings)), todo)
def rows(todo):
return [line for line in todo.splitlines() if "| **" in line]
class FileItems(unittest.TestCase):
def test_no_finding_files_nothing(self):
self.assertEqual(file(TODO), (TODO, []))
def test_files_one_security_item_per_module_atop_the_next_release(self):
todo, filed = file(TODO, *STDLIB)
self.assertEqual(filed, [7])
self.assertIn("`Next ID = 8`", todo)
self.assertEqual(rows(todo)[0], f"| 7 | 0.2.0 | security | **{GO_TAGLINE}** | 1 | 1 | 1 | 8 | 8 | 1.1 | 0.0 |")
self.assertTrue(rows(todo)[1].startswith("| 5 |"))
self.assertTrue(
todo.split("## Details\n\n", 1)[1].startswith(
f"### 7. {GO_TAGLINE}\n\n"
"govulncheck found these in Go 1.27.1:\n\n"
"- GO-2026-0001 (CVE-2026-1): Cross-site scripting in html/template\n"
"- GO-2026-0002 (GHSA-2222, CVE-2026-2): Denial of service in net/http\n\n"
"### 5. Add a thing."
)
)
def test_a_required_module_gets_its_own_item(self):
todo, filed = file(TODO, *STDLIB, *TEXT)
self.assertEqual(filed, [7, 8])
self.assertIn(
"| 8 | 0.2.0 | security | **Raise `golang.org/x/text` to v0.3.8, so fejkdata requires no version"
" of it with a known vulnerability.** |",
todo,
)
self.assertIn("govulncheck found these in `golang.org/x/text` v0.3.0:\n\n- GO-2026-0003: Panic in x/text\n", todo)
self.assertIn("`Next ID = 9`", todo)
self.assertEqual([row.split(" | ")[0] for row in rows(todo)], ["| 7", "| 8", "| 5"])
details = todo.split("## Details\n\n", 1)[1]
self.assertLess(details.index("### 7."), details.index("### 8."))
def test_a_vulnerability_already_planned_is_not_filed_again(self):
planned, _ = file(TODO, *STDLIB)
self.assertEqual(file(planned, *STDLIB), (planned, []))
def test_a_module_newly_vulnerable_is_filed_beside_one_already_planned(self):
planned, _ = file(TODO, *STDLIB)
todo, filed = file(planned, *STDLIB, *TEXT)
self.assertEqual(filed, [8])
self.assertIn("| 8 | 0.2.0 | security | **Raise `golang.org/x/text`", todo)
def test_a_vulnerability_no_version_fixes_names_no_target(self):
todo, _ = file(TODO, *STDLIB, ("GO-2026-0004", "stdlib", "v1.27.1", ""))
self.assertIn("- GO-2026-0004\n", todo)
self.assertIn(
"| 7 | 0.2.0 | security | **Ship no release while fejkdata carries Go 1.27.1, some of whose known"
" vulnerabilities no version fixes yet.** |",
todo,
)
def test_a_plan_with_no_next_id_is_refused(self):
with self.assertRaises(ValueError):
file(TODO.replace("`Next ID = 7`", ""), *STDLIB)
def test_a_plan_with_no_details_heading_is_refused(self):
with self.assertRaises(ValueError):
file(TODO.replace("## Details", "## Notes"), *STDLIB)
def test_a_plan_with_no_items_is_refused(self):
empty = TODO.split("| 5 |")[0].rstrip("\n") + "\n\n## Details\n"
with self.assertRaises(ValueError):
file(empty, *STDLIB)
def test_the_repositorys_plan_takes_an_item_in_its_own_shape(self):
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "todo.md")
with open(path, encoding="utf-8") as f:
plan = f.read()
todo, filed = file(plan, *STDLIB)
header = next(line for line in plan.splitlines() if line.startswith("| ID |"))
row = rows(todo)[0]
self.assertEqual(row.count("|"), header.count("|"))
self.assertEqual(row.split(" | ")[1], rows(plan)[0].split(" | ")[1])
self.assertRegex(row, rf"^\| {filed[0]} \| .* \| 0\.0 \|$")
self.assertIn(f"`Next ID = {filed[0] + 1}`", todo)
class Main(unittest.TestCase):
def setUp(self):
cwd = os.getcwd()
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
os.chdir(tmp.name)
self.addCleanup(os.chdir, cwd)
with open("todo.md", "w", encoding="utf-8") as f:
f.write(TODO)
def run_main(self, *findings):
with open("report.json", "w", encoding="utf-8") as f:
f.write(report(*findings))
out = io.StringIO()
with redirect_stdout(out):
code = file_vulnerabilities.main(["report.json", "todo.md"])
with open("todo.md", encoding="utf-8") as f:
return code, out.getvalue(), f.read()
def test_a_clean_report_passes_and_leaves_the_plan(self):
self.assertEqual(self.run_main(), (0, "No known vulnerabilities.\n", TODO))
def test_a_finding_fails_and_files_it(self):
code, out, todo = self.run_main(*STDLIB)
self.assertEqual((code, out), (1, "2 known vulnerabilities; todo.md item 7 plans the fix.\n"))
self.assertIn("| 7 | 0.2.0 | security |", todo)
def test_findings_in_two_modules_name_both_items(self):
_, out, _ = self.run_main(*STDLIB, *TEXT)
self.assertEqual(out, "3 known vulnerabilities; todo.md items 7, 8 plan the fix.\n")
def test_a_plan_it_cannot_read_exits_2(self):
with open("todo.md", "w", encoding="utf-8") as f:
f.write(TODO.replace("`Next ID = 7`", ""))
with redirect_stderr(io.StringIO()) as err:
self.assertEqual(self.run_main(*STDLIB)[:2], (2, ""))
self.assertEqual(err.getvalue(), "todo.md has no `Next ID = N` line to number the item by\n")
def test_one_finding_already_planned_still_fails(self):
self.run_main(*TEXT)
self.assertEqual(self.run_main(*TEXT)[:2], (1, "1 known vulnerability, already planned in todo.md.\n"))
if __name__ == "__main__":
unittest.main()