From cc7d4fb9724b99cde23d522a9a893dcff06330cb Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 13 Oct 2026 17:35:21 +0200 Subject: [PATCH] =?UTF-8?q?Answer=20architecture=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=207=20=F0=9F=9F=A1;=20build=20with=20the=20gate's?= =?UTF-8?q?=20Go,=20guard=20single-version=20channels=20on=20older=20tags,?= =?UTF-8?q?=20record=20the=20Cloudsmith=20tie,=20and=20return=20the=20tag?= =?UTF-8?q?=20from=20publish()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/packages.yml | 42 ++++++++++++++++++++++++- .github/workflows/test.yml | 5 ++- AGENTS.md | 1 + Dockerfile | 2 +- README.md | 10 ++++-- docs/decisions.md | 13 ++++++++ release-tooling/goreleaser.yaml | 35 ++++++++++----------- release-tooling/publish_release.py | 25 +++++++++------ release-tooling/publish_release_test.py | 13 +++----- renovate.json | 32 +++++++++++++++++++ 10 files changed, 135 insertions(+), 43 deletions(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 54eed91..10385eb 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -6,6 +6,13 @@ on: tag: type: string required: true + secrets: + TAP_GITHUB_TOKEN: + required: true + AUR_KEY: + required: true + CLOUDSMITH_API_KEY: + required: true workflow_dispatch: inputs: tag: @@ -16,6 +23,10 @@ on: permissions: contents: read +concurrency: + group: packages + cancel-in-progress: false + jobs: packages: name: Binaries, packages and image for ${{ inputs.tag }} @@ -36,6 +47,26 @@ jobs: with: path: tooling persist-credentials: false + - name: The Go the gate tests + id: go + run: sed -n 's/^ARG GO_VERSION=/version=/p' tooling/Dockerfile >> "$GITHUB_OUTPUT" + - uses: actions/setup-go@v7 + with: + go-version: ${{ steps.go.outputs.version }} + cache: false + # The tap, the bucket, the AUR and the image's latest tag each hold one version, so + # only the latest release updates them. + - name: Channels holding one version + id: latest + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + if [ "$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)" = "$TAG" ]; then + echo "skip=" >> "$GITHUB_OUTPUT" + else + echo "skip=--skip=homebrew,scoop,aur,docker" >> "$GITHUB_OUTPUT" + fi - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: @@ -46,7 +77,7 @@ jobs: with: version: v2.18.3 workdir: src - args: release --clean --config ../tooling/release-tooling/goreleaser.yaml + args: release --clean --config ../tooling/release-tooling/goreleaser.yaml ${{ steps.latest.outputs.skip }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} @@ -59,3 +90,12 @@ jobs: for f in dist/*.deb; do cloudsmith push deb --republish larvit/fejkdata/any-distro/any-version "$f"; done for f in dist/*.rpm; do cloudsmith push rpm --republish larvit/fejkdata/any-distro/any-version "$f"; done for f in dist/*.apk; do cloudsmith push alpine --republish larvit/fejkdata/alpine/any-version "$f"; done + - name: The built binary and the published image print the tag + env: + TAG: ${{ inputs.tag }} + IMAGE: ${{ steps.latest.outputs.skip == '' }} + run: | + test "$(dist/fejkdata_linux_amd64_v1/fejkdata --version)" = "fejkdata $TAG" + if [ "$IMAGE" = true ]; then + test "$(docker run --rm "ghcr.io/larvit/fejkdata:${TAG#v}" --version)" = "fejkdata $TAG" + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a7fefa..703b148 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,4 +81,7 @@ jobs: uses: ./.github/workflows/packages.yml with: tag: ${{ needs.release.outputs.tag }} - secrets: inherit + secrets: + TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + AUR_KEY: ${{ secrets.AUR_KEY }} + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} diff --git a/AGENTS.md b/AGENTS.md index 2b029b8..b264b6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,7 @@ In [docs/decisions.md](docs/decisions.md): - Goal 7.2 holds while a first template can be written with no escape; a literal brace still takes `{{` or `}}` - The locales are Ethnologue 2026's ten most-spoken languages, each in the country with the most speakers of it, the Nordic countries and Germany - A country with no open address register covering it whole ships its places and postal codes without streets +- The deb, rpm and apk repositories live on Cloudsmith, and the image on GHCR # Scoring run diff --git a/Dockerfile b/Dockerfile index ed4d0b5..41ad530 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN python3 -m unittest discover -s /release-tooling -p '*_test.py' && touch /te FROM goreleaser/goreleaser:v2.18.3 AS packaging WORKDIR /check COPY release-tooling/goreleaser.yaml ./ -RUN git init -q && goreleaser check --config goreleaser.yaml && touch /tested +RUN git init -q && git remote add origin https://github.com/larvit/fejkdata.git && goreleaser check --config goreleaser.yaml && touch /tested FROM portable # BuildKit skips a stage nothing copies from. diff --git a/README.md b/README.md index 0ae68bc..1986bd8 100644 --- a/README.md +++ b/README.md @@ -985,7 +985,8 @@ changed. A consumer's data, code and scripts keep working across a minor or a pa minor only adds, and a major is the only release that changes what exists. Seeded output is the exception: any release, a patch too, may change what a seed draws. Where you compare seeded output, install one exact version -(`go install github.com/larvit/fejkdata/cmd/fejkdata@vX.Y.Z`; `go.mod` already pins one), +(`go install github.com/larvit/fejkdata/cmd/fejkdata@vX.Y.Z`, the image +`ghcr.io/larvit/fejkdata:X.Y.Z` or a release's binary; `go.mod` already pins one), and regenerate seeded fixtures and expected values when you raise that version. | Surface | Major | Minor | @@ -1255,8 +1256,11 @@ publishes nothing. The new release then runs the Packages workflow, which attach binaries and packages, pushes the image, uploads to Cloudsmith and updates the Homebrew tap, the Scoop bucket and the AUR. It reads the repository secrets `TAP_GITHUB_TOKEN`, a token that can push to `larvit/homebrew-tap` and `larvit/scoop-bucket`, `AUR_KEY`, -the AUR account's SSH key, and `CLOUDSMITH_API_KEY`. To package a release again, run -the workflow by hand with its tag; it replaces what it uploaded before. +the AUR account's SSH key, and `CLOUDSMITH_API_KEY`; the image's GHCR package must be +public. To package a release again, run the workflow by hand with its tag; it replaces +what it uploaded before. For a tag older than the latest release it updates only that +release's assets and Cloudsmith, since the tap, the bucket, the AUR and the image's +`latest` each hold one version. ## License diff --git a/docs/decisions.md b/docs/decisions.md index a1fc3d8..810688a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -962,3 +962,16 @@ inherit both terms. So that country's `address` prints no street, and the README section says why beside each such country. This works against goal 2.1 until `todo.md` item 218 gives those countries streets. Valid until the maintainer accepts ODbL data, or the country opens a register. + +## The deb, rpm and apk repositories live on Cloudsmith, and the image on GHCR + +2026-10-13, decided by the maintainer. Serves goal 1.1; technical principle: prefer the +vendor-neutral option, sidestepped deliberately (`~/.claude/principles/technical-principles.md`). +A repository apt, dnf or apk can upgrade from needs signed indexes for three formats. +Self-hosting them on GitHub Pages would put two signing keys and the tooling that rebuilds +every index into this repository. Cloudsmith signs and indexes all three, for free to open +source, so the tie buys that tooling, not a capability only Cloudsmith has. Leaving it costs +each Linux user one new setup line. Every release's assets carry the same packages, so +nothing is lost in a move. GHCR sits beside the code and takes CI's own token; the image is +OCI, so leaving it changes only the image path. Valid while Cloudsmith's open-source plan +stays free. diff --git a/release-tooling/goreleaser.yaml b/release-tooling/goreleaser.yaml index 5a80e4d..e9f8ff8 100644 --- a/release-tooling/goreleaser.yaml +++ b/release-tooling/goreleaser.yaml @@ -36,19 +36,16 @@ changelog: disable: true release: - github: - owner: larvit - name: fejkdata mode: keep-existing replace_existing_artifacts: true nfpms: - package_name: fejkdata vendor: Larvit - homepage: https://github.com/larvit/fejkdata - maintainer: Lilleman auf Larv - description: Render locale-aware fake data from JSON templates. - license: MIT + homepage: &homepage https://github.com/larvit/fejkdata + maintainer: &maintainer Lilleman auf Larv + description: &description Render locale-aware fake data from JSON templates + license: &license MIT formats: [deb, rpm, apk] contents: - src: LICENSE @@ -64,16 +61,16 @@ dockers_v2: - latest dockerfile: ../tooling/release-tooling/image.Dockerfile labels: - org.opencontainers.image.source: https://github.com/larvit/fejkdata + org.opencontainers.image.source: *homepage org.opencontainers.image.version: "{{ .Version }}" - org.opencontainers.image.licenses: MIT + org.opencontainers.image.licenses: *license homebrew_casks: - name: fejkdata binaries: [fejkdata] - homepage: https://github.com/larvit/fejkdata - description: Render locale-aware fake data from JSON templates - license: MIT + homepage: *homepage + description: *description + license: *license repository: owner: larvit name: homebrew-tap @@ -87,9 +84,9 @@ homebrew_casks: scoops: - name: fejkdata - homepage: https://github.com/larvit/fejkdata - description: Render locale-aware fake data from JSON templates. - license: MIT + homepage: *homepage + description: *description + license: *license repository: owner: larvit name: scoop-bucket @@ -97,11 +94,11 @@ scoops: aurs: - name: fejkdata-bin - homepage: https://github.com/larvit/fejkdata - description: Render locale-aware fake data from JSON templates - license: MIT + homepage: *homepage + description: *description + license: *license maintainers: - - Lilleman auf Larv + - *maintainer private_key: "{{ .Env.AUR_KEY }}" git_url: ssh://aur@aur.archlinux.org/fejkdata-bin.git commit_author: diff --git a/release-tooling/publish_release.py b/release-tooling/publish_release.py index 63af191..133e4bb 100644 --- a/release-tooling/publish_release.py +++ b/release-tooling/publish_release.py @@ -26,23 +26,24 @@ def request(path: str, data: dict | None = None): return json.load(resp) -def main() -> int: +def publish() -> tuple[int, str | None]: + """Publish the release, returning the exit code and the tag it published, if any.""" 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 + return 1, None version, body = top.group(1), top.group(2).strip() if version == "Unreleased": print("top heading is Unreleased; nothing to publish") - return 0 + return 0, None 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 + return 1, None tag = f"v{version}" try: print(f"{tag} already published: {request(f'releases/tags/{tag}')['html_url']}") - return 0 + return 0, None except urllib.error.HTTPError as e: if e.code != 404: raise @@ -53,15 +54,21 @@ def main() -> int: at = request(f"git/tags/{at['sha']}")["object"] if at["sha"] != sha: print(f"{tag} exists at {at['sha']}, not {sha}; the version is burnt, bump the heading", file=sys.stderr) - return 1 + return 1, None 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"]) - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: - f.write(f"tag={tag}\n") - return 0 + return 0, tag + + +def main() -> int: + code, tag = publish() + if tag: + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"tag={tag}\n") + return code if __name__ == "__main__": diff --git a/release-tooling/publish_release_test.py b/release-tooling/publish_release_test.py index 07eb200..285f33d 100644 --- a/release-tooling/publish_release_test.py +++ b/release-tooling/publish_release_test.py @@ -25,8 +25,7 @@ class TagGuard(unittest.TestCase): self.addCleanup(os.chdir, cwd) with open("CHANGELOG.md", "w", encoding="utf-8") as f: f.write("# Changelog\n\n## [1.2.3]\n\n- A change.\n") - open("output", "w").close() - env = mock.patch.dict(os.environ, {"SHA": SHA, "GITHUB_OUTPUT": os.path.abspath("output")}) + env = mock.patch.dict(os.environ, {"SHA": SHA}) env.start() self.addCleanup(env.stop) @@ -44,13 +43,9 @@ class TagGuard(unittest.TestCase): with mock.patch.object(publish_release, "request", request), \ redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): - code = publish_release.main() + code, self.tag = publish_release.publish() return code, created - def output(self): - with open("output", encoding="utf-8") as f: - return f.read() - def test_a_tag_at_another_commit_publishes_nothing(self): code, created = self.publish({"git/ref/tags/v1.2.3": {"object": {"sha": OTHER, "type": "commit"}}}) self.assertEqual((code, created), (1, [])) @@ -73,11 +68,11 @@ class TagGuard(unittest.TestCase): code, created = self.publish({}) self.assertEqual(code, 0) self.assertEqual(created, [{"body": "- A change.", "name": "v1.2.3", "tag_name": "v1.2.3", "target_commitish": SHA}]) - self.assertEqual(self.output(), "tag=v1.2.3\n") + self.assertEqual(self.tag, "v1.2.3") def test_a_published_release_publishes_nothing(self): code, created = self.publish({"releases/tags/v1.2.3": {"html_url": "https://forge.example/release"}}) - self.assertEqual((code, created, self.output()), (0, [], "")) + self.assertEqual((code, created, self.tag), (0, [], None)) if __name__ == "__main__": diff --git a/renovate.json b/renovate.json index c613c8c..00eee14 100644 --- a/renovate.json +++ b/renovate.json @@ -11,9 +11,41 @@ "matchStrings": [ "image: (?golang):\\$\\{GO_VERSION:-(?[^}]+)\\}" ] + }, + { + "customType": "regex", + "datasourceTemplate": "docker", + "depNameTemplate": "goreleaser/goreleaser", + "description": "the GoReleaser the Packages workflow runs, which must match the image the gate checks its config with", + "managerFilePatterns": [ + "/^\\.github/workflows/packages\\.yml$/" + ], + "matchStrings": [ + "goreleaser/goreleaser-action@\\S+\\s+with:\\s+version: (?\\S+)" + ] + }, + { + "customType": "regex", + "datasourceTemplate": "pypi", + "depNameTemplate": "cloudsmith-cli", + "description": "the Cloudsmith CLI the Packages workflow installs", + "managerFilePatterns": [ + "/^\\.github/workflows/packages\\.yml$/" + ], + "matchStrings": [ + "cloudsmith-cli==(?\\S+)" + ] } ], "extends": [ "config:recommended" + ], + "packageRules": [ + { + "groupName": "GoReleaser", + "matchPackageNames": [ + "goreleaser/goreleaser" + ] + } ] }