diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..571298e --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d6336ba..ff78cff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 28cf18d..031f02b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ # Rules - Go runs only through `docker compose run --rm `; 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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b1d7908 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 3a11981..2e8c390 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compose.yaml b/compose.yaml index bb47873..ca3af11 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,6 +24,7 @@ x-go: &go HOME: /cache GOCACHE: /cache/build GOMODCACHE: /cache/mod + REPIN: ${REPIN:-} services: test: diff --git a/release-tooling/publish_release.py b/release-tooling/publish_release.py new file mode 100644 index 0000000..9303030 --- /dev/null +++ b/release-tooling/publish_release.py @@ -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()) diff --git a/todo.md b/todo.md index 2d65711..253bd54 100644 --- a/todo.md +++ b/todo.md @@ -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.