A tag is a mutable pointer. `actions/checkout@v7` is whatever the
publisher last moved v7 to, so using one is not trusting the version that
was reviewed -- it is trusting every future version, including whatever
is pushed by whoever compromises the publisher's account. That is the
shape of the tj-actions/changed-files compromise: no repository changed a
line, the tags moved underneath them, and the action began dumping runner
memory to the logs.
Each `uses:` now carries the full 40-character SHA with its release in a
trailing comment. Read the comment for the version; the SHA is what runs.
Dependabot already covers github-actions weekly and updates both halves
together, so keeping current costs nothing.
The dataaxiom cleanup action was already pinned -- it is handed
`packages: write` and deletes things, so it was worth doing early -- and
only picks up the trailing-version convention here. Its comment loses the
"rather than a moving major tag" framing, which is no longer what makes
it different from its neighbors now that they are all pinned too.
The two `uses: ./.github/workflows/...` entries are local paths, not
actions: they always resolve within the commit already running and there
is no SHA to pin.
The 09:00 UTC schedule competes for GitHub's busiest slot. The first
scheduled run started almost six hours late, and on 2026-09-14 no run had
started four and a half hours in, so that week was cut by hand. 09:17 is
still best-effort, but no longer queues behind every on-the-hour schedule.
Three pins and a types package all described Node 22, and moving any one
of them alone puts the build somewhere the others are not: @types/node
on its own would typecheck against APIs the runtime does not have, and
the base image on its own would ship a major CI never exercised. So
ci.yml, publish.yml, release.yml, both Dockerfile stages and
@types/node move in one change.
Worth knowing before this is deployed: 26 is Current, not LTS. node:26-
alpine reports lts=none, where 24-alpine is Krypton and the 22-alpine we
are leaving is Jod. 26 is due to become Active LTS in October. Nothing
here needs 26 over 24 -- the pins are a single number if the LTS line is
preferred.
engines stays at >=20.19, which is the floor for running ihasmail rather
than the version we build it on; the README's recommendation follows CI
to 26.
Checked on the runtime, not just in CI: the image builds on 26-alpine,
starts, and answers /api/health, and the login, SSE and body-carrying
POST checks from the node-server upgrade pass against a server on
26.8.1.
Publishing on release is the right trigger only if releases happen. They had
not: main ran 184 commits ahead of the last one, so `:latest` described a
build that neither the demo, nor production, nor anyone building from source
was running. This is the part that makes the trigger true without anyone
having to remember.
Mondays at 09:00 UTC. A run with no commits since the last release does
nothing at all -- an empty release moves `:latest` to an identical build,
spends a version number and mails every watcher about nothing. The decision is
written to the run summary either way, so a quiet week reads as a decision
rather than as a workflow that failed silently.
The awkward part is that a release created with GITHUB_TOKEN raises no
`release` event: GitHub refuses to let a token trigger another workflow, to
stop a workflow looping on its own output. A scheduled job that cut a release
and left publish.yml to notice would tag the commit and never build an image,
which is the kind of failure that looks like success. So publish.yml gains a
`workflow_call` trigger and this calls it directly. The alternative was a
personal access token kept as a secret; this needs no credential.
Two smaller decisions. Drafts are excluded when looking for the last release,
because an unpublished draft is not a release anybody has and counting from it
would hide commits that never shipped. And if the tag a release names has
gone, the count falls back to the whole history -- over-counting cuts a
release that was due anyway, where under-counting skips one that was not.