Compare commits

...

2 Commits

Author SHA1 Message Date
lilleman 1cf8ed502a Cut the release from the changelog heading on a green main instead of a hand-pushed tag
Tests / vet + fmt + tests (pull_request) Successful in 1m7s
Tests / Gitea release from CHANGELOG.md (pull_request) Has been skipped
2026-09-16 20:09:38 +02:00
lilleman ea71b3e479 Pin the categories each shipped category reads 2026-09-16 20:09:38 +02:00
5 changed files with 118 additions and 60 deletions
-23
View File
@@ -1,23 +0,0 @@
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 }}
+18
View File
@@ -49,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 }}
+12 -13
View File
@@ -559,7 +559,8 @@ with no breaking change. From `v2` the module path carries `/vN`, so fences ship
batched into as few majors as possible. batched into as few majors as possible.
[`testdata/shipped_shape.txt`](testdata/shipped_shape.txt) pins every path, each [`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 category's format and the categories it 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, changes it or `data/` adds its `CHANGELOG.md` entry, which CI checks. A removed,
renamed or retyped line is a major. renamed or retyped line is a major.
@@ -667,10 +668,11 @@ renamed or retyped line is a major.
- **Raising the lowest supported Go is a major.** A consumer building on it breaks, - **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 which is the one test every rule above applies; Go's convention of a minor is not
followed. followed.
- **A release is a Gitea release built from the changelog.** The tag alone serves - **The changelog heading is the one spelling of a release; CI cuts the tag.** A
`go get`, but prebuilt binaries need release assets, and the body being the tag's tag pushed by hand is served by `go get` at once, so a tag whose commit lacks its
changelog section keeps one text; a tag with no heading fails the workflow heading is burnt, not fixed. The heading on a gate-passed `main` commit is the
rather than publishing an empty release. 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
@@ -837,13 +839,10 @@ in its own commit:
REPIN=1 docker compose run --rm --user "$(id -u):$(id -g)" test 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`, 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 and merge: once `main` passes the gate, CI tags that commit `vX.Y.Z` and publishes
section as its body: the Gitea release with the section as its body. A top heading of `[Unreleased]`
publishes nothing.
```sh
git tag -a v0.1.0 -m v0.1.0 && git push origin v0.1.0
```
## Layout ## Layout
@@ -867,7 +866,7 @@ 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 Gitea release a tag publishes release-tooling/ the release CI publishes from the changelog heading
testdata/ the pinned shipped shape (see Versioning) testdata/ the pinned shipped shape (see Versioning)
``` ```
+33 -19
View File
@@ -1,38 +1,52 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Create the Gitea release for a tag, its body the tag's CHANGELOG.md section. """Publish the Gitea release that CHANGELOG.md's top heading names, tagging SHA.
Env: GITEA_API_URL, GITEA_REPOSITORY (owner/repo), GITEA_TOKEN, TAG (vX.Y.Z). 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 json
import os import os
import re import re
import sys import sys
import urllib.error
import urllib.request import urllib.request
HEADING = re.compile(r"^## \[([^\]]+)\].*?$\n(.*?)(?=^## \[|\Z)", re.M | re.S)
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: def request(path: str, data: dict | None = None):
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( req = urllib.request.Request(
f"{os.environ['GITEA_API_URL']}/repos/{os.environ['GITEA_REPOSITORY']}/releases", f"{os.environ['GITEA_API_URL']}/repos/{os.environ['GITEA_REPOSITORY']}/{path}",
data=json.dumps({"body": body, "name": tag, "tag_name": tag}).encode(), data=json.dumps(data).encode() if data else None,
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}", "Content-Type": "application/json"}, headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}", "Content-Type": "application/json"},
) )
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req) as resp:
print(json.load(resp)["html_url"]) 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
release = request("releases", {"body": body, "name": tag, "tag_name": tag, "target_commitish": os.environ["SHA"]})
print(release["html_url"])
return 0 return 0
+55 -5
View File
@@ -2,6 +2,7 @@ package fejkdata
import ( import (
"os" "os"
"sort"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -10,8 +11,8 @@ import (
const shapePin = "testdata/shipped_shape.txt" const shapePin = "testdata/shipped_shape.txt"
// TestShippedShapeIsPinned pins what a version promises about the shipped data (see // TestShippedShapeIsPinned pins what a version promises about the shipped data (see
// the README's Versioning): every path, each category's format, and each record // the README's Versioning): every path, each category's format and the categories it
// column's datatype and nullability. REPIN=1 rewrites the pin. // reads, and each record column's datatype and nullability. REPIN=1 rewrites the pin.
func TestShippedShapeIsPinned(t *testing.T) { func TestShippedShapeIsPinned(t *testing.T) {
f, err := New(WithSeed(1)) f, err := New(WithSeed(1))
if err != nil { if err != nil {
@@ -33,8 +34,9 @@ func TestShippedShapeIsPinned(t *testing.T) {
} }
} }
// shippedShape lists every path Fake accepts, one per line. A category-level template // shippedShape lists every path Fake accepts, one per line. A category carries the
// carries its format, and each of its columns its datatype and whether it may be null. // categories it references, a category-level template its format, and each of its
// columns its datatype and whether it may be null.
func shippedShape(f *Generator) string { func shippedShape(f *Generator) string {
facts := map[string]string{} facts := map[string]string{}
var walk func(prefix string, n node) var walk func(prefix string, n node)
@@ -44,8 +46,10 @@ func shippedShape(f *Generator) string {
for _, name := range sortedNames(n.children) { for _, name := range sortedNames(n.children) {
walk(join(prefix, name), n.children[name]) walk(join(prefix, name), n.children[name])
} }
case *choice:
facts[prefix] = reads(n)
case *template: case *template:
facts[prefix] = "\tformat " + strconv.Quote(n.format) facts[prefix] = "\tformat " + strconv.Quote(n.format) + reads(n)
if _, columns, err := recordOf(n); err == nil { if _, columns, err := recordOf(n); err == nil {
for _, c := range columns { for _, c := range columns {
fact := "\t" + c.DataType.String() fact := "\t" + c.DataType.String()
@@ -66,3 +70,49 @@ func shippedShape(f *Generator) string {
} }
return b.String() 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}","v":["z","w"]}`,
"d/pos": `["{/b}","q"]`,
}))
want := "a\tformat \"{x}\"\treads b c\na.x\tstring\nb\tformat \"y\"\nc\tformat \"{v}\"\nc.v\tstring\nd.pos\treads b\n"
if got := shippedShape(f); got != want {
t.Fatalf("shippedShape =\n%s\nwant\n%s", got, want)
}
}