From 25e33767afa11c5f465641e0656c15ef654bd72d Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 10:31:09 -0700 Subject: [PATCH] Release weekly and publish the image from GitLab Ports release.yml and publish.yml, the two workflows left behind when the GitHub account was suspended; nothing has released INBUXA Admin since. weekly-release keeps release.yml's decision unchanged: on a schedule that sets RELEASE_WEEKLY=1, count the commits on main since the newest published release, stop if there are none, and otherwise pick today's version (YYYY.M.D, .2 and on for a second release in a day), commit it to main in inbuxa-version.json and cut the release at that commit. DRY_RUN=1 stops after the decision. What changes is how it writes. GitHub's job pushed the bump with GITHUB_TOKEN; here the bump goes through the commits API with RELEASE_TOKEN, and last_commit_id pins it to the commit the job counted from, so a main that moved underneath makes the job fail rather than release something the notes don't describe. The release and its tag go through the releases API with the same token, which as an ordinary push starts a tag pipeline -- replacing publish.yml's workflow_call, which only existed because a GITHUB_TOKEN release raises no event. publish runs in that tag pipeline, after the build job has built and tested the tag again. It pushes a two-architecture image (arm64 under QEMU, as ihasmail's does) to the project registry, registry.coffeylabs.org/inbuxa/inbuxa-admin, tagged with the version and latest. Only date tags publish, not the inherited v1.0.x, and a tag whose commit's inbuxa-version.json says anything else is refused, so an image never reports a version other than its tag. Tag lookups use git show-ref, after ihasmail's port found rev-parse --verify reading some tag names as describe output on the git in these images. Needs, on the project: a RELEASE_TOKEN variable (project access token, Maintainer, api scope, protected and masked) whose role may push to main, and a pipeline schedule on main, Mondays 09:37 UTC, setting RELEASE_WEEKLY=1. --- .gitlab-ci.yml | 193 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 182 insertions(+), 11 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3aaee2f..b070448 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,20 +1,23 @@ -# CI on the self-hosted GitLab, ported from .github/workflows/ci.yml when the -# GitHub account was suspended on 2026-09-20. The Actions file stays in the -# tree: it is the reference this was written from and works unchanged if the -# appeal succeeds. +# CI on the self-hosted GitLab, ported from .github/workflows/ when the GitHub +# account was suspended on 2026-09-20. The Actions files stay in the tree: they +# are the reference this was written from and work unchanged if the appeal +# succeeds. # -# The image is pinned by digest, with its tag in the trailing comment -- the -# replacement for the workflow's SHA-pinned actions, since GitLab has no -# action allowlist. +# Every `image:` is pinned by digest, with its tag in the trailing comment -- +# the replacement for the workflows' SHA-pinned actions, since GitLab has no +# action allowlist. Read the comment for the version; the digest is what runs. # -# Not ported here: +# The runner is the inbuxa group runner on Web_Host, with the host docker +# socket bound in, as ihasmail's is. Jobs reach GitLab and its registry on the +# internal network, never through https://git.coffeylabs.org, which is +# Cloudflare-proxied and caps request bodies at 100 MB. +# +# Not ported: # * cleanup.yml pruned GHCR with dataaxiom/ghcr-cleanup-action; on GitLab # that belongs in the project's container registry cleanup policy, not in # a pipeline. -# * publish.yml and release.yml still need doing; they are larger and are -# being handled separately. -stages: [build] +stages: [build, publish, release] default: interruptible: true @@ -35,5 +38,173 @@ build: - npm test - npm run build rules: + - if: $RELEASE_WEEKLY == "1" + when: never - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + # A release tag is built and tested again before its image is published. + - if: $CI_COMMIT_TAG + +# ------------------------------------------------------------- publish ------ +# Port of publish.yml, to the project's own registry now that GHCR went with +# the GitHub account: registry.coffeylabs.org/inbuxa/inbuxa-admin. +# +# Tag-driven. publish.yml was called from release.yml because a release cut +# with GITHUB_TOKEN raises no event; GitLab has no such rule, so the tag the +# weekly release creates starts a tag pipeline, and this builds from it. +# +# Only date tags publish (v2026.9.21, v2026.9.21.2). The repository still +# carries the inherited v1.0.x tags, and a tag of any other shape pushed by +# hand is not a release. +# +# The tag must agree with inbuxa-version.json at the commit it names -- the +# property release.yml was built around: the tree a tag points at reports the +# version the tag claims. A tag placed beside an unbumped file fails here +# rather than publishing an image that reports the wrong version. +# +# Both architectures build under QEMU on this amd64 host, where publish.yml +# had a native arm64 runner. That is slow -- tens of minutes for npm ci and +# the Vite build through instruction translation -- and tolerable for a weekly +# tag, which is why this is tag-only. If arm64 starts timing out, the fix is +# an arm64 runner, not dropping the platform. +publish: + stage: publish + image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli + needs: [build] + variables: + DOCKER_BUILDKIT: "1" + IMAGE: $CI_REGISTRY_IMAGE + before_script: + - apk add --no-cache jq >/dev/null + - | + set -eu + VERSION="$(jq -er .version inbuxa-version.json)" + if [ "$CI_COMMIT_TAG" != "v$VERSION" ]; then + echo "Tag $CI_COMMIT_TAG names a commit whose inbuxa-version.json says $VERSION." >&2 + echo "Refusing to publish an image that would report the wrong version." >&2 + exit 1 + fi + echo "VERSION=$VERSION" > version.env + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + - docker run --privileged --rm tonistiigi/binfmt --install arm64 + - docker buildx create --use --name ci-builder --driver docker-container || docker buildx use ci-builder + script: + - . ./version.env + # Attestations are off, as they were in publish.yml: they add manifests of + # their own to the index. + - | + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --provenance=false --sbom=false \ + --tag "$IMAGE:$VERSION" \ + --tag "$IMAGE:latest" \ + --push . + - docker buildx imagetools inspect "$IMAGE:$VERSION" + after_script: + - docker logout "$CI_REGISTRY" || true + rules: + - if: $CI_COMMIT_TAG =~ /^v[0-9]{4}\.[0-9]+\.[0-9]+(\.[0-9]+)?$/ + +# ------------------------------------------------------- weekly release ----- +# Port of release.yml: cut a release once a week, but only if there is +# something in it. The decision is the workflow's, unchanged -- count the +# commits on main since the newest published release, and skip the week if +# there are none. A release with nothing in it moves :latest to an identical +# build, spends a version number, and notifies everybody about nothing. +# +# The version is the date, YYYY.M.D unpadded, with a .N suffix from 2 for a +# second release on one day. It is committed to main in inbuxa-version.json +# and the tag names that commit, so the commit is the release. +# +# It runs from a pipeline schedule (Mondays 09:37 UTC, as release.yml did) +# that sets RELEASE_WEEKLY=1. GitLab keeps schedules on the project, not in +# this file, so the schedule and this job only work as a pair. Run it by hand +# with RELEASE_WEEKLY=1, adding DRY_RUN=1 to see the decision and stop. +# +# Everything that writes uses RELEASE_TOKEN, a project access token +# (Maintainer, `api` scope; protected, masked), never CI_JOB_TOKEN, which can +# neither commit nor raise a tag pipeline: +# * the bump is committed through the commits API, with last_commit_id set +# to the commit this job counted from. If main moved meanwhile the API +# refuses and the job fails; run it again. Otherwise the notes and the +# count would describe a different commit from the one released. +# * the release -- and with it the tag -- is created through the releases +# API. A tag made that way is an ordinary push, so it starts the tag +# pipeline and `publish` builds the image. +# The token's role must be allowed to push to main. The token expires; when +# it does this fails loudly at the first API call, and a new one goes in the +# same variable. +weekly-release: + stage: release + image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim + # One at a time: two overlapping runs would race to write the same version + # and create the same tag. + resource_group: weekly-release + variables: + GIT_DEPTH: "0" + before_script: + - apt-get update -qq && apt-get install -y -qq --no-install-recommends git curl jq ca-certificates >/dev/null + # The build directory is shared between jobs, and a checkout owned by + # another user makes git refuse with "detected dubious ownership". + - git config --global --add safe.directory "$CI_PROJECT_DIR" + script: + - | + set -euo pipefail + API="http://gitlab/api/v4/projects/${CI_PROJECT_ID}" + auth=(--header "PRIVATE-TOKEN: ${RELEASE_TOKEN}") + git fetch -q --tags origin + sha="$(git rev-parse HEAD)" + + # The newest published release, or empty on a project that has never + # had one -- in which case everything counts as new. + previous="$(curl -fsS "${auth[@]}" "${API}/releases?order_by=released_at&sort=desc&per_page=1" | jq -r '.[0].tag_name // ""')" + # A release can outlive its tag; falling back to the whole history + # over-counts, which cuts a release that was due anyway. Tag lookups use + # show-ref, which matches an exact ref: rev-parse --verify on this git + # can read some tag names as describe output and "find" a tag that + # isn't there (see ihasmail's port). + if [ -n "$previous" ] && git show-ref --verify --quiet "refs/tags/${previous}"; then + count="$(git rev-list --count "${previous}..HEAD")"; range="${previous}..HEAD" + else + count="$(git rev-list --count HEAD)"; range="HEAD" + fi + if [ "$count" -eq 0 ]; then + echo "Nothing to release: no commits since ${previous}."; exit 0 + fi + + today="$(date -u +%Y.%-m.%-d)" + version="$today"; n=2 + while git show-ref --verify --quiet "refs/tags/v${version}"; do + version="${today}.${n}"; n=$((n + 1)) + done + tag="v${version}" + echo "Releasing ${tag} -- ${count} commit(s) since ${previous:-the beginning}, from ${sha}." + if [ "${DRY_RUN:-0}" = "1" ]; then echo "DRY_RUN=1: stopping here."; exit 0; fi + + # The bump, written with a JSON parser rather than sed: a version put + # into JSON by string substitution is one stray quote from a file + # nothing can read. + VERSION="$version" 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"); + ' + jq -n --arg msg "Version ${version}" --arg sha "$sha" --rawfile content inbuxa-version.json \ + '{branch:"main", commit_message:$msg, last_commit_id:$sha, + actions:[{action:"update", file_path:"inbuxa-version.json", content:$content}]}' > commit.json + bump="$(curl -fsS "${auth[@]}" --header "Content-Type: application/json" \ + --data @commit.json "${API}/repository/commits" | jq -er .id)" + echo "committed the bump as ${bump}" + + # Notes bounded to what is new: one line per change on main's + # first-parent history, which is what GitHub's generated notes listed. + notes="$(git log --first-parent --format='- %s' "$range")" + jq -n --arg tag "$tag" --arg ref "$bump" --arg name "INBUXA Admin ${version}" \ + --arg desc "$(printf '%s commit(s) since %s.\n\n%s' "$count" "${previous:-the beginning}" "$notes")" \ + '{tag_name:$tag, ref:$ref, name:$name, description:$desc}' > release.json + curl -fsS "${auth[@]}" --header "Content-Type: application/json" \ + --data @release.json "${API}/releases" | jq -r '"created release " + .tag_name' + rules: + - if: $RELEASE_WEEKLY == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH