Settle the compatibility contract, the changelog and the tag-published Gitea release
Tests / vet + fmt + tests (pull_request) Successful in 1m7s

This commit is contained in:
2026-09-16 19:29:44 +02:00
parent f9181a08b5
commit 7bf3da807c
8 changed files with 159 additions and 24 deletions
+23
View File
@@ -0,0 +1,23 @@
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
name: Gitea release from CHANGELOG.md
runs-on: ubuntu-24.04
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 }}
TAG: ${{ github.ref_name }}
+5 -1
View File
@@ -26,11 +26,15 @@ jobs:
else
base='${{ github.event.before }}'
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"
else
echo "code=true" >> "$GITHUB_OUTPUT"
fi
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
# `.` mounts an empty host dir instead, because the job is itself a container.
- name: Latest supported Go
+2 -1
View File
@@ -1,7 +1,8 @@
# Rules
- Go runs only through `docker compose run --rm <test|vet|fmt|build>`; the merge gate is `docker build .`.
- 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; what is major is the README's Versioning table.
- 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.
- 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.
+72 -8
View File
@@ -529,6 +529,37 @@ 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
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, 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; an 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 bytes a `--format` writes, 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, an option |
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
category's format 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
1. **Valid by construction** — every value passes the check its real consumer
@@ -613,14 +644,30 @@ tokens add cost in proportion to the output.
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,
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
public API, and one spelling per result grows by tightening, so every fence
invalidates some file. Each such release names the rejected spelling and its
replacement in the changelog and in the load 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 the first tag there is no
compatibility promise.
- **A change to what exists is a major; a minor only adds.** Data files, the CLI
and the Go API are the public API, and a consumer must be able to take a minor
without an edit — so an added column is a major, since it changes the CSV header
and the `INSERT` column list, as is a removed value, which changes what a fixture
holds, and a new option, which reserves a field name. One spelling per result
grows by tightening, so every fence invalidates some file. Each such release
names the rejected spelling and its replacement in the changelog and in the load
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.
- **A release is a Gitea release built from the changelog.** The tag alone serves
`go get`, but prebuilt binaries need release assets, and the body being the tag's
changelog section keeps one text; a tag with no heading fails the workflow
rather than publishing an empty release.
- **A `--data-path` override rebinds every reference to the category it
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
@@ -779,6 +826,21 @@ 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
```
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`,
merge, then tag `main`; the release workflow publishes the Gitea release with that
section as its body:
```sh
git tag -a v0.1.0 -m v0.1.0 && git push origin v0.1.0
```
## Layout
```
@@ -801,6 +863,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
cmd/fejkdata/ the fejkdata CLI
data/ shipped data (JSON), embedded at build: locale folders + a misc folder
release-tooling/ the Gitea release a tag publishes
testdata/ the pinned shipped shape (see Versioning)
```
## License
+1
View File
@@ -24,6 +24,7 @@ x-go: &go
HOME: /cache
GOCACHE: /cache/build
GOMODCACHE: /cache/mod
REPIN: ${REPIN:-}
services:
test:
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Create the Gitea release for a tag, its body the tag's CHANGELOG.md section.
Env: GITEA_API_URL, GITEA_REPOSITORY (owner/repo), GITEA_TOKEN, TAG (vX.Y.Z).
"""
import json
import os
import re
import sys
import urllib.request
def section(changelog: str, version: str) -> str | None:
m = re.search(rf"^## \[{re.escape(version)}\].*?$\n(.*?)(?=^## \[|\Z)", changelog, re.M | re.S)
return m.group(1).strip() if m else None
def main() -> int:
tag = os.environ["TAG"]
if not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
print(f"{tag}: not a release tag; a release is vX.Y.Z", file=sys.stderr)
return 1
with open("CHANGELOG.md", encoding="utf-8") as f:
body = section(f.read(), tag[1:])
if body is None:
print(f"CHANGELOG.md has no `## [{tag[1:]}]` heading; add the section, then tag", file=sys.stderr)
return 1
req = urllib.request.Request(
f"{os.environ['GITEA_API_URL']}/repos/{os.environ['GITEA_REPOSITORY']}/releases",
data=json.dumps({"body": body, "name": tag, "tag_name": tag}).encode(),
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
print(json.load(resp)["html_url"])
return 0
if __name__ == "__main__":
sys.exit(main())
+5 -14
View File
@@ -7,24 +7,15 @@
- Major data update. Shipped categories render as records with their building
blocks as columns (`sv_SE.person` → `femalefirst`, `malefirst`; `misc.uuid` →
`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
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
- Versioning — semver tags, starting at `v0.1.0`; `v1.0.0` once the grammar
settles. From v2 the module path carries `/vN` (`go.mod`, imports, the README's
install lines), so fences ship batched into as few majors as possible. Reword
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.
- CLI without Go — investigate prebuilt binaries: GoReleaser attaching them to
the Gitea release the tag workflow publishes, a container image, Homebrew and
Scoop. A `--version` flag lands with them.
- 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
own browser.