diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml new file mode 100644 index 0000000..dd46cf1 --- /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-server + 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..1681796 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,198 @@ +# Publish the container image to GHCR. +# +# The README and the docs site have told people to run +# `ghcr.io/inbuxa/inbuxa-server: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-server + +jobs: + # The version is read once and handed to both builds, so the two + # architectures cannot disagree about what they are. It is read from the + # macro the binary itself compiles in, which the weekly release commits + # before this runs -- so the image is tagged with the version it reports. + 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 + # Scoped to the macro body: branding.rs holds other string literals, + # and tagging an image from one of those would be worse than failing. + V="$(awk '/macro_rules! brand_version/,/^}/' crates/types/src/branding.rs \ + | grep -om1 '"[0-9][^"]*"' | tr -d '"')" + [ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; } + # 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-server:` 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..287d182 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,186 @@ +# 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. +# +# INBUXA's version is a string in crates/types/src/branding.rs, deliberately +# not in Cargo.toml so that upstream's version bumps merge without conflicts. +# So this 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 macro cannot promise. +name: Weekly release + +on: + schedule: + # Mondays, 10:07 UTC, and last of the three: INBUXA Admin and the webmail + # release ahead of the server they talk to. 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: "7 10 * * 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: YYYY.M.D, unpadded, as branding.rs + # documents. 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 + + # Scoped to the macro body rather than replacing the first quoted + # string in the file, and asserted to have matched exactly once. + # branding.rs holds other string literals, and a bump that silently + # edited one of those -- or none -- would ship a build whose version + # disagrees with its tag. + python3 - <<'PY' + import os, re + path = "crates/types/src/branding.rs" + src = open(path, encoding="utf-8").read() + pattern = re.compile(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")') + out, n = pattern.subn(lambda m: m.group(1) + os.environ["VERSION"] + m.group(2), src, count=1) + assert n == 1, f"brand_version! not found in {path}" + open(path, "w", encoding="utf-8").write(out) + PY + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add crates/types/src/branding.rs + 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 ${{ 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 upstream's tag shapes 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