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"; + } +}