Releasing
How remeris releases itself with its own knope.toml: the GitHub Actions workflows, one-time setup, cutting a release and verifying downloads with cosign.
remeris releases itself: its own knope.toml drives three GitHub Actions workflows, in the same way as Knope-based projects such as mizuya. This page explains the flow, what to
set up once, how to cut the first release (v0.0.1), and how users verify a download. For the commands, see usage.md; for the configuration, see
configuration.md; for the repository settings, see maintaining.md.
How a release happens
| Step | Workflow | What it does |
|---|---|---|
| 1 | prepare-release.yaml |
On every push to main (and by hand), runs remeris prepare-release-bot. It computes the next version from the conventional commits since the last tag, writes CHANGELOG.md and VERSION on a release branch, and opens or updates the release pull request. |
| 2 | you | Review the PR (the changelog is its body) and merge it. |
| 3 | release.yaml |
On the push of the squashed or rebased release commit to main, runs remeris release: it creates the GitHub release and the vX.Y.Z tag at that commit. |
| 4 | push-artifact.yaml |
The tag push builds the archives, signs and attests them, uploads them to the release, then signs the packslip bundle and uploads it too. |
Design points:
- Signed commit.
mainrequires signed commits, and a bot cannot sign locally..github/scripts/prepare-release-commit.shtherefore makes the commit through the GitHub API with ghcommit, which GitHub signs (Verified) under the identity of the GitHub App. The workflow downloads the ghcommit binary of a pinned version with the defaultgithub.token(read-only), and refuses it unless its SHA-256 is the one written next to the version in the workflow. That pin is what keeps a changed or swapped release asset out of a job that holds the App token. Both values change together: see Renovate. - App token, not
GITHUB_TOKEN. A tag or a push made with the default token does not trigger other workflows. The release and the PR use a token minted for the GitHub App, so the tag push startspush-artifact.yaml. - The release is created on a push to
main, not on the PR event.release.yamlruns on every push tomainbut only starts its job when the head commit message starts withchore: prepare release. The title is not trusted: the first step asks the API (commits/<sha>/pulls, with the read-only default token) and requires exactly one PR for the pushed commit that is merged, from thereleasebranch of this repository, intomain, and opened by the App (<app-slug>[bot]). If the API check fails, or the App slug or the lookup is missing, nothing is released: read the log and re-run the job (the PR association of a new commit can lag, and the step already retries for 30 seconds). The tag and release point at the pushed commit. Thereleaseenvironment is restricted tomainbecause the run is onmain. VERSION. The package inknope.tomlhas one versioned file,VERSION, a plain text file with the version. The binary does not read it: its version comes from the tag through-ldflags -X. It exists becauseReleaseruns after the PR is merged, in a new workflow run, and finds the release to publish by comparing the package version with the last tag. A package with no versioned file has no version except its tags, soReleasewould find nothing.push-artifact.yamlalso refuses a tag that differs fromVERSION. Do not editVERSIONorCHANGELOG.mdby hand.- Formatters.
prepare-release-botrunshk fix --no-stageafterPrepareRelease, so the generated changelog passes the same Markdown checks as the rest of the repository. - Loop guard. The merge of the release PR pushes to
main, and that push must not prepare the same release again before the tag exists.prepare-release.yamlskips a push whose head commit message starts withchore: prepare release(a squash or rebase merge keeps the PR title as the commit message) or is the merge commit of a PR from thereleasebranch. Merge the release PR with squash or rebase only, as the ruleset below says, so the first condition is the one that matters. - Bootstrap. The first workflows build remeris from the commit being released (
mise run build) and use that binary. See Bootstrap.
The workflows of knope.toml:
| Workflow | Use |
|---|---|
prepare-release-bot |
The CI path above (signed commit through ghcommit). |
prepare-release |
The same result on your machine, with an ordinary commit, git push and the CreatePullRequest step. Needs a GITHUB_TOKEN. |
release |
Creates the release and the tag. |
document-change |
Writes a change file in .changeset/ for a change that no conventional commit describes. |
Artifacts
mise run dist -- v0.0.1 (the task mise-tasks/dist) builds, for linux, darwin and windows on amd64 and arm64, with CGO_ENABLED=0 go build -trimpath -buildvcs=false and the
version information injected like mise run build does:
remeris_<tag>_<os>_<arch>.tar.xz, or.zipfor windows, holdingremeris(remeris.exe),LICENSEandREADME.md;SHA256SUMSfor all archives;SHA256SUMS.sigstore.json, a keyless cosign (3.x) signature bundle ofSHA256SUMS, made with the GitHub OIDC identity of the workflow;- a GitHub build provenance attestation for every archive (stored by GitHub, not a release asset);
packslip.sigstore.json, the signed packslip release bundle that mise’spackslip:backend verifies. It records the project, the version, and the platform, format, executable (remeris), size and digest of every archive, and links the attestations above. packslip reads the platform and the format from the archive names (linux|darwin|windows,amd64|arm64,.tar.xz|.zip), so those names must keep this shape.
The binaries are stripped (-ldflags '-s -w': no symbol table, no DWARF), which is what makes the archives about 2.5 times smaller; see Archive size.
mise run build stays unstripped, for debugging. Stack traces of panics, debug.ReadBuildInfo and remeris --version are unaffected.
The build is reproducible: the same commit, Go toolchain and GNU tools give byte-identical archives. The build date is the commit time (SOURCE_DATE_EPOCH, by default
git log -1 --format=%ct), the build user and branch are the fixed strings release, the build number stays unknown, and the archives have sorted entries, fixed owners, modes
and times; xz runs with -9e -T1 (one thread, so the output does not depend on the number of CPUs; it does depend on the xz version: the release job runs on ubuntu-26.04, which
ships a newer xz than the 5.4.5 used for the measurements below, so the published archives are not byte-identical to a local build with another xz). The task needs GNU tools
(tar, xz, zip, date, sha256sum), so run it on Linux, not on macOS. DIST_DIR changes the output directory and DIST_TARGETS (for example "linux/amd64 windows/arm64")
limits the platforms, which is handy to try the task locally.
push-artifact.yaml has two jobs, both on the tag push. The build job (contents: read, pinned to ubuntu-26.04: the archives are reproducible only for the same image and tool
versions, and ubuntu-latest moves without a change of the file) checks that the tagged commit is on main and that the tag matches VERSION, builds, and records the SHA-256 of
SHA256SUMS and the list of archives. The publish job (contents: write, id-token: write, attestations: write) checks out and runs nothing from the repository: it checks
the downloaded files against what the build job recorded, signs SHA256SUMS, attests the archives, and uploads exactly the listed archives, SHA256SUMS and its signature. Its
last step runs jdx/packslip (pinned to the commit of v1.11.1) on the verified local archives with bin: remeris and attest: link (the attestation of the step before is linked,
not made a second time), signs the packslip with the OIDC identity of the job, and uploads packslip.sigstore.json to the release. The packslip step runs after the other assets
are on the release and runs no repository code. Signing and uploading happen in the one job, so the packslip action is trusted with the job’s write token: it is pinned to a commit
for that reason, and Renovate keeps the pin current.
Archive size
Measured on Linux with Go 1.27.1 and xz 5.4.5, from the commit of this change (the sizes grow slowly with the code; another xz version changes them by a few percent at most). The
binary is the file in the archive; “before” is what v0.0.1 shipped (unstripped binary, tar.gz at gzip -9, zip for windows), “after” is what mise run dist makes now
(stripped binary, tar.xz at xz -9e for linux and darwin, zip for windows).
| Target | Binary before | Binary after | Archive before | Archive after | Archive saved |
|---|---|---|---|---|---|
| linux/amd64 | 33.5 MB | 23.2 MB | 16.0 MB (.tar.gz) |
6.1 MB (.tar.xz) |
62% |
| linux/arm64 | 31.0 MB | 21.4 MB | 14.6 MB (.tar.gz) |
5.2 MB (.tar.xz) |
65% |
| darwin/amd64 | 34.3 MB | 23.9 MB | 16.5 MB (.tar.gz) |
6.2 MB (.tar.xz) |
62% |
| darwin/arm64 | 32.1 MB | 22.1 MB | 15.4 MB (.tar.gz) |
5.5 MB (.tar.xz) |
65% |
| windows/amd64 | 34.1 MB | 24.0 MB | 16.4 MB (.zip) |
8.4 MB (.zip) |
49% |
| windows/arm64 | 31.3 MB | 21.8 MB | 14.8 MB (.zip) |
7.4 MB (.zip) |
50% |
Where the savings come from, for linux/amd64: stripping alone takes the gzip archive from 16.0 MB to 8.1 MB; xz in place of gzip takes it from 8.1 MB to 6.1 MB. -ldflags=-s and
-ldflags='-s -w' give the same binary (-s implies no DWARF); -w alone keeps the symbol table and gives 25.9 MB.
Windows stays .zip on purpose. A .tar.xz for windows would be about 25% smaller (6.3 MB against 8.4 MB for amd64), but Explorer and PowerShell’s Expand-Archive open only zip
files, and nothing here tests mise’s extraction of .tar.xz on Windows. If that is checked, switching is a one-line change in mise-tasks/dist (and the zip patterns of the
workflow).
What is in the binary
The stripped linux/amd64 binary is 23.2 MB on disk: .text 10.1 MB, .gopclntab 8.6 MB, .go.type 2.9 MB, .rodata 0.7 MB, .noptrdata 0.8 MB (size -A). The unstripped
binary adds 7.5 MB of DWARF, which -s -w removes. The .gopclntab (function tables and line numbers for stack traces and the garbage collector) and the type data are needed by
the runtime, and grow with the amount of code. The sections are measured with size -A; the contributors with go tool nm -size -sort size summed per package family (bss is left
out, it takes no space in the file). go-size-analyzer could not be run in the environment where this was measured, so there are no treemap numbers.
| Rank | Contributor | Symbol bytes | Share of the symbols |
|---|---|---|---|
| 1 | Go runtime, reflect, sync and the other core std packages | 2.8 MB | 24% |
| 2 | net, net/http, crypto/tls and crypto of the standard library |
1.9 MB | 17% |
| 3 | go-git with its SSH, OpenPGP and x/crypto dependencies | 1.5 MB | 13% |
| 4 | go-github (with go-querystring and oauth2) | 1.3 MB | 11% |
| 5 | the terminal UI: huh, bubbletea, lipgloss, the charm and ansi libraries | 0.9 MB | 7% |
| 6 | remeris itself | 0.9 MB | 7% |
| 7 | configuration: koanf, go-toml, YAML, mergo | 0.5 MB | 4.5% |
| 8 | urfave/cli | 0.2 MB | 1.6% |
(The symbols total 11.5 MB; the shares do not add up to 100% because 1.3 MB are go:func closures data and small packages not listed. The other 11.7 MB of the file are the
.gopclntab and the type data, which grow roughly in proportion to the code of each row.)
What would shrink it further, none of it done here (no dependency or code change in this PR), in the order of the likely gain:
- Dropping go-git: it is only the fallback when the
gitcommand is missing. About 10 to 14% of the binary, about 1 MB of the xz archive. - A thin GitHub client in place of go-github (the Gitea and Jira clients already are on
net/http): about 10%. - A lighter prompt library or hand-written prompts in place of the huh/bubbletea stack: about 5%.
Considered and rejected:
- UPX: xz already gives the same ratio, so the download does not shrink; it costs start-up time and memory, has macOS crash and antivirus false-positive reports, and conflicts with the provenance of the binary.
garble -tiny: an obfuscating toolchain; panics lose their stack traces, and the gain (about 15% raw) is small after xz.- Removing the
.gopclntab: the runtime needs it to walk stacks, and a linker patch that only drops function names breakslogandtesting.
One-time setup
The GitHub App
Create a GitHub App owned by the celest-io organization (Settings, Developer settings, GitHub Apps), for example named remeris-release-bot:
- Webhook: off. Homepage URL: the repository URL.
- Repository permissions: Contents read and write, Pull requests read and write, Metadata read (mandatory, always read-only).
- No organization or account permissions. No user authorization.
- Where it can be installed: only on this account.
Then:
- Install the App on
celest-io/remerisonly. - Generate a private key (a
.pemfile) on the App page and note the Client ID (starts withIv). - Create an Environment named
release(Settings, Environments). Its deployment branches aremainonly (both workflows that use it run onmain). Store the two secrets in this environment, not as repository secrets:
| Environment secret | Value |
|---|---|
RELEASE_BOT_APP_CLIENT_ID |
The Client ID of the App. |
RELEASE_BOT_APP_PRIVATE_KEY |
The full contents of the .pem file. |
- Delete the local
.pemfile. The key is only needed by the workflows, which mint a short-lived installation token withactions/create-github-app-token, limited to the permissions each job asks for.
The jobs that mint the token (prepare-release and release) declare environment: release. The environment is what limits the App key to runs on main and on the release PR. A
branch pushed by someone with write access cannot read it, whatever workflow it carries. Do not also create repository secrets with the same names.
Until the App and the secrets exist, prepare-release.yaml fails at its first step on every push to main. The failure is harmless: nothing has been created.
Branch and tag protection
The environment above and the tag ruleset below are what give a release signature its meaning. The cosign certificate only says “the push-artifact workflow of this repository ran
for a v* tag”. It is trustworthy only if just the release workflow can create such a tag and just main runs it with the key. Use rulesets (Settings, Rules):
main: require a pull request, require signed commits, require the CI checks, require approval of the most recent reviewable push, and dismiss stale approvals when new commits are pushed. Allow only squash and rebase merges (disable “Allow merge commits”). Nobody needs a bypass: the release commit is signed by GitHub through ghcommit, and the release PR is merged like any other.- Branches
releaseandrelease-staging: restrict creation and updates (and deletion) to the GitHub App, as the only bypass actor. Nobody else, administrators included, should push there by hand: the content ofreleaseis what gets released. The App force-pushes both branches on every run, so do not add “block force pushes” for the App. - Tags
v*: restrict creation, update and deletion to the App (bypass actor), and add “block deletions” and “block force pushes”. The tag push is what starts the artifact build, so it must come only from the release workflow. - Actions: Settings, Actions, General: workflow permissions “Read repository contents” by default. The workflows declare their own permissions.
- What the packslip signature proves: the workflow runs only on a
v*.*.*tag, which only the App can create, so the real signatures are made atrefs/tags/<tag>. But a modified copy ofpush-artifact.yamlon a branch is not stopped by any check inside the workflow: someone with write access can push such a branch, add a trigger to the copy and run it, and that copy can sign a bundle for this repository atrefs/heads/<branch>. mise with default options andpackslip verifywith no trust flags check the repository, not the ref, and would accept such a bundle. Three things reduce this. Strict consumers pin the workflow at a tag (miseworkflowoption,packslip verify --identity-prefix .../push-artifact.yaml@refs/tags/, see below), and tags are restricted to the App. A ruleset should restrict who can create branches (or who can push a branch that changes.github/workflows/), for example to the maintainers. Immutable releases would stop anyone from replacing the assets of a published release, which also bounds this risk, but the current workflow cannot work with them (see the Releases item). The packslip signature names this workflow file, and mise and packslip remember that file as the signer, so do not rename it or move the signing to another file without a plan for the users who pinned the signer. - Releases: leave “immutable releases” off for this repository.
push-artifact.yamlattaches the archives after the release exists, and an immutable release rejects that. (The alternative is a draft release that is published after the upload, which needs a change to the workflows.)
Cut v0.0.1
With VERSION at 0.0.0 and no tag, the computed first release is 0.0.1 today. Force it anyway: a later feat or breaking commit would change the computed version, and the
first release must be 0.0.1 whatever lands first.
- Merge the pipeline into
main, and finish the one-time setup above. - Open Actions, Prepare release, Run workflow on
main, and enter0.0.1as the version (nov). The workflow runsremeris prepare-release-bot --override-version 0.0.1. - A pull request named
chore: prepare release 0.0.1appears, fromreleasetomain, with the changelog as its body. The commit is Verified. ReviewCHANGELOG.mdandVERSION(0.0.1) in it. - Merge it only when the latest Prepare release run has finished and is green, and before anything else is merged into
main. Each push tomainruns the workflow again without a version and would recompute (and overwrite) the forced release PR. A run that is still going can replace the PR content right after you reviewed it. If a push slipped in, run the workflow again with0.0.1and review again. release.yamlruns on the push of the squashed release commit tomain: it creates thev0.0.1GitHub release with the changelog as notes, and the tag.push-artifact.yamlruns on the tag, and attaches the archives,SHA256SUMS,SHA256SUMS.sigstore.jsonandpackslip.sigstore.jsonto the release. (The real v0.0.1 predates the packslip step and is deliberately left without a bundle: mise installs start with the first release made by this workflow.)- Verify one download.
The merge of the release PR pushes a commit that prepare-release.yaml skips (see the loop guard). From then on the version is computed from the commits since the last tag. The
same rule as in step 4 holds for every later release: merge the release PR only when the latest Prepare release run is finished and green.
If something fails halfway:
- Release job failed, no release: re-run the failed
release.yamlrun (including when its PR check failed: nothing was released).remeris releasepublishes the release for aVERSIONthat has no tag. - Release exists, no archives: re-run
push-artifact.yamlfrom the Actions tab;gh release uploadrefuses to overwrite, so delete the partial assets first. - Archives exist, no
packslip.sigstore.json(only the last step of thepublishjob failed; v0.0.1 has no bundle on purpose and stays so): the upload step of the archives has already run, andgh release uploadrefuses to overwrite, so a plain re-run of thepublishjob fails at that step. Delete the archives,SHA256SUMSandSHA256SUMS.sigstore.jsonfrom the release page (a partial set is also fine to delete), then re-run thepublishjob of the tag run. It downloads the samedistartifact (kept 7 days), signs and attests again (a second, identical attestation is harmless), uploads everything and the packslip. The packslip upload itself uses--clobber, so a re-run replaces a bundle. After the 7 days, re-run the whole workflow: the build is reproducible, so the archives have the same hashes. - Wrong version in the PR: run Prepare release again with the right version, or close the PR and delete the
releasebranch (as the App, or as an administrator who lifts the ruleset for the moment).
Verify a download
Download the archive for your platform, SHA256SUMS and SHA256SUMS.sigstore.json from the release page, then:
# 1. Check the signature of SHA256SUMS (cosign 3.x): it was made by the push-artifact workflow of this repository, for a tag.
cosign verify-blob SHA256SUMS \
--bundle SHA256SUMS.sigstore.json \
--certificate-identity-regexp '^https://github\.com/celest-io/remeris/\.github/workflows/push-artifact\.yaml@refs/tags/v[0-9].*$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# 2. Check the archive against SHA256SUMS (--ignore-missing: you downloaded one archive only).
sha256sum --ignore-missing --check SHA256SUMS # macOS: shasum -a 256 --ignore-missing --check SHA256SUMS
# 3. Optional: check the build provenance attestation with the GitHub CLI.
gh attestation verify remeris_v0.0.2_linux_amd64.tar.xz --repo celest-io/remeris \
--signer-workflow celest-io/remeris/.github/workflows/push-artifact.yaml \
--source-ref refs/tags/v0.0.2
The examples use v0.0.2 and .tar.xz, the archive format of the first release after v0.0.1. The archives of v0.0.1 are .tar.gz (and .zip for windows), and their names
otherwise follow the same pattern; everything else on this page applies to both.
To pin one version in step 1, replace the regular expression by --certificate-identity https://github.com/celest-io/remeris/.github/workflows/push-artifact.yaml@refs/tags/v0.0.2.
Build provenance attestations need a public repository (or GitHub Enterprise Cloud for a private one). After extracting, remeris --version prints the tag, the revision and the
build date. Because the build is reproducible, building the tag yourself with mise run dist -- v0.0.2 on Linux with the same Go version and the same xz version (the compressed
bytes depend on it) gives the same archive hashes.
Install with mise and verify with packslip
mise installs remeris from the GitHub release when its packslip: backend is used. mise checks packslip.sigstore.json before it unpacks anything: the signature must be a GitHub
Actions run of celest-io/remeris, the signed project and version must be the requested ones, and the archive must have the signed digest and size.
mise use -g packslip:github.com/celest-io/remeris
The signer fingerprint of remeris is ps1_p2ti4zisstvj4fgdichvcpnhhq. It was printed by packslip pin packslip.sigstore.json (packslip 1.11.1) on the bundle of v0.0.2, the first
release with a bundle, which was checked first with packslip verify (signed by push-artifact.yaml@refs/tags/v0.0.2, repository celest-io/remeris) and installed with mise. It
is derived from the issuer and the ID of the signing repository, not from the workflow or the tag, so it stays the same for every release and survives a rename of the repository (a
repository that later takes the old name gets a different one). It is published here and in the README, apart from the releases, so that a user does not have to trust the first
release they see: packslip install github.com/celest-io/remeris --pin ps1_p2ti4zisstvj4fgdichvcpnhhq. A different fingerprint means the release was signed from another
repository: do not install it. To print it again, run packslip pin packslip.sigstore.json on a release you trust. Note that packslip show does not print it.
To check a download by hand with the packslip CLI:
V=v0.0.2
curl -fsSLO "https://github.com/celest-io/remeris/releases/download/${V}/packslip.sigstore.json"
curl -fsSLO "https://github.com/celest-io/remeris/releases/download/${V}/remeris_${V}_linux_amd64.tar.xz"
packslip verify packslip.sigstore.json --artifact "remeris_${V}_linux_amd64.tar.xz" # add --pin ps1_p2ti4zisstvj4fgdichvcpnhhq to require the signer fingerprint
packslip show packslip.sigstore.json # the platforms, formats and executables the bundle records (verifies nothing)
gh attestation verify "remeris_${V}_linux_amd64.tar.xz" --repo celest-io/remeris # the linked build provenance, which packslip verify does not fetch
packslip verify with no trust flags requires a run of celest-io/remeris as the signer, in any workflow and at any ref. To require this workflow at a tag, add
--identity-prefix https://github.com/celest-io/remeris/.github/workflows/push-artifact.yaml@refs/tags/ --issuer https://token.actions.githubusercontent.com; in mise, use the
workflow tool option, which accepts tag runs only unless it names another ref. Strict consumers should do this: a bundle signed by a run on a branch (a modified copy of the
workflow, see the ruleset notes) passes the default policy but fails these checks.
Bootstrap
The workflows prepare-release.yaml and release.yaml first build remeris from the commit they act on:
- name: Build remeris from this commit
run: |
mise run build
echo "${GITHUB_WORKSPACE}/build" >> "$GITHUB_PATH"
This needs no earlier release, which is why v0.0.1 can be cut, and it tests the tool on itself. The cost is that a regression of PrepareRelease or Release breaks the release
that should ship the fix, and the build adds about a minute.
Once a release exists, you can switch to the last released binary (a stable tool releasing the next version). Replace the build step in both workflows by a download that checks the
checksum, for example for the latest release (the archives are .tar.xz from the release after v0.0.1; v0.0.1 has .tar.gz, so use tar -xzf for it):
- name: Install the released remeris
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release download --repo celest-io/remeris --pattern 'remeris_*_linux_amd64.tar.xz' --pattern SHA256SUMS --dir /tmp/remeris
( cd /tmp/remeris && sha256sum --ignore-missing --check SHA256SUMS )
mkdir /tmp/remeris/bin
tar -xJf /tmp/remeris/remeris_*_linux_amd64.tar.xz --strip-components=1 --directory /tmp/remeris/bin
echo /tmp/remeris/bin >> "$GITHUB_PATH"
and consider adding cosign verify-blob on SHA256SUMS as in the previous section, and pinning the version (gh release download v0.0.1) with Renovate. Keep the build from
source as a fallback when a release is broken: run the workflow with the build step restored.
Renovate
renovate.json updates the actions (pinned to commit SHAs), the mise tools and, through a regular expression manager, the ghcommit version of prepare-release.yaml
(GHCOMMIT_VERSION, marked with a # renovate: comment). Renovate cannot update the checksum next to it (GHCOMMIT_SHA256, the ghcommit_linux_amd64 line of the release’s
checksums.txt), so a ghcommit PR from Renovate fails the install step until you edit that line in the same PR. This is deliberate: the pin must be reviewed by a person.