Skip to content

chore(release): publish only to GitHub Releases, and harden the signer - #222

Merged
kuny0707 merged 2 commits into
tronprotocol:developfrom
barbatos2011:chore/release-channels-github-only
Aug 22, 2026
Merged

chore(release): publish only to GitHub Releases, and harden the signer#222
kuny0707 merged 2 commits into
tronprotocol:developfrom
barbatos2011:chore/release-channels-github-only

Conversation

@barbatos2011

Copy link
Copy Markdown

The release pipeline advertised four channels: GitHub Releases, a Homebrew tap, .deb/.rpm/.apk packages, and a Docker Hub image. Two of them cost a long-lived credential, and neither of those two could be covered by the signature we tell users to verify. This drops them and keeps GitHub Releases as the only channel.

Why the two channels go

tronprotocol/homebrew-tap does not exist, and the brews: block carried no token: — so it would have run on the default GITHUB_TOKEN, which cannot write to another repository. Making it work needed a cross-repo PAT. tronprotocol/trond on Docker Hub does not exist either (404) and needed a DOCKERHUB_TOKEN.

Both are secrets that sit in this repository's Actions settings until someone rotates them, and whoever holds one can publish a trond that users trust, under the official name. The cosign signing here is keyless precisely to avoid that — its credential is an OIDC token minted for a single workflow run.

They were also outside what the signature can reach. signs: covers checksums.txt, and checksums.txt lists the file artifacts goreleaser built. A tap formula is pushed to another repository; an image is pushed to a registry by digest. Neither appears in it.

README nonetheless said the signature "transitively covers every artifact (tarballs, .deb / .rpm / .apk packages, docker images, the homebrew formula)". A local snapshot build shows checksums.txt holding 14 entries — archives and packages, no formula:

$ goreleaser release --snapshot --clean --skip=publish,sign
$ wc -l < dist/checksums.txt
14
$ ls dist/homebrew
(no such directory)

That sentence is corrected, and the reason the channels are gone is recorded in .goreleaser.yaml and in the constitution so it is not re-added by accident.

Two more findings, same blast radius

Both surfaced from actually running the pipeline rather than reading it.

The before: hook ran go mod tidy. tidy writes to go.mod/go.sum, so a release need not be built from the reviewed, committed lockfile. This is not theoretical — a local snapshot run left go.sum modified in the worktree (it pruned a stale golang.org/x/net v0.54.0). Now go mod download + go mod verify, which only read.

release.yml referenced its actions by moving tag and pulled goreleaser at version: latest. That job holds contents: write and id-token: write — it can publish and sign, so everything it runs is in the trusted computing base of every signature this project issues, and a tag repointed upstream would be an unreviewed change to the signer. Actions are now pinned to commit SHAs at their current versions (no major bumps), goreleaser to ~> v2.17.

Incidental fixes

  • archives.buildsarchives.ids, and the brews deprecation is gone with the block — goreleaser check passes for the first time (it previously exited non-zero, and is not run in CI).
  • The snapshot smoke asserted dist/homebrew/*.rb, which would now never appear. Removed, along with the dead --skip=docker.
  • CHANGELOG.md had ## [0.1.0-alpha] — 2026-XX-XX twice in a row.
  • install.sh printed "could not query the latest release" for what is really prerelease: auto: /releases/latest omits pre-releases, so an alpha-only tag 404s there and the message blamed the network. It now names the real cause. Installing a pre-release stays opt-in via TROND_VERSION.
  • Dockerfile.release deleted — its only consumer was the removed dockers_v2 block.

Verification

check result
goreleaser check clean (was failing)
goreleaser release --snapshot all 14 artifacts, exit 0
go.mod / go.sum after that build untouched
snapshot workflow's assertion, re-run under bash passes
make test 25 packages ok, 0 failures
Go files touched none

Still open — not in this PR

  • The tag trigger stays disabled pending TX-167; that reference appears nowhere else in the repo, so someone who knows what it was needs to decide whether it still applies.
  • The first version number is unpicked. If it carries a pre-release suffix, prerelease: auto files it as a pre-release and the README one-liner will not resolve it.
  • The other seven workflows still reference actions by tag. Lower privilege than release.yml, but the same class of issue.
  • Neither the cosign signing step nor a real publish has ever executed — the snapshot smoke skips both. Worth one workflow_dispatch run on a throwaway tag before the first real release.

Reintroducing a tap or an image later is a real option, not a prohibition. It needs a named credential-rotation owner and a verification story of its own — a fine-grained PAT scoped to contents:write on the tap alone, and cosign sign on the image digest (or GHCR plus build provenance, which needs no standing secret at all).

The release pipeline advertised four channels — GitHub Releases, a Homebrew
tap, .deb/.rpm/.apk packages, and a Docker Hub image. Two of them cost a
long-lived credential and neither could be covered by the signature this
project tells users to verify. Drop those two.

`tronprotocol/homebrew-tap` does not exist, and the `brews:` block carried no
`token:`, so it would have run on the default GITHUB_TOKEN — which cannot
write to another repository. Making it work needed a cross-repo PAT.
`tronprotocol/trond` on Docker Hub does not exist either and needed a
DOCKERHUB_TOKEN. Both are secrets that sit in this repository until someone
rotates them, and whoever holds one can publish a trond that users trust,
under the official name. The cosign signing here is keyless precisely to
avoid that: its credential is an OIDC token minted for one workflow run.

They were also outside what the signature reaches. `signs:` covers
checksums.txt; checksums.txt lists the file artifacts goreleaser built. A tap
formula goes to another repository and an image goes to a registry by digest,
so neither is in it. README nonetheless claimed the signature "transitively
covers every artifact (tarballs, .deb / .rpm / .apk packages, docker images,
the homebrew formula)". A local snapshot build shows checksums.txt holding 14
entries: archives and packages, no formula. That sentence is corrected, and
the reason the channels are gone is recorded in .goreleaser.yaml and in the
constitution so this is not re-added by accident.

Two more findings from running the pipeline locally, both in the same blast
radius:

- The `before:` hook ran `go mod tidy`. tidy writes to go.mod/go.sum, so the
  release need not be built from the reviewed, committed lockfile — a local
  snapshot run left go.sum modified in the worktree. Now `go mod download`
  + `go mod verify`, which only reads.

- release.yml referenced its actions by moving tag and pulled goreleaser at
  `version: latest`. The job holds contents:write and id-token:write, so
  anything it runs can publish and can sign. Actions are now pinned to
  commit SHAs at their current versions (no major bumps) and goreleaser to
  `~> v2.17`.

Also fixed along the way: `archives.builds` -> `archives.ids` and the `brews`
deprecation both cleared, so `goreleaser check` passes for the first time;
the snapshot smoke no longer asserts a homebrew formula it would never find;
CHANGELOG had `## [0.1.0-alpha]` twice; and install.sh's "could not query the
latest release" blamed the network for what is really `prerelease: auto` —
/releases/latest omits pre-releases, so an alpha-only tag 404s there.

Verified: `goreleaser check` clean; `goreleaser release --snapshot` produces
all 14 artifacts with go.mod/go.sum untouched; the snapshot workflow's
assertion re-run under bash passes; make test green; no Go files touched.

Not addressed here, and still open before a release can go out: the tag
trigger stays disabled pending TX-167, the first version number is unpicked,
and the other seven workflows still use unpinned action tags.
The trigger comment read "Tag-triggered releases are temporarily paused
(TX-167)", which is wrong twice: it is not temporary, and TX-167 appears
nowhere else in the repository, so nobody reading it can find out what it
referred to. Manual dispatch is the intended policy — a pushed tag is easy
to create by accident, and publishing a signed release should be a
deliberate act.
@kuny0707
kuny0707 merged commit 235ff82 into tronprotocol:develop Aug 22, 2026
11 checks passed
barbatos2011 added a commit to barbatos2011/tron-deployment that referenced this pull request Aug 22, 2026
… section

The changelog described a `0.1.0-alpha` release that never happened. There are
no tags in this repository and no published releases, so that heading
documented a version nobody could install, and its two link references at the
bottom (`compare/v0.1.0-alpha...HEAD`, `releases/tag/v0.1.0-alpha`) were dead.
It also appeared twice in a row until recently.

Everything is therefore one release. `[Unreleased]` and `[0.1.0-alpha]` are
merged into a single `## [0.1.0]`, with the Added list split into
**Foundation** (what the alpha section described: the CLI, the intent schema,
the render and SSH work) and **Since** (everything after). Changed / Fixed /
Security / Repository changes carry over unchanged. Content is preserved
line-for-line apart from the two intro paragraphs, which are rewritten into
one; a diff of every non-heading line confirms 356 of 358 lines are identical
and the 2 differences are those intros.

`0.1.0` rather than `0.1.0-alpha`: goreleaser runs with `prerelease: auto`, so
a tag carrying a pre-release suffix is filed as a pre-release, and
`/releases/latest` — which `scripts/install.sh` queries and the README
one-liner depends on — does not return pre-releases. The suffix would have
cost a working install path to restate something `0.x` already says under
semver.

`0.1.0` rather than `1.0.0`: the public surface is not only the CLI flags. It
is `schemas/intent.schema.json`, the ~30 schemas under `schemas/output/`, and
the exit-code contract `AGENTS.md` instructs agents to branch on. The
constitution requires strict semver, so `1.0.0` freezes all of that behind a
major-bump promise before any of it has met a real user. `TODOS.md` still
carries four deferred decisions, one of them an unreconciled overlap between
`scripts/db_cp.sh` and `trond snapshot clone`.

The date is left as `2026-XX-XX` because releases are cut by hand and the date
is not known until someone dispatches the workflow. To stop that placeholder
from shipping, release.yml now checks, before it builds anything, that the top
released section names the tag being released and ends in a real date. Both
failure modes were exercised locally: the file's current state is rejected for
the placeholder, `v0.2.0` against a `[0.1.0]` heading is rejected for the
version mismatch, and a filled-in date is accepted.

Stacked on tronprotocol#222 — that PR's commits are included here and the diff shrinks to
CHANGELOG.md plus the guard step once it merges.
@barbatos2011
barbatos2011 deleted the chore/release-channels-github-only branch August 22, 2026 11:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants