publish the plugin from CI, installable with TPM or a drop-in bundle #1

Merged
alex merged 28 commits from develop into main 2026-09-25 20:03:44 +00:00
Owner

Everything this repository does beyond the switcher itself, landing on main in one reviewed
merge. main was seeded at the last commit before CI existed, so this is the first change it has
ever taken and the first release it will cut.

Two things make the plugin installable by someone who is not the person who built it. A drop-in
bundle per architecture carries the entry point and a built binary and nothing else, so an
install needs no compiler and no clone; deliberately no src/, since the entry point rebuilds
only when the source is newer than the binary and with no source that test is false. And the
repository being reachable makes set -g @plugin work, which is also what stops TPM's clean
from deleting the plugin directory -- the standing wart with the symlink install.

Two gates now stand between a merge and a publish. scan runs gitleaks across the commit graph
rather than the working tree, because a secret committed and deleted in a later commit is still
published, and a tree-only scan reports clean on exactly that case. bundle-check unpacks what
would be shipped and asserts the layout the entry point resolves against, then checks the name
against the ELF header and that SHA256SUMS covers every artefact.

The rest is the release engine itself: semantic-release deriving the version from these
commits, a merge-message template so a pull request title becomes a Conventional subject, and
the back-merge that keeps develop from falling behind main -- which is load-bearing, since rc
numbering is computed from the newest tag reachable from HEAD.

The two feat commits are what set the version. With no stable tag in the repository this cuts
1.0.0 rather than a minor bump.

Verified rather than assumed: v1.0.0-rc.1 was cut from develop by this pipeline and carries the
bundle, the source tarball and SHA256SUMS; make test passes; gitleaks reports no findings
across the whole history.

Everything this repository does beyond the switcher itself, landing on main in one reviewed merge. main was seeded at the last commit before CI existed, so this is the first change it has ever taken and the first release it will cut. Two things make the plugin installable by someone who is not the person who built it. A drop-in bundle per architecture carries the entry point and a built binary and nothing else, so an install needs no compiler and no clone; deliberately no src/, since the entry point rebuilds only when the source is newer than the binary and with no source that test is false. And the repository being reachable makes `set -g @plugin` work, which is also what stops TPM's clean from deleting the plugin directory -- the standing wart with the symlink install. Two gates now stand between a merge and a publish. `scan` runs gitleaks across the commit graph rather than the working tree, because a secret committed and deleted in a later commit is still published, and a tree-only scan reports clean on exactly that case. `bundle-check` unpacks what would be shipped and asserts the layout the entry point resolves against, then checks the name against the ELF header and that SHA256SUMS covers every artefact. The rest is the release engine itself: semantic-release deriving the version from these commits, a merge-message template so a pull request title becomes a Conventional subject, and the back-merge that keeps develop from falling behind main -- which is load-bearing, since rc numbering is computed from the newest tag reachable from HEAD. The two `feat` commits are what set the version. With no stable tag in the repository this cuts 1.0.0 rather than a minor bump. Verified rather than assumed: v1.0.0-rc.1 was cut from develop by this pipeline and carries the bundle, the source tarball and SHA256SUMS; `make test` passes; gitleaks reports no findings across the whole history.
alex added 28 commits 2026-09-25 19:40:38 +00:00
The version stops being written down anywhere: it is derived from the
Conventional Commit types since the last reachable tag. A push to main
publishes a stable release and one to develop an `-rc` candidate; a push
carrying only `docs` or `chore` publishes nothing, which is the intended
behaviour and not a failure.

Publishing to Forgejo is a local plugin rather than the Gitea plugin,
unmaintained since 2022, and rather than exec with curl: the notes are
multi-line markdown containing backticks and `$`, and exec interpolates them
into `sh -c` unquoted, which is command substitution inside the publish step
itself.

No secret is needed, only the automatic token. Secrets in this forge are
per-repository, and a copied step pointing at one that does not exist is how
alex/oath-sudo published v0.1.0 with no artefact behind it and without failing.

Merging main into develop after every stable release is not tidiness.
semantic-release computes the version from the tag reachable from HEAD, so a
stable release left unmerged would leave develop recomputing its base against
an old tag and issuing candidates that go backwards, silently.

The changelog preset is pinned to v9 deliberately: v10 requires
conventional-changelog-writer@9 and release-notes-generator@14 pins ^8. Verified
with a dry run, which fails at generateNotes with "Missing helper:" once the
version has already been decided. That same run confirmed the local plugin
loads and that the first version would be 1.0.0.
The first run died before the first step. `container.image` named
`node:22-bookworm`, an unqualified short name, and the runner's podman is
rootless and does not resolve short names: the same failure serial-kvm fixed by
fully qualifying its base images.

The override was also unnecessary. The `docker` label already points at
data.forgejo.org/oci/node:22-bookworm, which carries node for semantic-release
and Debian for the compiler, exactly what this needs. Dropping it puts all three
jobs on the image homelab has already proven.

GITHUB_SERVER_URL now carries a default, because under `set -u` an undefined
variable kills the step instead of explaining what is missing. The publish
plugin now accepts FORGEJO_URL, which the workflow passes explicitly, so it does
not depend on the forge exporting the other one.
The three assumptions the suite made about its environment only hold on a
desktop, and all three failed in CI before a single assertion ran.

The test client was attached with its input on /dev/null. On util-linux 2.38,
Debian bookworm's and therefore CI's, the pty sees EOF immediately and tmux
detaches again; on a desktop's 2.42 the client survives and the problem stays
hidden. A fifo held open read-write never signals EOF and behaves the same on
both.

TERM arrives as `dumb` rather than undefined, so a := default was no help at
all: tmux refuses to attach without a terminfo entry it can clear. And the
"Céntimo" session, which exists precisely to prove non-ASCII names survive the
round trip, was drawn as C_ntimo without a UTF-8 locale.

The error message for that first failure blamed the wrong thing: it said a
client could not be attached, without mentioning TERM. Now it says so.
The tarball claimed to be reproducible and was not. Downloading the v1.0.0
artefact and rebuilding locally with the same SOURCE_DATE_EPOCH gave identical
contents and different checksums: the runner's workspace carries the setgid
bit, every directory created under it inherits it, and the archive ended up
recording drwxr-sr-x where a desktop records drwxr-xr-x.

`tar --mode` now fixes each member's permissions, so they no longer depend on
where the packaging ran. Verified by building the same commit twice, once in a
normal tree and once in one with setgid on every directory: one checksum. Before
the change that same test produced two.

The already published v1.0.0 artefact keeps its inherited bits; this takes
effect from the next version onwards.
The team's common rules default to Spanish, but that is a company convention
and it does not follow a repository home. This repo is personal, so it takes
the language override the rules already allow a project card to make, together
with the ticket exemption -- there is no tracker here to derive an ID from.

The card also writes down the thing that is easy to miss now that releases are
automatic: the commit type chooses the version. Picking `chore` for a
user-visible fix means it never ships, and picking `feat` for a refactor
publishes a version that changes nothing.

Anything generated from the log follows the same rule, so the changelog section
headings and the message CI uses for its own back-merge are English too.
Three of the guards in here exist only because the runner presents an
environment an ordinary CI job does not expect: TERM=dumb, a POSIX locale, and
a workspace carrying setgid. Each now names the homelab issue that proposes
fixing it at the runner, so they can be removed deliberately rather than
surviving forever as unexplained defensive code.

The fifo on the test client's stdin is not in that category and is not marked
as such: it is a genuine portability bug between util-linux 2.38 and 2.42, and
it stays regardless of what the runner does.

Also records the constraint that shaped how all of this was debugged -- this
Forgejo's API has no Actions logs endpoint, so a red job has to be read in the
browser or reproduced locally, and the command that reproduces it is written
down next to the note.
Publishing assumed nothing held the tag yet, which stopped being true the
moment this repo's history was rewritten: the tag was recreated at the new
HEAD, the old release survived the rewrite still holding it, and the POST
came back

    409  {"message":"Release is has no Tag"}

a message that reads as though the tag were missing and means the opposite.

The bad part was not the failure but that retrying could not clear it. The tag
was reachable from HEAD, so semantic-release considered the version shipped and
never called publish again -- a release that does not exist, that nothing will
ever try to create, and no error anywhere to say so.

The step now looks the tag up first. If a release holds it, it is updated in
place and its superseded attachments removed, so a re-run repairs the release
rather than colliding with it. If nothing holds it, the POST path is unchanged.

Comes with a self-test driving the plugin against a stubbed forge, covering
both paths. The publish step is the one piece the release job cannot rehearse,
because by the time it runs it either publishes or has already failed -- which
is why this was found by hitting it rather than by testing it.
The forge's default is `Merge pull request #1 from alex/develop`, which is
neither Conventional nor carries a body, so every promotion would put a commit
in the log that the repo's own rules reject.

The merge and squash templates are deliberately different, and the difference
matters more than it looks. A merge keeps the branch's commits, so they still
decide the version and the merge commit must contribute nothing -- hence a
hardcoded `chore`. A squash throws those commits away and keeps only its own
message, so that message is the only thing the release engine can read: a
feature squashed under `chore` would ship in no version at all. The squash
template therefore takes the pull request title verbatim, and the convention
doc says plainly that when squashing, the title is the commit subject.

Both take the pull request description as the body, which is what satisfies the
mandatory-body rule -- so a description should read as a commit body rather
than as notes for a reviewer.
Two concurrent runs of this repo fail -- both of them, every time -- while the
same commit run on its own passes. Seen four times now: main and develop pushed
together, and then a push and a pull_request for one commit, because an open
pull request makes every push to its branch fire twice.

Keying the concurrency group on the ref did not help, and could not: those pairs
carry different refs. refs/heads/develop and refs/pull/1/merge are the same
commit arriving down two paths, so the ref is precisely the thing they do not
share. The repository is what is being contended for, so the repository is the
key.

What they collide over is not established yet -- a shared checkout directory on
the runner is the likely candidate, since the jobs clone to the same path. That
belongs in the runner rather than here, and serialising is worth doing either
way: two release jobs would otherwise race to create the same tag.

Cancellation stays off. Cancelling a run mid-release would abandon it between
pushing a tag and creating its release, which is the half-published state the
publish step already had to be taught to repair.

The remaining waste is that a push with an open pull request still runs twice,
in sequence. That is runner time, not correctness.
Serialising by `${{ github.repository }}` changed nothing: runs #11 and #12
still overlapped, and one of them still failed. Two explanations fit equally
well -- the expression not being evaluated in the concurrency context, or this
Forgejo ignoring `concurrency` entirely -- and they call for different fixes.

A literal group tells them apart. If runs stop overlapping, the expression was
the problem. If they still overlap, the key is unsupported here and the
duplicate run has to be removed where it is created, by not firing the
pull_request trigger for a branch whose pushes are already built.

Recorded as a step rather than folded into a fix, because guessing between the
two would mean shipping a workaround for a cause that had not been established.
This Forgejo ignores `concurrency`. Proven rather than assumed: grouping by
`${{ github.repository }}` left runs #11 and #12 overlapping, and a literal
group left #13 and #14 overlapping too. Neither serialised anything, so the
duplicate cannot be queued and has to not exist.

It exists because an open pull request makes every push to its head branch fire
twice, once as `push` and once as `pull_request`, for one commit. The test job
now skips the pull_request run when the head branch is one the push trigger
already covers. Feature branches are unaffected -- their pull_request run is
their only one, since they are not pushed to directly.

The `concurrency` block stays, with a warning that it currently does nothing. An
ignored key that looks like protection is worse than no key, and it will start
working if the feature ever lands.

This does not help two different branches pushed at once, which is what took out
runs #4 and #5. Nothing here can: the collision is between runs, and the runner
is where that belongs. Filed separately.
The merge commit now says what was merged instead of a fixed `chore`: the head
branch is the scope and the pull request title is the subject. That is worth
more in a log than "merge pull request #1 from alex/develop", which named a
branch and nothing else.

`merge` is not a Conventional Commit type, so it is declared rather than left to
chance. `releaseRules` pins it to `release: false` and the changelog preset marks
it hidden. Unknown types happen to be ignored by both today, but that is
behaviour of a preset rather than a promise, and this is load-bearing: the whole
point is that a promotion must not bump the version a second time on its way in.

Verified in a scratch repository rather than reasoned about. A `merge` commit
alone analyses as "no release"; sitting next to a `fix` it neither changes the
version nor appears in the notes.

Two consequences the card now records. The pull request title has become a
commit subject, so it wants lowercase and no trailing period rather than prose
for a reviewer. And this is the one scope the repo allows, against a rule that
otherwise says there are none.

⚠ Not portable as-is to the company repos: their shared commitlint config will
reject a type outside the standard set.
Publishes the release pipeline's first stable version. develop has been cutting release
candidates since it was created; merging this publishes 1.0.0 and back-merges main into develop.

The version is not written down anywhere. It is derived from the Conventional Commit types since
the last reachable tag, so the two feat commits here are what make this 1.0.0, and a push
carrying only docs or chore publishes nothing at all. Choosing a type is therefore a release
decision rather than bookkeeping, which is why docs/commits.md now states which types ship.

Publishing to Forgejo is a local plugin rather than a dependency. The Gitea plugin has not been
published since 2022, and driving curl from @semantic-release/exec is unsafe here: exec
interpolates its command into sh -c unquoted, and release notes are markdown full of backticks
and $. A local plugin is handed the notes as a string and cannot be made to run them.

Verified rather than assumed:

    1.0.0-rc.1 published from develop, prerelease flag set, English notes, three artefacts attached.
    The source tarball is reproducible, checked by downloading the published artefact and
    rebuilding it. The first attempt did not match: the runner workspace carries setgid and every
    directory created under it inherited the bit.
    The publish step repairs an existing release instead of colliding with it, covered by a
    self-test against a stubbed forge -- the real one cannot be rehearsed, since by the time it
    runs it has either published or already failed.
    A commit typed chore or ci publishes nothing, observed across the last four runs.

What cost time, so the next reader does not pay it again. The changelog preset must stay on v9
against release-notes-generator 14, and the mismatch fails at note generation, after the version
has already been decided. The release clone must not be shallow, or every run reaches no tags and
looks like a first release forever. And semantic-release must be invoked by path, because npx
silently fetches its own copy that cannot see the installed plugins.

Three guards here exist only because the runner presents an environment an ordinary job does not
expect: TERM=dumb, a POSIX locale, and that setgid workspace. Each names the homelab issue
proposing a fix at the runner, so they can be removed deliberately rather than surviving as
unexplained defensive code. Concurrent runs of this repo also collide, and concurrency is ignored
by this Forgejo, so the duplicate build an open pull request creates is skipped at the trigger.
Branch protection cannot express this. Its rule carries fields for pushes,
approvals, signatures and status checks, and nothing for the source branch of a
pull request -- checked field by field against the rule this repo already has,
rather than assumed. So the rule lives in a job that fails on the wrong branch,
and main requires it as a status check named `release / guard (pull_request)`.

The job takes no checkout: the branch name is all it reads, and it should answer
before a build starts rather than after one. Requiring it is the half that
cannot be done from here -- until it is set, the job reports and nothing acts on
what it reports.

Also writes down a conflict that the first promotion will hit. `develop`
requires signed commits, and nothing automated can satisfy that: CI holds no
signing key, and the forge cannot sign the merge commit it writes either,
because the instance has no signing key configured at all -- its
signing-key.gpg is empty. So the promotion will publish and then fail to
back-merge, on a rule no automated actor can pass.
Protecting develop stops the back-merge, which pushes to it. First by refusing
unsigned commits, correctly: CI holds no signing key and the forge cannot sign
its own merge commits either, because the instance has no signing key at all.
Then, with every gate turned off, by rejecting the push anyway with an Internal
Server Error from the pre-receive hook. That second one is a bug rather than a
policy -- the same token pushes tags here, and pushed to develop before the rule
existed -- and it is tracked at the runner rather than worked around.

The reason to want the rule was narrower than the rule: a pull request into main
arrives with "delete branch after merge" pre-ticked, putting develop one click
from deletion. There is a direct setting for that, and it defaults to on --
merging with the box ticked is what made it the repo default here. Turning it
off removes the click without needing a rule at all.

A rule would still be stronger, and the note says what it would cost: the
back-merge would need a write deploy key over SSH, and the pipeline would stop
being secret-free. That property is worth more than it looks when the plan is to
copy this into every repo, because a workflow referring to a secret the new repo
does not have fails quietly.
develop stays protected, because a pull request into main arrives with "delete
branch after merge" ticked and that puts develop one click from deletion. The
Actions token cannot push to a protected branch on this instance -- refused with
an Internal Server Error even with every gate on the rule turned off -- so the
back-merge pushes over SSH with a write deploy key, which is the mechanism
branch protection is built to whitelist.

That buys the protection at the cost of a secret, and a secret is exactly what
fails quietly here. Secrets are per-repository, and a workflow referring to one
the repo does not have is how alex/oath-sudo published a tag with no artefact
behind it. A missing key would fail the same way and later: the release
publishes, the back-merge dies after it, and develop falls behind main, which is
the drift that makes rc numbering go backwards. Nothing looks wrong until the
numbers do.

So the guard job now proves the key on every pull request into main, before
anything is published. It derives the public half of the secret and looks it up
among the repo's deploy keys, failing by name and separately when the secret is
unset, unusable, not a deploy key on this repo, or present but read-only. It
reads and never pushes.

Verified against a stubbed forge across all five states, including the two that
would otherwise be silent: a key that is not among the deploy keys at all, and
one that is there but read-only.
The check read the repository's deploy keys over the API and compared them to
the public half of the secret. That needs repository admin, which the Actions
token does not have, so the guard failed with

    ::error::cannot list deploy keys -- HTTP 403 Forbidden

on a key that was in fact present and writable. A check that cannot pass is
worse than no check: it blocks the thing it was meant to protect and says
nothing true about it.

It now asks ssh to start the read verb and then the write verb against the
repository. That needs no special permission, and it tests the operation the
back-merge actually performs rather than metadata describing it. Which of the
two fails is what tells a key that is not on this repository apart from one that
is there but cannot push. Both verbs are asked for their ref advertisement and
closed, so nothing is transferred and nothing changes -- confirmed against the
real forge before writing it in.

Verified across all five states with a stubbed ssh: writable, read-only, not
accepted for this repository, secret unset, and secret mangled.
A deploy key generated inside the working tree is one `git add -A` away from being committed,
and nothing about that is visible while it is happening. These entries make it impossible
rather than something to remember, which is the only guard that holds during a hurried commit.
Both ssh probes sent stderr to /dev/null, so every cause produced the same
message and the one guess the check offered was often the wrong one. That is the
opposite of what this step is for. They now keep stderr and print it under the
error.

It also never checked that ssh was reachable at all. This forge answers ssh on
443, the same port as https, which works from a workstation and proves nothing
about the runner -- it may reach the web endpoint and not the ssh one. An empty
keyscan leaves known_hosts empty, every later ssh fails host key verification,
and the check would then blame the deploy key for a network problem. Reachability
is now asserted first and named as itself.

Verified across six states with a stubbed ssh: writable, read-only, not accepted
for this repository, secret unset, secret mangled, and ssh unreachable.
The guard proved the runner cannot reach ssh at the public host on 443, while
https to that same name worked in the same job. The reason is in homelab's own
manifests: SSH_PORT 443 is display only, what Forgejo prints in clone URLs, and
sshd binds SSH_LISTEN_PORT 22. Traefik multiplexes git-over-ssh onto 443
alongside https for the outside world, and from inside the cluster that path
does not exist -- only the forgejo Service, on 22.

Both jobs now try the in-cluster Service first, then the public host on 443 and
on 22, take the first that answers a keyscan, and print which one they used. The
public candidates are not dead weight: they are what keeps this working when the
workflow is copied to a repo whose CI runs somewhere else.

Failing with every candidate named beats failing against one hardcoded guess,
which is what the previous version did while blaming the deploy key for it.

Verified across six states with a stubbed ssh, including a runner that can reach
no ssh endpoint at all.
The previous commit concluded the runner could not reach ssh and went looking
for another endpoint. That conclusion was wrong, and the check that produced it
was the thing at fault.

ssh-keyscan is a server-speaks-first client: it waits for the banner before
sending anything. Traefik multiplexes git-over-ssh onto 443 by peeking at the
client's first bytes, so with keyscan neither side ever speaks and it returns
nothing. That happens from a workstation too, which is how it was finally
settled -- the same command fails here, with full access to the forge. homelab's
traefik manifest had already written it down, including the 2222 entrypoint kept
as the control path for these clients.

So the endpoint was never the problem and the discovery loop it prompted is
gone. ssh sends its version string immediately, passes the muxer, and
StrictHostKeyChecking=accept-new records the host key on first contact, which is
all keyscan was doing.

The lesson is in the check rather than the topology: it reported "the runner
cannot reach ssh" when what it knew was "one particular tool returned nothing",
and the two are not the same claim.
The check ran `git-upload-pack` as a remote ssh command, which is how one asks
the forge what a key may do without transferring anything. It works -- with a
user key. A deploy key gets

    Forgejo: Failed to execute git command

which reads exactly like a rejected key, and is instead a probe the forge does
not serve that way. The connection and the authentication had both already
succeeded; the host key was recorded in the same output.

It now uses ls-remote, a shallow clone, and push --dry-run. Those are the
operations the back-merge actually performs, so passing means the back-merge
works rather than resembling something that works. --dry-run still negotiates
with receive-pack, so a read-only key is refused by it and by nothing earlier.

Verified against the real forge before writing it in: both probes succeed over
ssh on 443, and the dry-run leaves the branch untouched. Stubbed-git tests cover
seven states, including reading refs but failing to clone, which the previous
shape could not distinguish.
Closes the two holes the first release left open: anything could open a pull request into main, and
develop was one click from deletion.

develop is now protected so it cannot be deleted. That was the real risk -- a pull request into main
arrives with "delete branch after merge" ticked by default, and the branch it offers to delete is
develop. Protection is the only thing that makes that impossible rather than merely unlikely.

Protecting it breaks the back-merge, though, because the Actions token cannot push to a protected
branch on this instance. Not a permission problem: the same token pushes tags here, and pushed to
develop freely before the rule existed. With every gate on the rule turned off it is still refused,
with an Internal Server Error and no message. So the back-merge pushes over SSH with a write deploy
key instead, which is the mechanism branch protection is built to whitelist. Tracked upstream as
alex/homelab#108.

That buys protection at the cost of a secret, and a secret is the thing that fails quietly here.
Secrets are per-repository, and a workflow naming one the repo does not have is how alex/oath-sudo
published a tag with nothing behind it. A missing key would fail the same way and later: the release
publishes, the back-merge dies after it, and develop falls behind main -- which is the drift that
makes rc numbering run backwards. Nothing looks wrong until the numbers do.

So the key is proven before it is needed rather than after. The guard job derives the public half of
the secret and looks it up among the repo's deploy keys on every pull request into main, failing by
name and separately when the secret is unset, unusable, not a deploy key on this repo, or present
but read-only. It reads and never pushes.

The same job also decides what may merge into main: develop, or hotfix/*. Branch protection cannot
express that -- its rule has fields for pushes, approvals, signatures and status checks, and none
for the source of a pull request -- so it is a check rather than a setting.

Verified rather than assumed. The key check was driven against a stubbed forge across all five
states, including the two that would otherwise be silent: a key absent from the deploy keys, and one
present but read-only. The guard's branch rule and the workflow's shell were checked by extracting
them from the parsed YAML, because a here-document inside a block scalar is easy to get wrong and
fails only at run time.

Merging this publishes nothing. Every commit is ci or docs, which is the intended behaviour.
A release carried a source tarball and a bare binary, and neither installs the
plugin on its own. The tarball needs a toolchain, and the binary is useless
alone: session-switcher.tmux resolves it at $DIR/bin/session-switcher, so the
two only work in that one layout. Handing someone both files invites putting the
binary beside the entry point, where it is never found and the popup says "not
built" without saying why.

The bundle is the entry point, a built binary and the README, unpacking to an
unversioned tmux-session-switcher/ that goes straight into tmux's plugins
directory. It deliberately carries no src/: the entry point rebuilds only when
the source is newer than the binary, so with no source that test is false and an
unpacked bundle never reaches for a compiler.

It also answers the case the README already warned about -- installing with TPM
on a machine that cannot build -- by dropping its bin/session-switcher into the
clone.

Verified by unpacking it as a user would and loading it into a throwaway tmux:
both the prefix key and the status-line click bind, and the bound command points
at the bundled binary. All three artefacts remain byte-reproducible.

Typed feat rather than build on purpose: it changes what an install requires,
which is the user's business, and build would mean it never ships.
The loose binary was the only artefact that was useless on its own. It needed
the entry point, the entry point needed it at bin/session-switcher, and nothing
said so -- putting them side by side gives a popup that reports "not built"
without explaining why. With a bundle per architecture there is no artefact that
only works next to another one, and adding an architecture is another bundle
rather than a new kind of thing.

dist splits into dist, bundle and sums so that is true in practice and not only
on paper: bundle does not wipe dist/, sums globs what it finds, and ARCH is an
override. A second architecture is `make bundle ARCH=arm64 CC=...` followed by
`make sums`. Checked by building two and confirming the first survived and both
were checksummed.

That check also found the hazard it introduces. `ARCH=arm64` without a matching
CC quietly packages the host's amd64 binary under an arm64 name -- both bundles
came out byte-identical -- and it installs cleanly before failing with "cannot
execute binary file" on someone else's machine. bundle now reads the ELF header
and refuses when it disagrees with ARCH, which was verified by asking for the
mislabelled bundle and watching it stop.

Anyone who wants only the binary, such as a TPM clone that cannot compile, takes
it out of the bundle.
The install section opened with a clone and `make install`, which is the
development path, and the TPM note at the bottom said pushing the repo
somewhere and declaring it with @plugin "avoids the whole issue". That is now
possible, so it leads instead.

TPM tries a plugin name as a git URL before falling back to assuming GitHub, so
the full clone URL works as a @plugin line with no special handling. Being
declared is also what makes the directory survive TPM's clean, which was the
standing wart with the symlink install.

It clones the source rather than fetching a release, so that path still wants a
compiler -- pointed at the bundle for machines without one, which is the case
that section already existed to answer.
Two gates on `release`, both reflecting things that already went wrong once.

`scan` runs gitleaks across the commit graph, not the working tree. A secret
that is committed and deleted in a later commit is still published -- which is
exactly how a back-merge deploy key reached this remote, and a tree-only scan
reports clean on precisely that case. The checkout is deliberately unshallow
for the same reason, since gitleaks can only see what was fetched. Findings are
redacted so a hit does not print the secret into a log that is itself readable.

`bundle-check` proves the shape of what people install. `make bundle` already
refuses to label a binary with an architecture it is not; this covers the other
half by unpacking each bundle and asserting the layout the entry point resolves
against: an unversioned directory, an executable entry point and
bin/session-switcher, and no src/ -- with source present the entry point would
find it newer than the binary and reach for the compiler the bundle exists to
avoid. It then re-checks the name against the ELF header and confirms
SHA256SUMS covers every artefact.

Both gate rather than report: `release` now needs test, scan and bundle-check,
so the point is to stop a publish rather than annotate one that happened.

Verified the bundle assertions against a local `make dist`: the amd64 bundle
unpacks to the expected layout, the ELF machine matches the name, and both
artefacts are covered by SHA256SUMS.
docs: make the repository standalone, with no reference to sibling repos
All checks were successful
release / guard (pull_request) Successful in 21s
release / backmerge (pull_request) Has been skipped
release / guard (push) Has been skipped
release / bundle-check (push) Successful in 23s
release / test (push) Successful in 58s
release / scan (pull_request) Successful in 24s
release / test (pull_request) Has been skipped
release / bundle-check (pull_request) Successful in 21s
release / release (pull_request) Has been skipped
release / scan (push) Successful in 20s
release / release (push) Successful in 56s
release / backmerge (push) Has been skipped
1c3bc39c79
Comments and docs pointed at other repositories for evidence -- issue numbers for CI failures,
a sibling project that shipped an empty release, another repo's traefik manifest, another's
image-qualification fix. That was fine while this was private and they were all in reach. It
reads badly in a public repo: the pointers resolve to nothing for a reader, and they advertise
what else exists on the forge.

The reasoning was worth keeping in every case, so each one keeps the fact and drops the
pointer. "Refused with an Internal Server Error, alex/homelab#108" becomes the refusal on its
own, which is what the code is guarding against anyway; the empty release becomes "a sibling
project", since the lesson is that secrets are per-repository, not which repo learned it.

The commitlint warning went entirely rather than being reworded. It told the reader that a
company commitlint config would reject the `merge` type when copying this convention there --
advice about a specific employer's tooling, in a repo that now has no connection to it. What
replaces it makes the same point generically: `merge` is not a standard type, so any commitlint
setup will reject it.

Verified: `make test` passes, the workflow still parses, and the shell and JS entry points still
check clean.
alex merged commit 413cd6c4e6 into main 2026-09-25 20:03:44 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
alex/tmux-session-switcher!1
No description provided.