writeonce/docs/guides/releasing.md
shoney.arickathil cfe81cf8d0 ci: release workflow — no gh auth login, tag-triggered
- .github/workflows/release.yml: builds, verifies and publishes on a
  `v*` tag. `permissions: contents: write` on the injected
  GITHUB_TOKEN replaces `gh auth login`; no PAT, nothing to rotate
- runs-on ubuntu-22.04 DELIBERATELY: the build host's glibc caps which
  symbol versions the binaries import, and that cap is the floor every
  user needs. 22.04 (2.35) includes Ubuntu 22.04 / Debian 12 / RHEL 9;
  24.04 (2.39) would exclude them
- guards that fail instead of publishing: tag vs VERSION, produced
  asset name vs the filename /install links, sha256, and a smoke test
  that builds a hello project with the binaries INSIDE the tarball
- reports the shipped glibc floor so the claim on /install is checkable
  from a build log
- releasing.md: pipeline route up front, manual route kept; GH_TOKEN
  recipe for non-GitHub CI

Not run — this repo has no CI history and Actions cannot execute
locally. Every guard's shell was dry-run here against the real dist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 04:58:40 +02:00

9.4 KiB

Cutting a release — building the tarball and publishing it on GitHub

The /install page on writeonce.de links a GitHub release asset by an exact URL. Publishing is therefore not "upload a file somewhere": the asset filename has to match what the site links, or the download button 404s. This runbook keeps the two in step.

The URL the site links today:

https://github.com/shoneyj/writeonce/releases/download/v0.1.0/writeonce-0.1.0-linux-amd64.tar.gz

which decomposes as <repo>/releases/download/<tag>/<asset-name>. So the tag must be v0.1.0 and the asset must be named exactly writeonce-0.1.0-linux-amd64.tar.gz — which is what just dist already produces.

Two routes

Automated (preferred). .github/workflows/release.yml builds, verifies and publishes on a v* tag push. It needs no gh auth login and no secret: GitHub injects a per-job GITHUB_TOKEN, and the single line permissions: contents: write is what lets that token create a release. The token expires when the job ends, so there is nothing to rotate or leak. Skip to Releasing from the pipeline below.

Manual. Everything from §0 onward — the path for a first release, or when the pipeline is broken and you need to ship anyway.

Releasing from the pipeline

git tag -a v0.1.0 -m "writeonce 0.1.0"
git push origin v0.1.0

That is the whole release. The workflow then, in order: checks the tag matches VERSION, builds via scripts/mkdist.sh, asserts the produced filename is the one /install links, verifies the .sha256, extracts the tarball and builds a hello project with the binaries inside it, prints the glibc floor of what is about to ship, and publishes both files with gh release create.

Two things about that file are deliberate:

  • runs-on: ubuntu-22.04, not ubuntu-latest. The binaries link glibc dynamically, so the build host's glibc caps the symbol versions they can import — and that cap becomes the minimum glibc every user needs. On 24.04 (glibc 2.39) the floor is 2.39; on 22.04 (2.35) it is 2.35. That is the difference between excluding and including Ubuntu 22.04, Debian 12 and RHEL 9. Changing the image changes who can run the release, so change install/view.wo's supported-systems list in the same commit.
  • It fails rather than publishes when the tag, VERSION and the asset name disagree, because those three are what the download URL on /install is built from.

Other CI (GitLab, Jenkins, Buildkite)

No gh auth login there either — gh reads a token from the environment:

GH_TOKEN=$MY_SECRET gh release create v0.1.0 dist/*.tar.gz dist/*.sha256

The secret is a PAT with repo scope (classic) or Contents: read and write (fine-grained). Outside GitHub it is a long-lived credential you own and must rotate — which is exactly the cost GITHUB_TOKEN avoids, and the reason to prefer Actions for this one job even if the rest of your CI lives elsewhere.

0. Authenticate gh (manual route only)

gh keeps its own credential, separate from git's. SSH keys let you git push; they do not let gh call the API, so a machine that pushes fine can still fail to create a release.

Check first — if this names your account, skip the rest of this section:

gh auth status

The interactive flow (what to pick)

gh auth login

It asks five things:

prompt answer
What account do you want to log into? GitHub.com
Preferred protocol for Git operations? SSH — this repo's remote is already git@github.com:shoneyJ/writeonce.git, and answering HTTPS here rewrites how git talks to GitHub for every repo on the host
Upload your SSH public key? pick ~/.ssh/id_ed25519.pub, or Skip if that key is already on your account
How would you like to authenticate? Login with a web browser
One-time code gh prints something like ABCD-1234; press Enter, paste it at https://github.com/login/device, authorise

The token lands in the system credential store, or in a plain file if there is no store (gh auth status prints the location; --insecure-storage forces the file). -w/--web skips straight to the browser step.

In a Claude Code session, prefix it with ! — ! gh auth login — so the prompts are yours to answer and the output lands in the conversation. An agent cannot complete a device flow on your behalf.

No browser on that machine (server, container, CI)

Create a personal access token at https://github.com/settings/tokens. Classic tokens need the scopes repo, read:org and gist; a fine-grained token needs Contents: read and write on the repo, which is what release assets are written through. Then:

printf '%s' "$TOKEN" | gh auth login --with-token

Read it from a chmod 600 file rather than typing it inline — an argument on the command line lands in shell history and in ps. For automation, skip login entirely and export GH_TOKEN; gh picks it up and stores nothing.

Verify before you rely on it

gh auth status
gh repo view shoneyJ/writeonce --json name,visibility

The second call is the real check: it proves the token can reach this repo, which a valid-but-wrong-account token would not.

1. Before you build

Check the tree is clean and the version is what you mean to ship — VERSION is the single source, and mkdist.sh refuses to build if woc version or wovm --version disagree with it:

git status --porcelain     # expect empty
cat VERSION                # e.g. 0.1.0

2. Build the artifact

just dist

That builds both release binaries, runs the version drift guard, and writes two files:

dist/writeonce-<ver>-linux-amd64.tar.gz
dist/writeonce-<ver>-linux-amd64.tar.gz.sha256

3. Verify it before anyone else can

Two checks, both cheap, both worth it. First the digest:

cd dist && sha256sum -c writeonce-0.1.0-linux-amd64.tar.gz.sha256 && cd ..

Then prove the tarball actually works, using the binaries INSIDE it — not the ones in your build tree:

tmp=$(mktemp -d)
tar -C "$tmp" -xzf dist/writeonce-0.1.0-linux-amd64.tar.gz
export PATH="$tmp/writeonce/bin:$PATH"
woc version && wovm --version

mkdir -p "$tmp/hello" && cd "$tmp/hello"
printf 'name    = "hello"\nversion = "0.1.0"\n\n[runtime]\nwo = ">= 0.1"\n' > wo.toml
printf 'fn main() -> Int {\n  print("hello, writeonce");\n  return 0;\n}\n' > main.wo
woc . && ./target/hello

If that prints hello, writeonce, the release is sound.

4. Tag the commit

The tag is what the download URL points at, so tag the exact commit the binaries were built from:

git tag -a v0.1.0 -m "writeonce 0.1.0"
git push origin v0.1.0

5. Publish and upload

One command creates the release and attaches both files:

gh release create v0.1.0 \
  dist/writeonce-0.1.0-linux-amd64.tar.gz \
  dist/writeonce-0.1.0-linux-amd64.tar.gz.sha256 \
  --title "writeonce 0.1.0" \
  --notes-file - <<'EOF'
Linux x86-64, glibc 2.38 or newer. Two binaries — `woc` and `wovm` —
depending only on the system C library.

    rm -rf /usr/local/writeonce
    tar -C /usr/local -xzf writeonce-0.1.0-linux-amd64.tar.gz
    export PATH=$PATH:/usr/local/writeonce/bin

Verify with the published `.sha256` before extracting.
EOF

Add --draft to stage it without publishing, or --prerelease to mark it as one. If the release already exists and you only need to attach (or replace) files:

gh release upload v0.1.0 dist/writeonce-0.1.0-linux-amd64.tar.gz --clobber

Without gh, the same thing through the web UI: the repo's Releases page → Draft a new release → choose tag v0.1.0 → drag both files into the attachment box → Publish release. Uploading by hand is where the filename usually drifts, so paste it rather than retyping it.

curl -sIL -o /dev/null -w '%{http_code} %{url_effective}\n' \
  https://github.com/shoneyj/writeonce/releases/download/v0.1.0/writeonce-0.1.0-linux-amd64.tar.gz

A 200 means /install's download button works for a stranger. Anything else means the tag, the asset name, or the repo path disagrees with the link in docs/examples/site/install/view.wo.

7. Refresh the site's mirror

/install offers this site as a mirror beside the GitHub link, served by the framework's StaticFiles out of $WO_DIST (default ./dist). Copy the same two files there so the mirror is not stale:

scp dist/writeonce-0.1.0-linux-amd64.tar.gz* user@host:/srv/writeonce/dist/

Both copies are the same bytes, and the published .sha256 covers either one — that is the point of shipping the digest beside the archive.

Notes

  • dist/ is gitignored (.gitignore:103). The tarball is a build artifact; the release is where it lives, not the repository.
  • Repo path case. The git remote is shoneyJ/writeonce while the site links shoneyj/writeonce. GitHub paths are case-insensitive and redirect, so both resolve — but keep them consistent when either changes.
  • Bumping the version means editing VERSION, rebuilding, and updating every place the site names the current release: docs/examples/site/install/view.wo (the download links, the tar command, and the .sha256 link). The version appears there as literal text, so grep for the old number before you publish.
  • One target today. mkdist.sh builds linux-amd64 only; a cross matrix is future work. Do not add architectures to the release notes that no build produces.