From a157fed8f27fa57a892b8e3ae25ee8e264953465 Mon Sep 17 00:00:00 2001 From: jcoffey <51408202+jcoffey-dev@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:12:20 -0700 Subject: [PATCH] Release weekly, and publish an image (#5) INBUXA Admin had CI and nothing after it: twelve tags inherited from upstream's numbering, no GitHub releases at all, and no image. Deploying it meant building the tree yourself. This adds the three workflows ihasmail already runs -- weekly release, publish, prune -- and the Dockerfile they need. Monday 09:37 UTC, and nothing on a quiet week. Staggered twenty minutes behind ihasmail-inbuxa's and twenty ahead of the server's, so three releases do not compete for runners and a bad Monday names one repository rather than three. The version is the difference from ihasmail. ihasmail derives its version from the commit it builds, so its release only reads. INBUXA Admin keeps its version in inbuxa-version.json, so the release writes it: the bump is committed to main and the tag names that commit. The tree a tag points at therefore reports the version the tag claims, which a tag placed beside an unbumped file cannot promise. The bump is written with a JSON parser rather than sed, because a version substituted into JSON as a string is one stray quote from a file nothing can read. The image is nginx serving the built files and nothing else -- the interface talks to the mail server from the browser, never from the container. It is built from source in the image rather than copied from dist/, so an image always matches the commit it claims. One image serves any installation: API_BASE_URL writes the `` tag that README already documents as the deploy-time way to point the interface at its server. Set nothing and the container still starts, for a build that was given VITE_API_BASE_URL instead. Two things the smoke test found rather than review. Unprivileged nginx runs as uid 101, so the copied files are chowned to it or the tag can never be written. And the directory stays root's, so the entrypoint writes back through the existing file instead of `sed -i`, which replaces the file and needs to create a temp file in the directory. Verified by running it: the tag lands, a deep route falls back to index.html, hashed assets come back immutable while index.html is no-cache, it runs as uid 101, and it starts with no API_BASE_URL set. --- .github/workflows/cleanup.yml | 69 ++++++++++++ .github/workflows/publish.yml | 194 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 182 +++++++++++++++++++++++++++++++ Dockerfile | 31 ++++++ docker/entrypoint.sh | 38 +++++++ docker/nginx.conf | 25 +++++ 6 files changed, 539 insertions(+) create mode 100644 .github/workflows/cleanup.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release.yml create mode 100644 Dockerfile create mode 100644 docker/entrypoint.sh create mode 100644 docker/nginx.conf diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml new file mode 100644 index 0000000..7aa4fca --- /dev/null +++ b/.github/workflows/cleanup.yml @@ -0,0 +1,69 @@ +# Prune old image versions from GHCR. +# +# Releases are kept forever -- they carry no assets and their generated notes +# are this project's only changelog, so deleting one destroys history that +# cannot be reconstructed for nothing saved. Images are the opposite: a +# multi-arch build a week, and the by-digest push in publish.yml leaves two +# untagged per-architecture manifests behind each time on top of the tagged +# index. Those accumulate and nobody wants fifty of them. +# +# THE FOOTGUN: the obvious tool for this -- delete-package-versions with +# `delete-only-untagged-versions` -- will happily delete the per-architecture +# manifests that a multi-arch tag points *at*, because they are untagged by +# design. Nothing appears to break: the tag still exists, and pulls simply +# start failing for one architecture. This action understands manifest lists +# and will not orphan a retained index, and `validate` re-checks every +# multi-arch manifest against the registry afterwards. +# +# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show +# exactly what would be deleted without rebuilding and re-pushing an image to +# find out. +name: Prune images + +on: + workflow_call: + inputs: + dry_run: + type: boolean + default: false + workflow_dispatch: + inputs: + dry_run: + description: "List what would be deleted, delete nothing" + type: boolean + default: true + +jobs: + prune: + runs-on: ubuntu-latest + permissions: + packages: write + steps: + # The only third-party action here that is not published by GitHub or + # Docker, and the one with the most to lose: it is handed + # `packages: write` and its whole job is deletion, so a ref repointed at + # something else -- by a compromise or a mistake upstream -- is a bad + # day. It was pinned to a commit long before the rest of them were. + - uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 + with: + owner: inbuxa + package: inbuxa-admin + token: ${{ secrets.GITHUB_TOKEN }} + # Ten weekly releases is roughly a quarter of history, which is more + # than enough to roll back to and far less than the year's worth that + # would otherwise pile up. Older *releases* stay either way; this + # only removes the images. + keep-n-tagged: 10 + # Belt and braces on top of the action's own manifest awareness: + # `latest` is never a candidate for deletion under any counting. + exclude-tags: latest + delete-untagged: true + # Sweeps the wreckage of a half-failed run: an index whose platform + # images did not all land, and referrers whose parent is gone. + delete-partial-images: true + delete-orphaned-images: true + # Checks every remaining multi-architecture manifest still resolves + # in the registry. This is the step that would catch the footgun + # above rather than leaving a reader to discover it on `docker pull`. + validate: true + dry-run: ${{ inputs.dry_run }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..105aac8 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,194 @@ +# Publish the container image to GHCR. +# +# The README and the docs site have told people to run +# `ghcr.io/inbuxa/inbuxa-admin:latest` for a long time, and nothing ever +# pushed it: `docker pull` answered `denied`, because the package did not +# exist. This is the workflow that makes those instructions true. It is also +# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid +# both install by pulling an image and neither builds from source. +# +# FIRST RUN: a package GHCR creates for the first time is **private**, even in +# a public repository, and an anonymous `docker pull` will still answer +# `denied`. Nothing in a workflow can change that -- the visibility is set once +# by hand under the package's settings, and until it is, this looks like it +# worked while the docs stay just as wrong as before. Check with a logged-out +# pull, not with one from a machine that has credentials. +# +# Two architectures, each built on its own native runner rather than under +# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through +# instruction translation, which takes tens of minutes and occasionally runs +# out of memory; `ubuntu-24.04-arm` is free for public repositories and does +# the same work at native speed. The cost is the by-digest dance below: each +# runner pushes an untagged image, and a final job joins the two digests into +# one multi-arch tag. +name: Publish image + +on: + release: + types: [published] + # Callable, so release.yml can build the release it just cut. This is not a + # stylistic choice: a release created with GITHUB_TOKEN does **not** raise a + # `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 expected this file to notice would silently never publish. The + # alternatives are a personal access token kept as a secret, or calling the + # workflow directly. This is the one that needs no credential. + workflow_call: + inputs: + ref: + description: "Tag, branch or SHA to build" + required: true + type: string + tag_latest: + description: "Also move :latest to this build" + type: boolean + default: false + # Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then + # orphans can be neither rerun nor canceled, and this workflow otherwise + # only fires on a release -- which is not something to cut twice because a + # runner died. `ref` also allows publishing an image for a tag that predates + # this workflow, which is how the first one gets built. + workflow_dispatch: + inputs: + ref: + description: "Tag, branch or SHA to build" + required: true + default: main + tag_latest: + description: "Also move :latest to this build" + type: boolean + default: false + +env: + # Hardcoded rather than derived from github.repository, which would have to + # be lowercased to be a legal registry path. This is the string the docs name. + IMAGE: ghcr.io/inbuxa/inbuxa-admin + +jobs: + # The version is read once and handed to both builds, so the two + # architectures cannot disagree about what they are. It comes from the file + # the interface itself reads, which the weekly release commits before this + # runs -- so the image is tagged with the version it will report. + version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref || github.ref }} + - id: v + run: | + set -euo pipefail + V="$(jq -er .version inbuxa-version.json)" + # A date version carries nothing a Docker tag objects to, so there is + # no second, sanitized form of it here. + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "version $V" + + build: + needs: version + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push by digest + id: push + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: ${{ matrix.platform }} + # Attestations are off deliberately: they add manifests of their own + # to the index, and `imagetools create` below expects the two entries + # it pushed rather than four. + provenance: false + sbom: false + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + - name: Save the digest + run: | + mkdir -p /tmp/digests + # The prefix is stripped here and put back in the merge job, so the + # filename is the bare hash. Leaving it on produces + # `image@sha256:sha256:...` when the reference is rebuilt. + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # One artifact per platform; the merge job globs them back together. + name: digest-${{ strategy.job-index }} + path: /tmp/digests/* + retention-days: 1 + if-no-files-found: error + + # Joins the per-architecture digests into a single tagged manifest, so + # `docker pull ghcr.io/inbuxa/inbuxa-admin:` resolves on both. + publish: + needs: [version, build] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Create the manifest + run: | + # Arrays rather than a string: the tags and the digest references + # have to reach docker as separate arguments, and building them by + # word-splitting an unquoted variable is the version of this that + # breaks the day a value contains a space. + tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}") + # :latest follows real releases only. A prerelease that moved it + # would hand every `:latest` deployment an unfinished build, and a + # dispatch run has to ask for it on purpose. + if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then + tags+=(-t "${IMAGE}:latest") + elif [ "${{ inputs.tag_latest }}" = "true" ]; then + tags+=(-t "${IMAGE}:latest") + fi + refs=() + for f in /tmp/digests/*; do + refs+=("${IMAGE}@sha256:$(basename "$f")") + done + echo "tags: ${tags[*]}" + echo "refs: ${refs[*]}" + docker buildx imagetools create "${tags[@]}" "${refs[@]}" + - name: Show what landed + run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}" + + # Runs only after a successful publish, because that is the only moment the + # package grows. See cleanup.yml for why this is not the obvious one-liner. + prune: + needs: publish + permissions: + packages: write + uses: ./.github/workflows/cleanup.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..825bec3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,182 @@ +# Cut a release once a week, but only if there is something in it. +# +# It does nothing on a quiet week. A release with no commits in it is worse +# than no release: it moves `:latest` to an identical build, spends a version +# number, and mails everybody watching the repository about nothing. +# +# Unlike ihasmail, whose version is derived from the commit it builds, INBUXA +# Admin keeps its version in inbuxa-version.json. So this writes it: the bump +# is committed to main, and the tag names that commit. The commit is the +# release, which means the tree a tag points at always reports the version the +# tag claims -- something a tag placed beside an unbumped file cannot promise. +name: Weekly release + +on: + schedule: + # Mondays, 09:37 UTC -- twenty minutes behind ihasmail-inbuxa's, twenty + # ahead of the server's. Staggered rather than simultaneous so three + # releases do not compete for runners, and so a bad Monday names one + # repository instead of three. GitHub runs scheduled jobs best-effort and + # can delay a run considerably, so the exact minute is not a promise; the + # odd minute keeps it off the crowded top of the hour. + # + # Note also that GitHub disables scheduled workflows in a repository with + # no activity for 60 days, which is worth checking for before assuming + # this file is broken. + - cron: "37 9 * * 1" + workflow_dispatch: + inputs: + dry_run: + description: "Work out what would be released, then stop" + type: boolean + default: false + +# One at a time. Two overlapping runs would race to write the same version and +# create the same tag, and the loser fails noisily for a reason that has +# nothing to do with the code. +concurrency: + group: weekly-release + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should_release: ${{ steps.decide.outputs.should_release }} + version: ${{ steps.decide.outputs.version }} + tag: ${{ steps.decide.outputs.tag }} + previous: ${{ steps.decide.outputs.previous }} + count: ${{ steps.decide.outputs.count }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + - id: decide + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + # The newest published release, or empty on a repository that has + # never had one -- in which case everything counts as new. Drafts are + # excluded: an unpublished draft is not a release anybody has, so + # counting from it would hide commits that have never shipped. + previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')" + # A tag named by a release is normally present after a full checkout, + # but a release can outlive its tag. Falling back to the whole + # history is the safe direction to be wrong in: it over-counts, which + # cuts a release that was due anyway, where under-counting would skip + # one that was. + if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then + count="$(git rev-list --count "${previous}..HEAD")" + else + count="$(git rev-list --count HEAD)" + fi + + # INBUXA's version is the date, as the rest of the family does it: + # YYYY.M.D, unpadded. A second release on one day takes a `.N` + # suffix, counting from 2, which is why this asks the tags rather + # than assuming today is free. + today="$(date -u +%Y.%-m.%-d)" + version="$today" + n=2 + while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do + version="${today}.${n}" + n=$((n + 1)) + done + + should_release=true + reason="" + if [ "$count" -eq 0 ]; then + should_release=false + reason="no commits since ${previous}" + fi + + { + echo "should_release=$should_release" + echo "version=$version" + echo "tag=v${version}" + echo "previous=$previous" + echo "count=$count" + } >> "$GITHUB_OUTPUT" + + # Written to the run summary so a skipped week reads as a decision + # rather than as a workflow that quietly did nothing. + { + echo "### Weekly release" + echo + if [ "$should_release" = "true" ]; then + echo "Releasing **v${version}** — ${count} commit(s) since ${previous:-the beginning}." + else + echo "Nothing to release: ${reason}." + fi + } >> "$GITHUB_STEP_SUMMARY" + + cut: + needs: check + if: needs.check.outputs.should_release == 'true' && !inputs.dry_run + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + sha: ${{ steps.bump.outputs.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + - id: bump + env: + VERSION: ${{ needs.check.outputs.version }} + run: | + set -euo pipefail + + # Rewritten with a JSON parser rather than sed: the file is small and + # the shape is known, but a version written into JSON by string + # substitution is one stray quote away from a file nothing can read. + node -e ' + const fs = require("fs"); + const f = "inbuxa-version.json"; + const j = JSON.parse(fs.readFileSync(f, "utf8")); + j.version = process.env.VERSION; + fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n"); + ' + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add inbuxa-version.json + git commit -m "Version ${VERSION}" + git push origin HEAD:main + + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + args=(--target "${{ steps.bump.outputs.sha }}" + --title "INBUXA Admin ${{ needs.check.outputs.version }}" + --generate-notes) + # Bound the notes to what is actually new. Without a start tag the + # generator reaches back to whatever it decides is previous, which on + # a repository carrying older tag shapes -- this one still has the + # inherited v1.0.x tags -- is not always the last release. + if [ -n "${{ needs.check.outputs.previous }}" ]; then + args+=(--notes-start-tag "${{ needs.check.outputs.previous }}") + fi + gh release create "${{ needs.check.outputs.tag }}" "${args[@]}" + + # Called rather than left to the `release` trigger on purpose: see the note + # at the top of publish.yml. A release created with GITHUB_TOKEN raises no + # event, so without this the tag would exist and no image would follow it. + publish: + needs: [check, cut] + permissions: + contents: read + packages: write + uses: ./.github/workflows/publish.yml + with: + ref: ${{ needs.cut.outputs.sha }} + tag_latest: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e56f94e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# INBUXA Admin as an image: the built interface and a static server for it. +# +# The interface is static files and nothing else -- it talks to the mail server +# from the browser, never from here -- so this is nginx with a SPA fallback and +# no back end of its own. +# +# It is built here rather than copied from `dist/`, which is committed for the +# convenience of people serving the tree directly. An image built from a stale +# `dist/` would be a build nobody can reproduce from the commit it claims. +FROM docker.io/node:26-alpine AS build +WORKDIR /build +# The lockfile alone first, so a commit that changes no dependency reuses this +# layer instead of resolving the tree again. +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +# The version comes from inbuxa-version.json, which the release commits before +# this builds, so there is nothing to pass in here. +RUN npm run build + +FROM docker.io/nginxinc/nginx-unprivileged:1.29-alpine +# Unprivileged nginx, which runs as uid 101 and cannot bind 80. 8080 is the +# port it listens on and the one to publish. +EXPOSE 8080 +# Owned by the nginx user (uid 101 in this image), not root: the entrypoint +# below rewrites index.html, and cannot if the file is root's. The directory +# stays root's, which is why the entrypoint writes through the file rather +# than replacing it. +COPY --from=build --chown=101:101 /build/dist /usr/share/nginx/html +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --chmod=0755 docker/entrypoint.sh /docker-entrypoint.d/40-api-base-url.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..6fc74de --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Point this deployment at its mail server, at container start. +# +# INBUXA Admin reads `` from index.html when it was +# not given VITE_API_BASE_URL at build time, which is what lets one image serve +# any installation. This writes that tag from API_BASE_URL. +# +# nginx runs the files in /docker-entrypoint.d before starting, so this happens +# once per container and the served index.html is already correct. +set -eu + +[ -n "${API_BASE_URL:-}" ] || exit 0 + +html=/usr/share/nginx/html/index.html +[ -f "$html" ] || exit 0 + +# Escaped for sed's replacement, where & and the delimiter are special. A URL +# containing either is unlikely, but a silently mangled API address is the kind +# of failure that looks like the server being down. +esc=$(printf '%s' "$API_BASE_URL" | sed 's/[&|]/\\&/g') +tag="" + +# Written back through the existing file rather than with `sed -i`, which +# replaces it and so needs to create a temp file in the directory. That +# directory belongs to root in this image and nginx does not run as root, so +# in-place editing is the one thing that cannot work here. The file itself is +# ours, and truncating it is enough. +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT + +if grep -q ']*>|$tag|" "$html" > "$tmp" +else + sed "s||$tag|" "$html" > "$tmp" +fi +cat "$tmp" > "$html" + +echo "api-base-url set to $API_BASE_URL" diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..1e337c4 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,25 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # A single-page app: every path that is not a file on disk is the app's own + # route, and the app is what decides what it means. Without this, a reload + # anywhere but the root is a 404 from nginx. + location / { + try_files $uri $uri/ /index.html; + } + + # Hashed filenames, so the content at a given name never changes. index.html + # is deliberately not in here: it is the file that names the current hashes, + # and a cached one pins a deployment to the build it replaced. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location = /index.html { + add_header Cache-Control "no-cache"; + } +}