Settle the compatibility contract, the changelog and the changelog-cut Gitea release #17

Merged
lilleman merged 6 commits from worktree-versioning-contract into main 2026-09-16 20:20:01 +02:00
9 changed files with 442 additions and 27 deletions
+27 -1
View File
@@ -26,11 +26,19 @@ jobs:
else else
base='${{ github.event.before }}' base='${{ github.event.before }}'
fi fi
if git diff --quiet "$base"...HEAD -- '*.go' go.mod go.sum Dockerfile .dockerignore .github data README.md; then if git diff --quiet "$base"...HEAD -- '*.go' go.mod go.sum Dockerfile .dockerignore .github data testdata README.md; then
echo "code=false" >> "$GITHUB_OUTPUT" echo "code=false" >> "$GITHUB_OUTPUT"
else else
echo "code=true" >> "$GITHUB_OUTPUT" echo "code=true" >> "$GITHUB_OUTPUT"
fi fi
echo "base=$base" >> "$GITHUB_OUTPUT"
- name: CHANGELOG entry for data changes
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
echo "::error::the shipped data changed without a CHANGELOG.md entry (see the README's Versioning)"
exit 1
fi
# `docker build` streams the context to the daemon. A compose bind-mount of # `docker build` streams the context to the daemon. A compose bind-mount of
# `.` mounts an empty host dir instead, because the job is itself a container. # `.` mounts an empty host dir instead, because the job is itself a container.
- name: Latest supported Go - name: Latest supported Go
@@ -41,3 +49,21 @@ jobs:
- name: Lowest supported Go - name: Lowest supported Go
if: ${{ !cancelled() && steps.changes.outputs.code == 'true' }} if: ${{ !cancelled() && steps.changes.outputs.code == 'true' }}
run: docker build --build-arg GO_VERSION=1.22.12 . run: docker build --build-arg GO_VERSION=1.22.12 .
release:
name: Gitea release from CHANGELOG.md
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- run: python3 release-tooling/publish_release.py
env:
GITEA_API_URL: ${{ github.api_url }}
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.sha }}
+3 -2
View File
@@ -1,7 +1,8 @@
# Rules # Rules
- Go runs only through `docker compose run --rm <test|vet|fmt|build>`; the merge gate is `docker build .`. - Go runs only through `docker compose run --rm <test|vet|fmt|build>`; the merge gate is `docker build .` plus CI's changelog check.
- Tests first, in their own commit; the implementation follows in the next. A re-pin of seeded output is its own commit. - 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. - One-line commit messages: no ticket prefix, no repo name, no authorship trailers.
- Hard tabs. No comment by default; delete a restatement, a rationale, history, or a file preamble. - Hard tabs. No comment by default; delete a restatement, a rationale, history, or a file preamble.
- One spelling per result: reject the other at `New`, and let the error name the spelling to use. - One spelling per result: reject the other at `New`, and let the error name the spelling to use.
+11
View File
@@ -0,0 +1,11 @@
# Changelog
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). A release's
`Breaking` section comes first and names each rejected spelling with its
replacement, and each removed path, column or flag.
## [Unreleased]
### Added
- First release: the CLI, the library and the shipped data set.
+77 -10
View File
@@ -529,6 +529,41 @@ then costs about what its output costs: an unweighted pick is O(1) whatever the
list's length, a weighted one O(log n), and long formats, deep nesting and many list's length, a weighted one O(log n), and long formats, deep nesting and many
tokens add cost in proportion to the output. tokens add cost in proportion to the output.
## Versioning
Semver tags on `main`, `v0.1.0` first; one version covers the shipped data, the
library and the CLI, and [`CHANGELOG.md`](CHANGELOG.md) names what each release
changed. A consumer's data, code and scripts keep working across a minor or a patch:
a minor only adds, and a major is the only release that changes what exists.
| Surface | Major | Minor |
|---------|-------|-------|
| Shipped data | remove or rename a path; change a category's format; remove a value, or change a weight or a repeat; add a reference from one shipped category into another | a path outside a record's columns, a locale, a value in a list |
| Records | remove, rename, retype or add a column; let a column be null | a record, as a new category |
| Data format | a fence: a spelling `New` rejects that it accepted; a template option, since it reserves a field name | a builtin |
| CLI | remove or rename a flag, or change its default; change what an exit code means; change the framing a `--format` writes (header, quoting, statement shape), the `--list` layout, or what an error names | a flag, a format |
| Library | change or remove an exported name; raise the lowest supported Go | an exported name, a `With…` option |
A patch changes no row of this table: performance, docs, or a fix inside a promised
behaviour that changes no value, path, format or spelling.
Seeded output is a promise within one version: same seed, same version, same
data, same output. Any release may shift a stream, since a value added to a list
moves every draw after it, so pin fixtures per version. An error's wording may
improve in a minor; the path, rejected spelling and replacement it names may not.
Before `v1.0.0` a minor is the breaking unit: `0.(x+1).0` may carry a major's
changes, each named in the changelog, and a `0.x.y` patch may not. `v1.0.0` is
cut once the shipped data is in its record shape and one full minor has shipped
with no breaking change. From `v2` the module path carries `/vN`, so fences ship
batched into as few majors as possible.
[`testdata/shipped_shape.txt`](testdata/shipped_shape.txt) pins every path, each
template category's format, the categories each category reads, and each column's
datatype and nullability; a pull request that
changes it or `data/` adds its `CHANGELOG.md` entry, which CI checks. A removed,
renamed or retyped line is a major.
## Goals ## Goals
1. **Valid by construction** — every value passes the check its real consumer 1. **Valid by construction** — every value passes the check its real consumer
@@ -613,14 +648,31 @@ tokens add cost in proportion to the output.
folder, `..` the folder above — what those spellings already mean to anyone who folder, `..` the folder above — what those spellings already mean to anyone who
has typed a path. A locale's files reach each other without naming the locale, has typed a path. A locale's files reach each other without naming the locale,
so a folder renames and copies without editing its references. so a folder renames and copies without editing its references.
- **After the first tag, a new fence is a major version.** Data files are the - **A change to what exists is a major; a minor only adds.** Data files, the CLI
public API, and one spelling per result grows by tightening, so every fence and the Go API are the public API, and a consumer must be able to take a minor
invalidates some file. Each such release names the rejected spelling and its without an edit — so an added column is a major, since it changes the CSV header
replacement in the changelog and in the load error, and that is the whole and the `INSERT` column list, as is a removed value, which changes what a fixture
migration: a fence rejects one spelling with one replacement, so the fix is holds, and a new option, which reserves a field name. One spelling per result
local to each site. A fence that would need a non-local rewrite ships a grows by tightening, so every fence invalidates some file. Each such release
converter with its release instead. Before the first tag there is no names the rejected spelling and its replacement in the changelog and in the load
compatibility promise. error, and that is the whole migration: a fence rejects one spelling with one
replacement, so the fix is local to each site. A fence that would need a
non-local rewrite ships a converter with its release instead. Before `v1.0.0` a
minor carries what a major would.
- **Seeded output is promised within one version.** Any edit to a category shifts
its stream and everything drawn after it, so a promise across versions would
freeze every shipped list; a fixture is re-pinned on a bump, as this repo's own are.
- **An error is a contract by what it names, not its bytes.** A script branches on
the exit code and reads the named path or spelling, so those hold; wording improves
in a minor.
- **Raising the lowest supported Go is a major.** A consumer building on it breaks,
which is the one test every rule above applies; Go's convention of a minor is not
followed.
- **The changelog heading is the one spelling of a release; CI cuts the tag.** A
tag pushed by hand is served by `go get` at once, so a tag whose commit lacks its
heading is burnt, not fixed. The heading on a gate-passed `main` commit is the
trigger instead: the tag can land only there, and the Gitea release the same job
publishes keeps one text as its body and is where prebuilt binaries will attach.
- **A `--data-path` override rebinds every reference to the category it - **A `--data-path` override rebinds every reference to the category it
replaces.** References bind against the merged tree, so once shipped data uses replaces.** References bind against the merged tree, so once shipped data uses
`{.person}`, a consumer's `sv_SE/person.json` is what every shipped reference `{.person}`, a consumer's `sv_SE/person.json` is what every shipped reference
@@ -770,8 +822,9 @@ docker compose run --rm --user "$(id -u):$(id -g)" tidy # go mod tidy
Every pull request runs `docker build .` against both the latest and the lowest 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 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 the files the build and its tests read, nor the workflow itself, in which case
it's skipped (see [Decisions](#decisions)). That build is the whole gate — vet, it's skipped (see [Decisions](#decisions)). That build is the whole gate but the
complexity, format check and tests — so run it locally before pushing: changelog check, which CI runs against the PR base — vet, complexity, format check
and tests — so run it locally before pushing:
```sh ```sh
docker build . # latest docker build . # latest
@@ -779,6 +832,18 @@ docker build --build-arg GO_VERSION=1.22.12 . # lowest supported
GO_VERSION=1.22.12 docker compose run --rm test # the same tests, without the image build GO_VERSION=1.22.12 docker compose run --rm test # the same tests, without the image build
``` ```
A change to the shipped data re-pins [`testdata/shipped_shape.txt`](testdata/shipped_shape.txt)
in its own commit:
```sh
REPIN=1 docker compose run --rm --user "$(id -u):$(id -g)" test
```
To release, head `CHANGELOG.md` with the version's section in place of `Unreleased`
and merge: once `main` passes the gate, CI tags that commit `vX.Y.Z` and publishes
the Gitea release with the section as its body. A top heading of `[Unreleased]`
publishes nothing.
## Layout ## Layout
``` ```
@@ -801,6 +866,8 @@ value.go the value proof: what a typed column or calc operand holds, chec
data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge
cmd/fejkdata/ the fejkdata CLI cmd/fejkdata/ the fejkdata CLI
data/ shipped data (JSON), embedded at build: locale folders + a misc folder data/ shipped data (JSON), embedded at build: locale folders + a misc folder
release-tooling/ the release CI publishes from the changelog heading
testdata/ the pinned shipped shape (see Versioning)
``` ```
## License ## License
+1
View File
@@ -24,6 +24,7 @@ x-go: &go
HOME: /cache HOME: /cache
GOCACHE: /cache/build GOCACHE: /cache/build
GOMODCACHE: /cache/mod GOMODCACHE: /cache/mod
REPIN: ${REPIN:-}
services: services:
test: test:
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Publish the Gitea release that CHANGELOG.md's top heading names, tagging SHA.
A top heading of `[Unreleased]`, or a version already released, publishes nothing.
Env: GITEA_API_URL, GITEA_REPOSITORY (owner/repo), GITEA_TOKEN, SHA.
"""
import json
import os
import re
import sys
import urllib.error
import urllib.request
HEADING = re.compile(r"^## \[([^\]]+)\].*?$\n?(.*?)(?=^## \[|\Z)", re.M | re.S)
def request(path: str, data: dict | None = None):
req = urllib.request.Request(
f"{os.environ['GITEA_API_URL']}/repos/{os.environ['GITEA_REPOSITORY']}/{path}",
data=json.dumps(data).encode() if data else None,
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
return json.load(resp)
def main() -> int:
with open("CHANGELOG.md", encoding="utf-8") as f:
top = HEADING.search(f.read())
if top is None:
print("CHANGELOG.md has no `## [...]` heading", file=sys.stderr)
return 1
version, body = top.group(1), top.group(2).strip()
if version == "Unreleased":
print("top heading is Unreleased; nothing to publish")
return 0
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
print(f"top heading `[{version}]` is neither Unreleased nor X.Y.Z", file=sys.stderr)
return 1
tag = f"v{version}"
try:
print(f"{tag} already published: {request(f'releases/tags/{tag}')['html_url']}")
return 0
except urllib.error.HTTPError as e:
if e.code != 404:
raise
sha = os.environ["SHA"]
try:
at = request(f"tags/{tag}")["commit"]["sha"]
if at != sha:
print(f"{tag} exists at {at}, not {sha}; the version is burnt, bump the heading", file=sys.stderr)
return 1
except urllib.error.HTTPError as e:
if e.code != 404:
raise
release = request("releases", {"body": body, "name": tag, "tag_name": tag, "target_commitish": sha})
print(release["html_url"])
return 0
if __name__ == "__main__":
sys.exit(main())
+114
View File
@@ -0,0 +1,114 @@
package fejkdata
import (
"os"
"sort"
"strconv"
"strings"
"testing"
)
const shapePin = "testdata/shipped_shape.txt"
// REPIN=1 rewrites the pin.
func TestShippedShapeIsPinned(t *testing.T) {
f, err := New(WithSeed(1))
if err != nil {
t.Fatal(err)
}
got := shippedShape(f)
if os.Getenv("REPIN") == "1" {
if err := os.WriteFile(shapePin, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(shapePin)
if err != nil {
t.Fatal(err)
}
if got != string(want) {
t.Fatalf("the shipped shape differs from %s: a removed, renamed or retyped line is a breaking change; add the CHANGELOG.md entry, then repin with REPIN=1", shapePin)
}
}
func shippedShape(f *Generator) string {
facts := map[string]string{}
var walk func(prefix string, n node)
walk = func(prefix string, n node) {
switch n := n.(type) {
case *folder:
for _, name := range sortedNames(n.children) {
walk(join(prefix, name), n.children[name])
}
case *choice:
facts[prefix] = reads(n)
case *template:
facts[prefix] = "\tformat " + strconv.Quote(n.format) + reads(n)
if _, columns, err := recordOf(n); err == nil {
for _, c := range columns {
fact := "\t" + c.DataType.String()
if _, nullable := columnItems(n.fields[c.Name]); nullable {
fact += " null"
}
facts[join(prefix, c.Name)] = fact
}
}
}
}
for _, name := range sortedNames(f.categories) {
walk(name, f.categories[name])
}
var b strings.Builder
for _, p := range f.List() {
b.WriteString(p + facts[p] + "\n")
}
return b.String()
}
// reads names the categories any template under n references, sorted.
func reads(n node) string {
set := map[string]bool{}
var collect func(node)
collect = func(n node) {
switch n := n.(type) {
case *choice:
for _, it := range n.items {
collect(it)
}
case *template:
for _, b := range n.refs {
set[strings.TrimPrefix(b.key, "/")] = true
}
for name, field := range n.fields {
if !isRef(name) {
collect(field)
}
}
}
}
collect(n)
if len(set) == 0 {
return ""
}
keys := make([]string, 0, len(set))
for k := range set {
keys = append(keys, k)
}
sort.Strings(keys)
return "\treads " + strings.Join(keys, " ")
}
func TestShippedShapeNamesReads(t *testing.T) {
f := newGenerator(t, writeData(t, map[string]string{
"a": `{"format":"{x}","x":["{/b}",{"format":"{/c.v}","weight":2}]}`,
"b": `"y"`,
"c": `{"format":"{v} {n}","n":[null,{"format":"{int(1,9)}","datatype":"integer"}],"v":["z","w"]}`,
"d/pos": `["{.q}","{/b}"]`,
"d/q": `"r"`,
}))
want := "a\tformat \"{x}\"\treads b c\na.x\tstring\nb\tformat \"y\"\nc\tformat \"{v} {n}\"\nc.n\tinteger null\nc.v\tstring\nd.pos\treads b d.q\nd.q\tformat \"r\"\n"
if got := shippedShape(f); got != want {
t.Fatalf("shippedShape =\n%s\nwant\n%s", got, want)
}
}
+141
View File
@@ -0,0 +1,141 @@
en_US.address format "{street-number} {street}\n{locality}, {region} {postal-code}"
en_US.address.locality string
en_US.address.postal-code string
en_US.address.region string
en_US.address.street string
en_US.address.street-number string
en_US.address.street.name
en_US.address.street.suffix
en_US.color
en_US.company format "{base} {suffix}"
en_US.company.base string
en_US.company.suffix string
en_US.date format "{month}/{day}/{year}"
en_US.date.day string
en_US.date.month string
en_US.date.year string
en_US.email format "{local}@{domain}"
en_US.email.domain string
en_US.email.local string
en_US.email.local.n
en_US.ip
en_US.person format "{prefix}{femalefirst|malefirst} {last}"
en_US.person.femalefirst string
en_US.person.last string
en_US.person.malefirst string
en_US.person.prefix string
en_US.phone
en_US.phone.area
en_US.phone.exch
en_US.phone.line
en_US.price format "${amt}.{cents}"
en_US.price.amt string
en_US.price.cents string
en_US.sentence
en_US.sentence.adj
en_US.sentence.noun
en_US.sentence.prep
en_US.sentence.verb
en_US.ssn format "{int(100,999)}-{digits(2)}-{digits(4)}"
en_US.time format "{hour}:{minute} {ampm}"
en_US.time.ampm string
en_US.time.hour string
en_US.time.minute string
en_US.time.minute.t
en_US.url format "https://{host}{path}"
en_US.url.host string
en_US.url.path string
en_US.username
en_US.username.n
en_US.version format "{pre}{n}.{n}.{n}{suffix}"
en_US.version.n string
en_US.version.pre string
en_US.version.suffix string
en_US.word
misc.car
misc.car.maker
misc.car.model
misc.coordinate format "{lat}, {lon}"
misc.coordinate.lat string
misc.coordinate.lon string
misc.country
misc.country.alpha2
misc.country.alpha3
misc.country.name
misc.creditcard
misc.creditcard.d
misc.currency
misc.currency.code
misc.currency.name
misc.currency.symbol
misc.emoji
misc.httpstatus
misc.httpstatus.code
misc.httpstatus.reason
misc.language
misc.language.code
misc.language.name
misc.mac format "{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}"
misc.mimetype
misc.mimetype.ext
misc.mimetype.type
misc.objectid format "{hex(24)}"
misc.timezone
misc.useragent
misc.uuid format "{hex(8)}-{hex(4)}-4{hex(3)}-{variant}{hex(3)}-{hex(12)}"
misc.uuid.variant string
sv_SE.address format "{street} {street-number}\n{postal-code} {locality}"
sv_SE.address.locality string
sv_SE.address.postal-code string
sv_SE.address.street string
sv_SE.address.street-number string
sv_SE.color
sv_SE.company format "{base} {suffix}"
sv_SE.company.base string
sv_SE.company.suffix string
sv_SE.date format "{year}-{month}-{day}"
sv_SE.date.day string
sv_SE.date.month string
sv_SE.date.year string
sv_SE.email format "{local}@{domain}"
sv_SE.email.domain string
sv_SE.email.local string
sv_SE.email.local.n
sv_SE.ip
sv_SE.person format "{prefix}{femalefirst|malefirst} {last}"
sv_SE.person.femalefirst string
sv_SE.person.last string
sv_SE.person.malefirst string
sv_SE.person.prefix string
sv_SE.phone
sv_SE.phone.a
sv_SE.phone.b
sv_SE.phone.c
sv_SE.phone.prefix
sv_SE.price format "{amt}{ore} kr"
sv_SE.price.amt string
sv_SE.price.ore string
sv_SE.sentence
sv_SE.sentence.adj
sv_SE.sentence.noun
sv_SE.sentence.prep
sv_SE.sentence.verb
sv_SE.ssn format "{digits(2)}{mmdd}-{digits(3)}{luhn()}"
sv_SE.ssn.mmdd string
sv_SE.ssn.mmdd.d
sv_SE.ssn.mmdd.m
sv_SE.time format "{hour}:{minute}{sec}"
sv_SE.time.hour string
sv_SE.time.minute string
sv_SE.time.minute.t
sv_SE.time.sec string
sv_SE.url format "https://{host}{path}"
sv_SE.url.host string
sv_SE.url.path string
sv_SE.username
sv_SE.username.n
sv_SE.version format "{pre}{n}.{n}.{n}{suffix}"
sv_SE.version.n string
sv_SE.version.pre string
sv_SE.version.suffix string
sv_SE.word
+5 -14
View File
@@ -7,24 +7,15 @@
- Major data update. Shipped categories render as records with their building - Major data update. Shipped categories render as records with their building
blocks as columns (`sv_SE.person` → `femalefirst`, `malefirst`; `misc.uuid` → blocks as columns (`sv_SE.person` → `femalefirst`, `malefirst`; `misc.uuid` →
`variant`), and `sv_SE.address` draws its postal code apart from its locality. `variant`), and `sv_SE.address` draws its postal code apart from its locality.
It also settles what a version promises about shipped data: its paths, its
record columns with their datatypes and nulls — a column of one reference alone
takes both, so typing a column or adding a null breaks its readers — and whether
a seed renders the same output across versions.
- `email.local` and `username` share their handle lists, while their name - `email.local` and `username` share their handle lists, while their name
variants differ on purpose. Share the lists only if that is a clean win. variants differ on purpose. Share the lists only if that is a clean win — a
reference between shipped categories is a major once tagged.
### Release ### Release
- Versioning — semver tags, starting at `v0.1.0`; `v1.0.0` once the grammar - CLI without Go — investigate prebuilt binaries: GoReleaser attaching them to
settles. From v2 the module path carries `/vN` (`go.mod`, imports, the README's the Gitea release the tag workflow publishes, a container image, Homebrew and
install lines), so fences ship batched into as few majors as possible. Reword Scoop. A checkout build prints `devel` for `--version`; the binaries carry the stamped tag.
the Decision "After the first tag, a new fence is a major version" to match:
before `v1.0.0` a fence ships in a minor.
- Changelog — `CHANGELOG.md`, started with `v0.1.0`; the fence Decision's
"changelog" links there.
- CLI without Go — investigate prebuilt binaries: GoReleaser publishing to Gitea
releases, a container image, Homebrew and Scoop.
- Homepage — a simple page for fejkdata with an in-browser generator: the library - Homepage — a simple page for fejkdata with an in-browser generator: the library
compiled to WebAssembly, so visitors generate as much data as they like in their compiled to WebAssembly, so visitors generate as much data as they like in their
own browser. own browser.