Releasing and verifying WaWarden¶
This document describes the repository settings a release depends on, how releases are produced, what each release contains and under which licences, how to verify one, and the checklists the maintainer follows.
Contents¶
Repository settings¶
The approval gate and the immutability of releases come from repository settings, not from the workflow file. When a job names an environment that does not exist, GitHub creates it with no protection rules, so these settings are what keeps a release from publishing without approval.
The following settings are in place. Each command below is read-only and prints
the value shown when the setting holds; run them with an authenticated gh. Some
of these endpoints answer only to repository administrators.
- Private vulnerability reporting is enabled.
gh api repos/dortort/wawarden/private-vulnerability-reporting --jq .enabledprintstrue. - Releases are immutable.
gh api repos/dortort/wawarden/immutable-releases --jq .enabledprintstrue. - The
releaseenvironment requires a reviewer's approval.gh api repos/dortort/wawarden/environments/release --jq '[.protection_rules[].type]'prints["required_reviewers","branch_policy"]. Self-review is allowed: with a single maintainer, preventing it would block every release. - Administrators cannot bypass the
releaseenvironment's protection rules.gh api repos/dortort/wawarden/environments/release --jq .can_admins_bypassprintsfalse. - The
releaseenvironment accepts deployments only frommain.gh api repos/dortort/wawarden/environments/release --jq .deployment_branch_policyprints{"custom_branch_policies":true,"protected_branches":false}, andgh api repos/dortort/wawarden/environments/release/deployment-branch-policies --jq '[.branch_policies[] | .type + ":" + .name]'prints["branch:main"]. maincannot be deleted or force-pushed, and requires a linear history.gh api repos/dortort/wawarden/rules/branches/main --jq '[.[].type]'prints["deletion","non_fast_forward","required_linear_history"]. A linear history means a merge commit cannot be pushed tomain, so contributors rebase (git pull --rebase) before pushing. The rules come from the branch ruleset:gh api repos/dortort/wawarden/rulesets --jq '.[] | select(.target == "branch") | .id'prints its id, andgh api repos/dortort/wawarden/rulesets/<id> --jq '{enforcement, target, include: .conditions.ref_name.include, rules: [.rules[].type], bypass_actors}'prints{"bypass_actors":[],"enforcement":"active","include":["~DEFAULT_BRANCH"],"rules":["deletion","non_fast_forward","required_linear_history"],"target":"branch"}: enforced, targeting the default branch,main, with no bypass actors.v*tags cannot be deleted or moved.gh api repos/dortort/wawarden/rulesets --jq '.[] | select(.target == "tag") | .id'prints the tag ruleset's id, andgh api repos/dortort/wawarden/rulesets/<id> --jq '{enforcement, include: .conditions.ref_name.include, rules: [.rules[].type], bypass_actors}'prints{"bypass_actors":[],"enforcement":"active","include":["refs/tags/v*"],"rules":["deletion","update","non_fast_forward"]}. Creating a tag is not restricted, because the release workflow creates it.- Actions must be pinned by commit SHA.
gh api repos/dortort/wawarden/actions/permissions --jq .sha_pinning_requiredprintstrue. GitHub refuses to run a workflow that references an action by tag or branch. - The default workflow token is read-only.
gh api repos/dortort/wawarden/actions/permissions/workflow --jq .default_workflow_permissionsprintsread.
Neither ruleset has bypass actors, so they apply to administrators too.
The release workflow's first job reads the release environment through the API
and stops unless it exists, has a required reviewer, does not let administrators
bypass its protection rules, and accepts deployments only from the branch main.
The workflow token cannot read the other settings: check them with the commands
above after any change to the repository settings.
How a release is produced¶
- Releases are cut only from
main, by the manually dispatched workflow.github/workflows/release.yml. Nobody pushes release tags by hand. - The workflow has four jobs, which run in this order:
guard(Check version, tag, environment and CI),build(Build),rebuild(Rebuild and compare) andrelease(Publish), the publishing job. Every job runs only when the workflow was dispatched onrefs/heads/main. - The workflow input is the version. The
guardjob accepts onlyvMAJOR.MINOR.PATCHwith decimal numbers without leading zeros and no suffix (v0.1.0, notv0.01.0orv1.0.0-rc.1), and stops whengit ls-remotefinds the tag in the repository or fails. The publishing job checks again that the tag does not exist just before it creates the release. - The
guardjob also checks thereleaseenvironment as described under Repository settings, and stops unlessci.ymlhas a successfulpushrun onmainfor the commit being released. Dispatch only after CI onmainhas finished. - The job that publishes runs in the
releaseenvironment, so it starts only after the maintainer approves it. In the release workflow, the permissions that can write to the repository or the registry (contents: write,packages: write,attestations: write) and the OIDC token used for signing (id-token: write) are granted only to that job, so they exist only after approval. - Outside the release workflow, one job holds
id-token: write: the OpenSSF Scorecard job (.github/workflows/scorecard.yml), which needs it to publish its results. It runs on pushes tomainand on a schedule, does not build or run the repository's code, and holds nocontents,packagesorattestationswrite permission. The other write permissions outside the release workflow aresecurity-events: write(Scorecard and CodeQL upload code-scanning results),issues: write(the dailygovulncheckjob manages one issue), andcontents: writeandpull-requests: write, held by the job of the whatsmeow bump workflow that pushes the branchbump/whatsmeowand opens its pull request, and runs no Go code. - The publishing job pushes the image by digest, without a tag. Only after the pushed digest matches the build job's, and the index is signed and attested, does it add the version tag.
- The workflow creates the tag and the GitHub release itself, on the commit it
built. The release notes, including the image index digest, are written first.
gh release createthen creates the release as a draft, attaches every asset, and only then publishes it. - Releases are immutable (see Repository settings): once published, a release's tag cannot be moved or deleted, its assets cannot be replaced, and its tag name cannot be reused, even if the release is deleted. The workflow token cannot read this setting before publishing, so the setting is the control: the workflow only checks the published release afterwards and fails if it does not report itself as immutable.
Build and rebuild¶
The build is reproducible. hack/repro-build.sh is the single recipe used by the
release workflow, by CI and by anyone reproducing a release:
- Go binaries for
linux/amd64andlinux/arm64, built withCGO_ENABLED=0,-trimpath,-buildvcs=true,-mod=readonlyand an empty build ID, with the toolchain named by thetoolchainline ingo.mod(GOTOOLCHAIN=local): the script stops unlessgo env GOVERSIONequals that line. Inci.yml,codeql.yml,release.yml,govulncheck-daily.ymlandbump-whatsmeow.yml, every job that runs Go also runshack/check-go-version.shright after setting Go up, which makes the same comparison in the job's own environment. The script accepts anyVERSIONthat is a valid image tag (CI builds withVERSION=ci); the version string is injected at link time intointernal/buildinfo.Version, andwawarden versionprints it. - Modules are pinned by
go.sumand never vendored. The script stops when anything namedvendorexists at the repository root, and builds with-mod=readonly, which fails rather than changego.modorgo.sum. A build downloads the modules through the Go module proxy, or whateverGOPROXYnames. The go command checks each download against its hash ingo.sum. With default settings, it checks a new hash against the Go checksum database (sum.golang.org) before adding it togo.sum. The CImodulesjob and an architecture test also fail when anything namedvendorexists at the repository root. Another architecture test requires every workflow that sets up Go to setGOFLAGS: -mod=readonlyin its top-levelenv, and fails on any line of such a workflow that mentionsGOFLAGSbut is not exactlyGOFLAGS: -mod=readonly, and on any-modflag there that is not written-mod=readonly, such as-mod vendor,-mod=vendoror--mod=mod. It reads the workflow text only, so it does not see a module mode chosen in a script that a workflow calls. -
Before packaging, the script checks each binary:
- its embedded build settings show no
devtag,vcs.revisionequal to the commit,vcs.modified=false,CGO_ENABLED=0and the right architecture; - it contains neither the marker string that only dev builds carry nor the marker string of the development-only fake engine;
go tool nmlists no symbol ofinternal/engine/fake;- it contains the version variable that the link step sets (
go tool nmlistsinternal/buildinfo.Version).
- its embedded build settings show no
-
On a Linux host, and only in its
binariesandocimodes, the script also runs the binary built for the host's architecture, with an empty environment (env -i).wawarden versionmust print exactly the requested version, the commit anddev build false. In the release workflow, thebuildjob runs theamd64binary on anamd64runner, and therebuildjob runs thearm64binary on anarm64runner. Both release binaries are therefore run before anything is published. - The publishing job runs the script in
pushmode, which runs none of the binaries it builds: that job holds the write permissions, so it runs no code built from the repository. It proves instead that its binaries are byte-identical to the ones the two other jobs ran. The image it pushes must have thebuildjob's index digest, and itsSHA256SUMSmust equal thebuildjob's, which therebuildjob reproduced. - After building the binaries, the script runs
hack/licenses.sh, which collects the licence files of the Go standard library and of every module linked into them (see Licences) intodist/licenses, and packs that directory intowawarden_<version>_licenses.tar.gzwithhack/tarball.go.SHA256SUMSlists this archive next to the binaries, so every comparison ofSHA256SUMSbelow covers it too. SOURCE_DATE_EPOCHis the commit time. File times inside the image are set to it.- A multi-arch OCI image built by BuildKit from a digest-pinned
gcr.io/distroless/static-debian13:nonrootbase. The image adds the binary,/wawarden, and an empty/datadirectory owned by the runtime user with mode0700and the licence files under/licensesto the base, runs as65532:65532and has no shell. The image's runtime contract is indocs/configuration.md. - The script creates its own temporary
docker-containerbuilder from a BuildKit image pinned by version and digest in the script, and removes it afterwards. The workflows pin the buildx version inBUILDX_VERSIONand download that buildx release on every run instead of restoring it from the GitHub Actions cache, and the script stops whenBUILDX_VERSIONis set and the installed buildx reports another version. No build cache is used, and BuildKit's own provenance and SBOM attestations are switched off, because they differ between identical builds. Provenance is attested separately (below). - The release jobs check out the commit shallowly and fetch no tags
(
fetch-depth: 1,fetch-tags: false), so the build sees no tag. The script stops if any tag is reachable from the commit. - Every GitHub Action in the workflows is pinned by commit SHA, which the repository requires (see Repository settings).
The rebuild job then runs the same script from scratch on a fresh runner of the
other architecture: arm64, where the build job ran on amd64. The release
proceeds only when both jobs produce identical binary checksums and an identical
image index digest. Both jobs also generate the SPDX SBOMs, and the release
stops unless the two sets are identical apart from the document namespace and the
creation time. The publishing job builds and
pushes the image a third time and stops unless the pushed digest equals the one
both builds produced.
What a release contains¶
| Artifact | Where |
|---|---|
Multi-arch image (linux/amd64, linux/arm64) |
ghcr.io/dortort/wawarden@sha256:<digest>, also tagged <version>; the release notes state the index digest |
Binaries wawarden_<version>_linux_amd64 and wawarden_<version>_linux_arm64 |
Release assets |
From v0.2.0 on, the licence files of the Go standard library, of WaWarden and of every module linked into the binaries, wawarden_<version>_licenses.tar.gz |
Release asset, and /licenses in the image |
SHA256SUMS for the binaries and, from v0.2.0 on, the licence archive |
Release asset |
SPDX 2.3 SBOMs for the binaries, wawarden_<version>_linux_<arch>.spdx.json |
Release assets, and attested to each binary in GitHub's attestation store |
Build provenance attestations (SLSA, via actions/attest-build-provenance): one for the image index, and one whose subjects are the files SHA256SUMS lists |
GitHub's attestation store; the image attestation is also pushed to the registry, and the files' Sigstore bundle is the release asset wawarden_<version>_provenance.sigstore.json |
| Keyless cosign signature on the image index, made with cosign v3.1.3 | Registry, next to the image |
The SBOMs list the Go modules and the Go standard library compiled into each binary. No SBOM covers the image as a whole, so the packages of the distroless base image are not listed; no image-level SBOM is produced.
Binary sizes, measured on 2026-10-13 with
CGO_ENABLED=0 go build -trimpath ./cmd/wawarden: about 31.8 MiB for
linux/amd64 and 30.2 MiB for linux/arm64. Each release's assets give the
exact sizes.
Image and memory figures were measured with hack/repro-build.sh on the code
of v0.2.0, and have not been measured again since:
- the image's compressed layers added up to about 15.3 MiB (
linux/amd64) and 14.4 MiB (linux/arm64), and thelinux/arm64image took about 29.0 MiB unpacked; - an unpaired
serveleft idle for two minutes in that image onlinux/arm64, with no network, a read-only root filesystem and a named volume on/data, held a resident set of about 22.5 MiB. That was 8.0 MiB of its own memory and 14.5 MiB of the binary's mapped pages;docker statsreported 9.9 MiB for the container.
A paired service needs more memory. It grows with message traffic and with
history sync, whose blobs WAWARDEN_HISTORY_MAX_BYTES caps.
Signing and attesting use GitHub's OIDC identity for the release workflow on
main. No long-lived signing key exists.
Licences¶
WaWarden is licensed under GPL-3.0-or-later from v0.2.0 on; v0.1.0 was
released under the MIT licence. From v0.2.0 on, the image's
org.opencontainers.image.licenses label, set in the Dockerfile, and the same
annotation on the image index, set by hack/repro-build.sh, are
GPL-3.0-or-later.
The binaries link the Go standard library, under Go's BSD-style licence and
patent grant. From v0.2.0 on, they also link third-party Go modules, each
under its own licence:
| Licence | Modules |
|---|---|
| MPL-2.0 | The protocol library go.mau.fi/whatsmeow and its go.mau.fi/util |
| GPL-3.0 | The Signal library, go.mau.fi/libsignal |
| Apache-2.0 | github.com/petermattis/goid, and the MCP library github.com/modelcontextprotocol/go-sdk |
| MIT | The MCP library's code contributed before its move to Apache-2.0, unless its authors relicensed it (its licence file carries both texts), and several other modules |
| ISC | github.com/coder/websocket |
| BSD-3-Clause | The rest, the SQLite driver and its C library translation included |
README.md lists every module. GPL-3.0 is why WaWarden
itself is GPL-3.0-or-later from v0.2.0 on. Every release ships their licence
texts, the GPL-3.0 and MPL-2.0 texts included, and WaWarden's own:
hack/licenses.shlists, withgo list -deps, the modules whose packages are compiled into./cmd/wawardenforlinux/amd64andlinux/arm64, and copies every regular file at the root of each module whose name starts withLICENSE,LICENCE,COPYING,NOTICE,PATENTSorUNLICENSE, in any letter case, todist/licenses/<module path>@<version>/: from the Go module cache for the third-party modules, and from the repository root, without a version, for WaWarden. The script stops when a linked module ships none of these files.- The standard library's
LICENSEandPATENTSare committed underhack/stdlib-licenses/<Go version>/, because not every Go installation carries them (Homebrew's does not). The script copies them todist/licenses/std@<Go version>/for the version that thetoolchainline ofgo.modnames, and stops when the local Go is another version, when that directory is missing, or when either file differs from the one in the local Go installation, where that one exists. A toolchain update therefore comes with a new directory, copied from the new Go distribution, and the removal of the old one. - Files get mode
0444, directories0755, and every timestamp is set to the commit time. The script can be run on its own; it writes nothing outsidedist/licenses. - The image carries that directory as
/licenses. hack/tarball.go, run withgo run, packs the directory intowawarden_<version>_licenses.tar.gz: entries in lexical order, mode0444or0755, owner and group0without names, the commit time as every modification time, and a gzip header without a name or a time. Its output depends only on the files and the Go toolchain, whichgo.modpins, so the archive is reproducible like the binaries. The release attaches it, andSHA256SUMSlists it.- The corresponding source of a release is its tagged commit together with the
module versions that the commit's
go.sumpins.
Verifying a release¶
Replace <version> with the release (for example v1.0.0), <digest> with the
image index digest from the release notes, and <arch> with amd64 or arm64.
Always deploy the image by digest.
Every gh command below needs an authenticated gh (gh auth login). The
cosign command needs cosign v3.1.3, the release used to sign, or a newer one.
Image provenance¶
gh attestation verify oci://ghcr.io/dortort/wawarden@sha256:<digest> \
--repo dortort/wawarden \
--cert-identity https://github.com/dortort/wawarden/.github/workflows/release.yml@refs/heads/main
This checks that the image was built by release.yml running on main of this
repository: --cert-identity compares the signing certificate's identity with
the whole value, exactly. Do not replace it with --signer-workflow: gh
matches that flag as a prefix, so ...@refs/heads/main would also accept a run
on a branch whose name starts with main, such as main-x. The binary and SBOM
checks below use the same exact identity.
gh reads the registry with your existing registry login; run
docker login ghcr.io first if verification cannot fetch the image.
Use the index digest. Attestations and the signature are attached to the multi-arch index, not to the per-platform manifests.
Image signature¶
cosign verify ghcr.io/dortort/wawarden@sha256:<digest> \
--certificate-identity https://github.com/dortort/wawarden/.github/workflows/release.yml@refs/heads/main \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Binaries¶
Download the binaries and SHA256SUMS from the release, then check the
checksums. On Linux:
On macOS:
Then check the provenance of each binary:
gh attestation verify wawarden_<version>_linux_<arch> \
--repo dortort/wawarden \
--cert-identity https://github.com/dortort/wawarden/.github/workflows/release.yml@refs/heads/main
The same command, given wawarden_<version>_licenses.tar.gz, checks the
provenance of the licence archive of a release from v0.2.0 on.
go version -m <binary> prints the build settings embedded in a binary. A release
binary shows CGO_ENABLED=0, -trimpath=true, vcs.revision equal to the
release commit and vcs.modified=false, and no -tags setting containing dev.
On a Linux host of the same architecture, wawarden version prints the release
version and dev build false.
SBOMs¶
gh attestation verify wawarden_<version>_linux_<arch> \
--repo dortort/wawarden \
--cert-identity https://github.com/dortort/wawarden/.github/workflows/release.yml@refs/heads/main \
--predicate-type https://spdx.dev/Document/v2.3
gh attestation verify checks SLSA provenance unless --predicate-type names
another type. This command checks that the release workflow attested an SPDX 2.3
SBOM for that binary.
The release record¶
gh release verify <version> --repo dortort/wawarden
gh release verify-asset <version> SHA256SUMS --repo dortort/wawarden
These check the attestation GitHub creates when an immutable release is published.
Reproducing a release¶
You need:
- git;
- the Go toolchain named by the
toolchainline in the release'sgo.mod, installed locally: the script setsGOTOOLCHAIN=localand stops if the local Go is any other version; - rootful Docker with the buildx plugin, at the buildx version pinned in the
release's
.github/workflows/release.yml(BUILDX_VERSION); rootless Docker and Podman have not been tested; jqandtar. On macOS, the systemtouch,shasumandsha256sumare enough; GNU coreutils are not needed;- access to the Go module proxy, or to whatever
GOPROXYnames. Modules are not vendored, so the script downloads them, and the go command checks them against the release'sgo.sum.
The script builds with its own temporary docker-container builder, created from
the BuildKit image pinned in the script, so the default builder of your Docker
installation is not used. Leave BUILDKIT_IMAGE unset. Set BUILDX_VERSION to the
pinned buildx version, and the script refuses to run with any other.
Fetch exactly the release commit, without tags, and build:
mkdir wawarden-<version> && cd wawarden-<version>
git init -q
git fetch --depth 1 --no-tags https://github.com/dortort/wawarden refs/tags/<version>
git checkout -q --detach FETCH_HEAD
BUILDX_VERSION=<buildx version> VERSION=<version> hack/repro-build.sh
dist/SHA256SUMS must be identical to the release's SHA256SUMS, and the image
index digest the script prints last must equal <digest>.
Three details decide whether the result matches:
- Tags. Since Go 1.24,
go buildstamps the main module's version from the repository's tags: the tag on the commit, or a pseudo-version based on the most recent tag reachable from it. The release checkout fetches no tags, so the release build sees none. A full clone, orgit clone --branch <version>, sees tags and would produce different binaries, so the script stops when any tag is reachable from the checked-out commit; use the fetch above. - A clean tree. Any modified or untracked file that git does not ignore marks
the build
vcs.modified=trueand changes the binaries. The script stops on a dirty tree. Write nothing into the checkout except under ignored paths such asdist/. - The builder. A different BuildKit or buildx version can produce a different image digest from identical binaries.
If a checksum or the digest differs, check the builder versions, the Go toolchain
and that the checkout is clean and tag-free before reporting a release-integrity
issue. Report it as described in SECURITY.md if the difference
remains.
Versioning¶
v0.1.0,v0.2.0andv0.3.0were development releases. Each raised the minor version, replaced the one before it and promised no compatibility.v1.0.0was the first release under the compatibility promise below. It completed sending, scheduled backups with retention, audit retention and master key rotation. It also completed the documentation set: the threat model, the configuration reference, the hardening guide, the agent guidance, the ban-risk notes, the runbook,SECURITY.mdand this document.- From
v1.0.0on, a patch release fixes, a minor release adds, and a major release is the only one that removes or changes.
The changelog lists what each release changed.
Supported versions¶
Only the latest minor release line receives security fixes, released as patch versions of that line. Nothing is backported to an older line. Minor releases within v1 keep the documented interfaces compatible, so upgrading to the latest minor release is how you receive a fix. The v0.x releases promised no compatibility, and each replaced the one before it.
| Version | Supported |
|---|---|
Latest minor line (vX.Y.* of the newest release) |
Yes |
| Anything older | No |
SECURITY.md says how to report a vulnerability.
Compatibility promise¶
From v1.0.0 on, no release within v1.x removes, renames or changes the
meaning of:
- the routes of the client, admin and health listeners, with their parameters, response fields and error codes;
- the MCP tool names and their argument schemas;
- the environment variable names and the values they accept;
- the subcommands, their flags and the exit codes;
- the notification event names and their fields;
- the Prometheus and CloudWatch EMF metric names.
A minor release may add optional parameters, response fields, tools, routes,
variables, flags, events and metrics. A removal or other breaking change needs
v2.0.0, announced as a deprecation in the release notes at least one minor
release ahead. A security fix may tighten behaviour in a patch release, with an
advisory.
Outside the promise:
- Behaviour that depends on WhatsApp, the protocol library or the owner's phone.
- Downgrades. Schema migrations go forward only: a release may upgrade the archive and the device store, and refuses to open an archive written by a newer release. Take a backup before upgrading (see the runbook).
How the promise is tested¶
TestV1SurfaceMatchesTheGoldenininternal/archtesttests the names in the promise.TestMCPToolListMatchesTheGoldenFilesininternal/apitests the MCP tools' schemas.- The REST response fields, the error codes, the values the variables accept and the event fields are checked in review.
TestV1SurfaceMatchesTheGolden derives a sorted list of the v1 surface from
the source of a release build and the committed MCP tools/list golden files.
It compares that list line by line with
internal/archtest/testdata/v1-surface.txt, which holds:
- every route of the client, admin and health listeners, with its class;
- every MCP tool, with its input-schema property and required names for each client class;
- every environment variable that
configaccepts; - every subcommand, with its flags and their types, and the command run when none is named;
- the exit codes;
- the notification event names;
- the Prometheus and CloudWatch EMF metric names.
Any difference fails the test, an addition included. The test also records a floor for each category's entry count. A category that falls below it fails even after regeneration, so neither a net removal nor a scan that stops seeing entries passes by regenerating alone. A rename, or a removal offset by an addition in the same category, passes once regenerated and must be caught in review.
A change that adds to the surface regenerates the file after review, and commits it with the change:
A line that the regenerated file drops or alters is a breaking change.
Maintainer checklists¶
Cutting a release¶
- Every command under Repository settings prints the expected value.
-
mainis green: CI, the reproducibility job andgovulncheck. The workflow refuses a commit without a successfulci.ymlrun onpush; CodeQL and Scorecard results are checked by hand. -
hack/offline-test.shpasses on the commit to release: the whole suite, release and dev builds, runs in a container without a network. - Choose the version under Versioning and check that the tag does not exist.
- On the commit to release, read the release-state statements:
CHANGELOG.md, Supported versions, the Scope section ofSECURITY.mdand the "Since" fields of the threat model.CHANGELOG.mdhas a section for the version being cut. The "Since" fields name it for each control it introduces, and no sentence says which release is the latest or what is planned. - The build and rebuild jobs list
wawarden_<version>_licenses.tar.gzin identicalSHA256SUMS, and it holds a directory for the standard library of thego.modtoolchain, for WaWarden and for every module ingo.modthat the binaries link (see Licences). - Dispatch the workflow on
main:gh workflow run release.yml --ref main -f version=<version>. - Before approving the
releaseenvironment, read the run: the commit is the intended tip ofmain, the version is right, and the build and rebuild jobs reported identical checksums and an identical index digest. - Approve. Watch the publishing job through signing, attestation and release creation.
- The
ghcr.io/dortort/wawardenpackage is still public, so that anyone can pull and verify it: pulling the new image by digest works withoutdocker login. - From a machine other than the one used for development, run every command under Verifying a release against the new release, including a reproduction.
- Check that the release is immutable:
gh release view <version> --repo dortort/wawarden --json isImmutable. The workflow's last step fails when it is not. - If the run fails before the release is published, delete the draft release, if one exists, and dispatch again. If the failed run left a tag, the tag ruleset prevents deleting it and the workflow refuses an existing tag: release the next patch version instead. If the run fails after publishing, do not try to repair the release: fix the cause and release the next patch version.
- If the publishing job stops because the pushed digest differs, the registry holds an untagged, unsigned index under that digest. Nothing refers to it; investigate the difference before dispatching again.
Dependency updates¶
Dependabot (.github/dependabot.yml) opens pull requests every week for:
- Go modules that have tagged releases;
- GitHub Actions, which are pinned by commit SHA;
- the digest of the base image in the
Dockerfile.
It proposes an upstream release only once it is three days old. Nothing is merged automatically. Each pull request is reviewed and merged by hand.
Dependabot never proposes modernc.org/libc on its own. The SQLite driver,
modernc.org/sqlite, requires exactly the libc version its own go.mod
names, so libc moves only when a driver update raises it. An architecture test
fails when the libc version in go.mod differs from the one the driver's
go.mod requires.
Dependabot does not update the following, so they are bumped by hand:
| Item | Where it is pinned |
|---|---|
| Go | The go and toolchain lines of go.mod. The workflows set up Go from go.mod; every job that runs Go in ci.yml, codeql.yml, release.yml, govulncheck-daily.yml and bump-whatsmeow.yml then runs hack/check-go-version.sh, which stops the job unless go env GOVERSION equals the toolchain version, and hack/repro-build.sh requires exactly that version too. Dependabot proposes no Go release, but a module update it proposes can raise the go line when the new version needs a newer Go; review such a pull request as a Go bump. |
| golangci-lint | The version input of both golangci/golangci-lint-action steps in .github/workflows/ci.yml. |
| govulncheck | The go install golang.org/x/vuln/cmd/govulncheck@<version> step in .github/workflows/ci.yml and in .github/workflows/govulncheck-daily.yml. |
| buildx | BUILDX_VERSION in the env block of .github/workflows/ci.yml and of .github/workflows/release.yml. |
| BuildKit | The default of BUILDKIT_IMAGE (version and digest) in hack/repro-build.sh. |
| The Go image of the offline run | default_image (version and digest) in hack/offline-test.sh. The script stops unless the image's Go equals the toolchain version, so bump it with Go. |
| cosign | The cosign-release input of the sigstore/cosign-installer step in .github/workflows/release.yml. Update the cosign version this document names with it. |
For every update:
- Review the upstream change, not only
go.modandgo.sum. Modules are not vendored, so the pull request's own diff shows only versions and hashes. For a Dependabot pull request, read the release notes and commits it links and the upstream diff between the two versions. Forgo.mau.fi/whatsmeow, read the upstream commit log, the diffstat and the diff of the watched paths that the bump workflow described below puts into the pull request, and pay particular attention to connection handling, the device store, sending, media download and upload, pairing, and message decryption. - Look for new modules, licence changes,
initfunctions with side effects, new goroutines, new listeners and handlers registered onhttp.DefaultServeMux. The architecture and listener-inventory tests catch some of these, and a new module fails the architecture tests until it is added to one of their two reviewed lists: modules that source files may import, and modules thatgo.modmay require only as indirect requirements. The review is the main defence. -
go mod verifypasses andgo mod tidy -diffprints nothing. ThemodulesCI job runs both, fails when anything namedvendorexists at the repository root, and builds with-mod=readonly. -
govulncheckis clean. - A bump of Go, the base image, buildx or the BuildKit image changes the release digests. The reproducibility job must still produce identical results from two independent builds. For a base image bump, also verify the new digest's signature as documented by the distroless project.
The whatsmeow bump workflow¶
go.mau.fi/whatsmeow has no tagged releases, so Dependabot cannot propose
updates for it. The bump-whatsmeow workflow
(.github/workflows/bump-whatsmeow.yml) proposes them instead, every Monday at
05:41 UTC and whenever a maintainer dispatches it from main with an upstream
commit (7 to 40 lowercase hex characters) or latest, the default. It never
merges, tags or releases. It runs two jobs:
- The first job can only read the repository. It asks the Go module proxy for
the target's pseudo-version, and ends without a change when
go.modalready pins it. Otherwise it runsgo get,go mod tidyandgo mod verify, stops unless exactlygo.modandgo.sumchanged, writes the pull request's description, which states no result of the build or the tests, uploads it withgo.mod,go.sumand the watched-path diff as the artifactwhatsmeow-bump, builds the release anddevtag sets, and runshack/offline-test.sh. A target that resolves to a tagged version or to another commit, or that needs a newer Go than thetoolchainline ofgo.modnames (GOTOOLCHAINislocal), stops it. - The second job runs only when the first one passed, and alone holds
contents: writeandpull-requests: write. It runs no Go code. It puts a line in front of the artifact's description saying that the first job passed its checks, builds and tests, commits the artifact'sgo.modandgo.sumonto the commit the run started from, pushes the commit to the branchbump/whatsmeow, and opens the pull request from that branch, or updates the title and description of the open one from that branch of this repository; a pull request from a fork's branch of the same name is never touched. The branch belongs to the workflow: a run replaces it, refuses to when it holds a commit that the workflow did not make, and leaves it alone when it already pins the same version, so that an approved CI run stays valid. A closed pull request is not remembered: the next run opens a new one for the newest commit, even one that was declined. An architecture test (internal/archtest/workflows_test.go) holds the workflow to this split: main only, no workflow-wide permission, no Go and nohack/script in the job that can write, credentials never persisted by a checkout, the Go version check right after setup-go and before any Go command, no step that says checks passed before the tests run, and the lookup by this repository's owner and branch, which skips pull requests from forks however many there are.
The description carries the old and new pseudo-versions, the upstream compare
link, whether the new commit descends from the pinned one (and if not, the
commits it drops), the upstream commit log between them, a diffstat between the
two module versions' sources as the module proxy serves them, the go.mod
diff, and the full diff between those sources of the watched paths: socket/,
store/, send.go, download.go, upload.go, message.go, pair*.go,
handshake.go, msgsecret.go and appstate*. Upstream text is fenced so that
it cannot end its code block. When the description would exceed GitHub's limit,
the diff is cut at a line with a note, and the complete diff is
watched-paths.diff in the run's whatsmeow-bump artifact.
When the first job fails, nothing is pushed. If it failed at the build or the
tests, its whatsmeow-bump artifact still holds the description, which claims
no result, and the diff. A bump that needs a change of this repository, such
as a new module for the architecture tests' reviewed lists, is made by hand on a
branch with the same commands.
Opening the pull request needs the repository setting Allow GitHub Actions to
create and approve pull requests (Settings, Actions, General, Workflow
permissions), which is off by default for a repository in a personal account.
gh api repos/dortort/wawarden/actions/permissions/workflow --jq .can_approve_pull_request_reviews
prints true when it is on. While it is off, GitHub refuses the workflow
token's request to open a pull request: the run fails after pushing the branch,
and its summary links the page from which a maintainer opens the pull request
by hand, with body.md from the artifact as its description. The setting also
lets a workflow token approve pull requests; no other workflow holds
pull-requests: write, and no ruleset requires an approval.
CI does not start on its own. GitHub starts no workflow run for an event that
the workflow token causes, apart from workflow_dispatch and
repository_dispatch; when a workflow opens or updates a pull request with that
token, the pull_request runs are created in an approval-required state
(Triggering a workflow from a workflow).
A maintainer with write access starts them with Approve workflows to run in
the pull request's merge box, after the workflow opens the pull request and
again after each run that pushes the branch. This needs no personal access
token, GitHub App or stored secret, and no workflow gains a permission. A pull
request that a maintainer opened by hand runs CI on its own, but later pushes
by the workflow wait for approval again.
For a bump pull request, in addition to the list above:
- Approve the CI runs as described above, and merge only after
cipassed on the pull request's head commit. Themainruleset requires no status check, so GitHub does not enforce this. - Read the whole watched-path diff, from the artifact when the description says it was cut, and the commit log. Treat a target that does not descend from the pinned commit as a downgrade or rewritten history, and find out why before merging.