Skip to content

feat: add the CI and release pipelines - #7

Merged
leinardi merged 1 commit into
mainfrom
feat/ci-release-pipeline
Sep 10, 2026
Merged

leinardi merged 1 commit into
mainfrom
feat/ci-release-pipeline

Conversation

@leinardi

Copy link
Copy Markdown
Owner

Summary

monmux had no CI workflow, no release pipeline and no published artifact — docs/release.md said so in its first line. This turns that design into files and reconciles the document with what now exists.

One rule frames all of it: no job runs monmux against hardware. Nothing in either workflow executes ddcutil, m1ddc or monmux switch, and the test suite refuses to start a process by construction, so a runner needs neither tool installed.

CI — .github/workflows/ci.yaml

test on Linux and macOS, cross, lint-macos, govulncheck, release-config (goreleaser check), plus the reviewdog hook jobs on pull requests (actionlint, pre-commit-hooks, markdownlint, shellcheck, yamllint, conventional-commits). The linter runs natively on both OSes because each backend sits behind a build tag, and make go-lint-cross aborts in GOROOT when it is older than the toolchain — a bad thing to have as the only line of defence. Both linter jobs read the version out of .pre-commit-config.yaml, so there is one source of truth.

Releases — .github/workflows/release.yaml + .goreleaser.yaml

Fully automatic from one workflow_dispatch, with an optional version and a dry_run switch. The job order exists because tags here are immutable: the ruleset blocks tag deletion with no bypass actors, so the familiar "delete the tag on failure" cleanup cannot work. Everything that can fail without a tag happens before the tag exists, and everything after the push is idempotent, so a re-run with the same explicit version recovers a failed publish.

Produces: static binaries for linux/amd64, linux/arm64, darwin/arm64; tarballs; .deb and .rpm (Cloudsmith and attached to the release); a Homebrew cask in leinardi/homebrew-tap depending on m1ddc; a cosign-signed checksum file; a build provenance attestation.

Versioning, and the catalog rule

The version comes from the Conventional Commits since the last tag via svu, with one project rule on top: a change to models.yaml that adds or moves a byte monmux can send is never a patch. A new tool, internal/catalog/internal/generate/cmd/relnotes, compares the two catalogs, prints the release-notes section, and prints the flag the workflow enforces that rule with.

"Can send" is deliberately wider than "is write-enabled": catalog.Model.UnsafeOperation ignores the flag and --unsafe-model calls exactly that, so recording a model or an input also counts. A release that only adds disabled entries is still at least a minor.

Three decisions worth reviewing

  • A cask, not a formula — goreleaser hard-deprecated brews: in v2.16, and a cask is macOS-only by nature. It quarantines its download, and the binaries carry no Apple Developer ID, so the cask strips com.apple.quarantine in a postflight hook. That is a real bypass and is documented as one in docs/security.md.
  • An unversioned ddcutil dependency — a hard 2.2 floor would make the package uninstallable on Debian 12 and Ubuntu 24.04 (both ship 1.4), including for somebody with a source-built 2.2 in /usr/local. Preflight enforces the floor properly against the binary that is actually installed.
  • Tags stay immutable — recovery is documented instead of cleanup. A version that was ever published never points anywhere else.

Supply chain

Every action is pinned to a full commit SHA; goreleaser and svu are exact versions; everything stamped into a binary comes from the commit rather than the clock, so the publish rebuild reproduces what the dry run inspected (verified by building the same commit twice and comparing SHA-256); and the documented verification commands pin the signing workflow and its ref rather than matching any workflow in the repository.

Guards on the release job, in firing order

  1. Refuses any ref but main (workflow_dispatch lets the caller pick a branch, and this job holds contents: write, OIDC and the tap token).
  2. Refuses when the publishing credentials are missing — before anything is built, not after the tag is pushed.
  3. Refuses a version that is not higher than every released version, except the recovery re-run of the version already on HEAD.
  4. Serialized by a release concurrency group that is never cancelled.
  5. Re-reads the remote's tags one last time immediately before pushing.

Pull request checklist

  • I am targeting the main branch
  • I have rebased this branch on top of the destination branch
  • I have executed make check locally before creating the commit and it has run successfully
  • I have performed a self-review of my own code
  • There are no WIP commits in this PR

Monitor writes

  • If this changes internal/catalog/models.yaml, the evidence is in the entry, and the Summary above says which model and which input it enables — no catalog entry is touched by this PR
  • No hardware run in this pull request was performed by an AI agent — nothing here ran against a monitor at all

Type of changes

  • 🐛 Bug fix
  • ✨ New feature
  • 🖥️ Catalog (a model or an input recorded, enabled or corrected)
  • 🔧 Refactoring
  • 📜 Docs
  • 🧰 CI / tooling / infra
  • Other (describe in Summary)

Before the first release

Hand setup, tracked in docs/release.md: the catalog label, the release environment's deployment-branch rule, the required status checks once these workflows have run, and — before a real (non-dry) release — leinardi/homebrew-tap plus the HOMEBREW_TAP_TOKEN secret. Cloudsmith and CLOUDSMITH_SERVICE_SLUG are already configured.

monmux had no CI workflow, no release pipeline and no published
artifact; docs/release.md said so in its first line. This turns that
design into files, and reconciles the document with what now exists.

One rule frames all of it: no job runs monmux against hardware. Nothing
in either workflow executes ddcutil, m1ddc or `monmux switch`, and the
test suite refuses to start a process by construction, so a runner needs
neither tool installed.

CI (.github/workflows/ci.yaml) runs the tests on a Linux and a macOS
runner, the cross targets, govulncheck, `goreleaser check`, and the
reviewdog hook jobs on pull requests. The linter also runs natively on
macOS: each backend sits behind a build tag, so a linter on one OS does
not analyse the other's files, and `make go-lint-cross` aborts in GOROOT
when it is older than the toolchain, which is a bad thing to have as the
only line of defence. Both jobs read the linter version out of
.pre-commit-config.yaml, so there is one source of truth.

Releases (.github/workflows/release.yaml) are fully automatic from one
workflow_dispatch, with an optional version and a dry-run switch. The
job order exists because tags here are immutable: the repository ruleset
blocks tag deletion with no bypass actors, so the familiar "delete the
tag on failure" cleanup cannot work, and everything that can fail
without a tag therefore happens before the tag exists. Everything after
the push is idempotent, so a re-run with the same explicit version
recovers a failed publish.

The version comes from the Conventional Commits since the last tag, via
svu, with one project rule on top: a change to models.yaml that adds or
moves a byte monmux can send is never a patch. A new tool,
internal/catalog/internal/generate/cmd/relnotes, compares the two
catalogs and prints both the release-notes section and the flag the
workflow enforces that rule with. A recorded entry counts, because
--unsafe-model reaches it through Model.UnsafeOperation.

What a release produces: static binaries for linux/amd64, linux/arm64
and darwin/arm64; tarballs; .deb and .rpm packages, published to
Cloudsmith as well as attached to the release; a Homebrew cask in
leinardi/homebrew-tap depending on the m1ddc formula; a checksum file
signed keyless with cosign; and a build provenance attestation.

Three deliberate decisions, each with its reasoning in docs/release.md:
a cask rather than a formula, because goreleaser hard-deprecated brews:
and a cask is macOS-only by nature; an unversioned ddcutil dependency,
because a hard 2.2 floor would make the package uninstallable on Debian
12 and Ubuntu 24.04 while preflight enforces the floor properly anyway;
and the quarantine-stripping postflight hook, without which Gatekeeper
refuses an unsigned binary outright.

The supply chain is pinned rather than floating: every action is a full
commit SHA, goreleaser and svu are exact versions, everything stamped
into a binary comes from the commit rather than the clock (so the
publish rebuild reproduces what the dry run inspected), and the
documented verification commands pin the signing workflow and its ref
rather than matching any workflow in the repository.

Guards on the release job, in the order they fire: it refuses to run
from any ref but main; it refuses when the publishing credentials are
missing; it refuses a version that is not higher than every released
version, except the recovery re-run of the version on HEAD; it is
serialized by a concurrency group that is never cancelled; and it checks
the remote one last time before pushing the tag.

Also: the issue and pull-request templates now ask monmux's questions -
redaction warnings, which catalog entry --unsafe-model named, and a
monitor report that becomes catalog evidence - and SECURITY.md says the
latest release is the supported version, which it could not say before.
@leinardi
leinardi merged commit 97be9f7 into main Sep 10, 2026
12 checks passed
@leinardi
leinardi deleted the feat/ci-release-pipeline branch September 10, 2026 16:47
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.

1 participant