writeonce/docs/guides/releasing.md
shoney.arickathil e6560dd9e5 docs(releasing): first-time pipeline readiness checklist
- states plainly what does NOT trigger it: builds run on a
  GitHub-hosted runner, not locally, and only on a `v*` tag push —
  pushing master releases nothing
- 14 numbered steps: get the workflow onto GitHub, enable Actions,
  allow ocaml/setup-ocaml, the 403/workflow-permissions fallback,
  a --draft rehearsal on a throwaway tag, cleanup, then the real tag
- calls out that the rehearsal tag is EXPECTED to fail the tag/VERSION
  guard, and how to rehearse the full job instead
- step 8/9: read the runner's glibc floor and reconcile
  install/view.wo with it — the runner, not the dev machine, decides
  who can run the release
- lists the three likely first-run failures

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

340 lines
12 KiB
Markdown

# 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.
## Making the pipeline ready (first time only)
It does **not** build on your machine, and pushing to `master` does not
release anything. The job runs on a GitHub-hosted runner, and only a
`v*` TAG push starts it. Ordinary commits, PRs and branch pushes are
ignored by this workflow.
```
1 Get the workflow onto GitHub — Actions only sees files in the repo.
git checkout master
git merge site-homepage # or open a PR and merge it
git push origin master
2 Repo -> Actions tab. If it offers to enable workflows, enable them.
3 Repo -> Settings -> Actions -> General -> "Allow actions":
must permit actions/checkout and ocaml/setup-ocaml.
On "Allow select actions", add: ocaml/setup-ocaml@*
4 Same page -> "Workflow permissions". The workflow asks for
contents: write explicitly, which is normally enough. If the
publish step later fails with 403, come back and select
"Read and write permissions".
5 REHEARSE before a real tag. Add --draft to the gh release create
line in .github/workflows/release.yml, commit, push.
6 Fire it with a throwaway tag:
git tag -a v0.0.0-test -m "pipeline rehearsal"
git push origin v0.0.0-test
Note: the tag guard compares the tag to VERSION, so this run is
EXPECTED to fail at step "Tag must match VERSION". That is the
cheapest proof the guard works. To rehearse the whole job, set
VERSION to 0.0.0-test on a scratch branch and tag that instead.
7 Watch it: Actions tab, or `gh run watch` once gh is authenticated.
8 Read the "Report the glibc floor" step. It prints what the
RUNNER-built binaries actually require. Expect 2.35-ish from
ubuntu-22.04, versus 2.38 from this dev machine.
9 Update docs/examples/site/install/view.wo's supported-systems list
to whatever step 8 printed, and the distros that follow from it.
Publishing binaries whose floor differs from the page is the one
failure a user cannot debug.
10 Clean up the rehearsal:
gh release delete v0.0.0-test --yes
git push origin :refs/tags/v0.0.0-test
git tag -d v0.0.0-test
11 Remove --draft, commit, push.
12 Ship for real:
git tag -a v0.1.0 -m "writeonce 0.1.0"
git push origin v0.1.0
13 Verify the link a stranger clicks (should print 200):
curl -sIL -o /dev/null -w '%{http_code}\n' \
https://github.com/shoneyj/writeonce/releases/download/v0.1.0/writeonce-0.1.0-linux-amd64.tar.gz
14 Refresh the site's mirror from $WO_DIST (section 7 below).
```
Known first-run risks, in the order they are likely to bite:
`ocaml/setup-ocaml@v3` resolving OCaml 4.14 on the 22.04 image; the
`objdump` in the glibc-floor step needing `binutils` (present on GitHub
runners, absent in slim containers); and a 403 on publish, which is
step 4.
## 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.
## 6. Check the link the site actually uses
```
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.