A dated release from this branch moved :latest, GHCR's :latest and the GitHub "latest release" on every tag. That is right while 2026.x is the newest series, and wrong once 27.1 ships: a security release from this branch would take :latest back to the older line. Both CIs now ask Gitea for the newest final releases and move :latest only while none is numbered by series (v27.1, v27.1.3), so the switch happens on its own when 27.1 is published. The check fails rather than guess. The inherited v1.0.x numbers don't count as a series.
376 lines
18 KiB
YAML
376 lines
18 KiB
YAML
# CI and publishing on GitHub Actions, for a repository whose source of truth
|
|
# is the self-hosted Gitea. Gitea push-mirrors every commit and tag here, and
|
|
# this workflow does the heavy work on GitHub's hosted runners -- native arm64
|
|
# included -- then reports the result back to Gitea as a commit status.
|
|
#
|
|
# THE SWITCH. Every job here runs only when the org variable BUILD_ON is
|
|
# `github`. Gitea's .gitea/workflows/ci.yml reads the same variable (set on
|
|
# the Gitea org too): with it set, Gitea skips its own build jobs and waits for
|
|
# the status this workflow posts; without it, Gitea builds everything itself,
|
|
# exactly as before, and every job here is skipped. If GitHub is ever
|
|
# unavailable, unsetting BUILD_ON on Gitea is the whole fallback.
|
|
#
|
|
# There is no pull_request trigger: pull requests live on Gitea. A PR's branch
|
|
# arrives here as an ordinary push, and the status lands on its head commit,
|
|
# which is where Gitea's PR looks for it.
|
|
#
|
|
# Releases stay on Gitea: its weekly-release.yml bumps inbuxa-version.json,
|
|
# creates the release and with it the tag, and the mirror brings the tag here
|
|
# to be built and published.
|
|
#
|
|
# Org configuration, not in this file:
|
|
# vars.BUILD_ON `github` to build here
|
|
# vars.REGISTRY the Gitea container registry's DNS-only name
|
|
# vars.GITEA_URL Gitea's public URL, for statuses and packages
|
|
# secrets.GITEA_TOKEN jcoffey-dev, write:repository + write:package
|
|
#
|
|
# Every `uses:` is pinned to a full commit SHA with the release in the
|
|
# trailing comment. A tag is a mutable pointer, so trusting `@v7` is trusting
|
|
# every future version of that action. Do not "simplify" a pin back to a tag.
|
|
name: ci
|
|
|
|
on:
|
|
push:
|
|
branches: ['**']
|
|
# Only date tags publish (v2026.9.21, v2026.9.21.2), the same filter as
|
|
# Gitea's ci.yml. The repository still carries the inherited v1.0.x tags,
|
|
# and a tag of any other shape pushed by hand is not a release.
|
|
tags:
|
|
- 'v[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+'
|
|
- 'v[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+.[0-9]+'
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
GITEA_URL: ${{ vars.GITEA_URL }}
|
|
REGISTRY: ${{ vars.REGISTRY }}
|
|
# A registry path must be lowercase; the repository name already is.
|
|
IMAGE: ${{ vars.REGISTRY }}/inbuxa/inbuxa-admin
|
|
STATUS_CONTEXT: github/ci (${{ github.ref_type }})
|
|
|
|
jobs:
|
|
# Tells Gitea a result is on its way, so a PR shows the check as running
|
|
# rather than missing.
|
|
pending:
|
|
if: ${{ vars.BUILD_ON == 'github' }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- env:
|
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
run: |
|
|
jq -n --arg c "$STATUS_CONTEXT" \
|
|
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|
|
'{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' \
|
|
| curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/json" --data @- \
|
|
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
|
|
|
|
# A release tag is built and tested again before its image is published.
|
|
build:
|
|
if: ${{ vars.BUILD_ON == 'github' }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
with:
|
|
node-version: 22
|
|
cache: npm
|
|
# --ignore-scripts: a postinstall script in any transitive dependency
|
|
# would otherwise run with the job's credentials in its environment.
|
|
- run: npm ci --ignore-scripts
|
|
- run: npm run typecheck
|
|
- run: npm run lint
|
|
- run: npm run i18n:check
|
|
- run: npm test
|
|
- run: sh docker/listen-ipv6.test.sh
|
|
- run: npm run build
|
|
|
|
# ----------------------------------------------------------- publish ------
|
|
# The tag must agree with inbuxa-version.json at the commit it names: 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.
|
|
version:
|
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
version: ${{ steps.v.outputs.version }}
|
|
latest: ${{ steps.latest.outputs.latest }}
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
- id: v
|
|
run: |
|
|
set -eu
|
|
VERSION="$(jq -er .version inbuxa-version.json)"
|
|
if [ "$GITHUB_REF_NAME" != "v$VERSION" ]; then
|
|
echo "::error::Tag $GITHUB_REF_NAME names a commit whose inbuxa-version.json says $VERSION."
|
|
exit 1
|
|
fi
|
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
# :latest belongs to the newest series. Once a final release numbered by
|
|
# series exists (v27.1, v27.1.3), a dated release from release/2026
|
|
# leaves :latest and GitHub's latest release where they are. The check
|
|
# fails rather than guess: a wrong :latest is worse than a re-run.
|
|
- id: latest
|
|
run: |
|
|
set -euo pipefail
|
|
url="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases?draft=false&pre-release=false&limit=50"
|
|
series="$(curl -fsS "$url" | jq -er '[.[].tag_name | select(test("^v[0-9]{2}\\."))] | first // ""')"
|
|
if [ -n "$series" ]; then
|
|
echo "$series is out: :latest stays on its series"; echo "latest=false" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "no series release yet: :latest moves"; echo "latest=true" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# Each architecture on its own native runner, pushed as an untagged image by
|
|
# digest; `publish` joins the two digests into one multi-arch tag.
|
|
image:
|
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
|
needs: [build, version]
|
|
runs-on: ${{ matrix.runner }}
|
|
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
|
|
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
with:
|
|
registry: ${{ vars.REGISTRY }}
|
|
username: jcoffey-dev
|
|
password: ${{ secrets.GITEA_TOKEN }}
|
|
- name: Build and push by digest
|
|
id: push
|
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
|
with:
|
|
context: .
|
|
platforms: ${{ matrix.platform }}
|
|
# Attestations add manifests of their own to the index, and
|
|
# `imagetools create` below expects the two entries pushed here.
|
|
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
|
|
env:
|
|
DIGEST: ${{ steps.push.outputs.digest }}
|
|
run: |
|
|
mkdir -p /tmp/digests
|
|
# Bare hash as the filename; the prefix is put back when joining.
|
|
touch "/tmp/digests/${DIGEST#sha256:}"
|
|
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: digest-${{ strategy.job-index }}
|
|
path: /tmp/digests/*
|
|
# Kept a week, and overwritable, so a re-run of the build or of
|
|
# publish alone still finds (or replaces) the digests.
|
|
retention-days: 7
|
|
overwrite: true
|
|
if-no-files-found: error
|
|
|
|
# Joins the digests into `:<version>`, and `:latest` while the version job
|
|
# says so, and links the package
|
|
# to the repository on Gitea. The release itself already exists: Gitea's
|
|
# weekly-release.yml made it, which is what made the tag.
|
|
publish:
|
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
|
needs: [version, image]
|
|
runs-on: ubuntu-latest
|
|
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: ${{ vars.REGISTRY }}
|
|
username: jcoffey-dev
|
|
password: ${{ secrets.GITEA_TOKEN }}
|
|
- name: Create the manifest
|
|
env:
|
|
VERSION: ${{ needs.version.outputs.version }}
|
|
LATEST: ${{ needs.version.outputs.latest }}
|
|
run: |
|
|
refs=()
|
|
for f in /tmp/digests/*; do refs+=("${IMAGE}@sha256:$(basename "$f")"); done
|
|
tags=(-t "${IMAGE}:${VERSION}"); if [ "$LATEST" = true ]; then tags+=(-t "${IMAGE}:latest"); fi
|
|
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
|
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
|
# Shows the package on the repository's Packages tab. Idempotent.
|
|
- env:
|
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
run: |
|
|
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_URL/api/v1/packages/inbuxa/container/inbuxa-admin/-/link/inbuxa-admin" \
|
|
|| echo "package already linked (or link refused); not fatal"
|
|
|
|
# The console's files for a host install (the installer's --console host),
|
|
# taken out of the image that was just published rather than built again:
|
|
# the tarball is what the image serves. They are static files, the same for
|
|
# every architecture, so one tarball. index.html carries no api-base-url;
|
|
# whoever installs it writes one, as the image's entrypoint does.
|
|
files:
|
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
|
needs: [version, publish]
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
VERSION: ${{ needs.version.outputs.version }}
|
|
TAG: ${{ github.ref_name }}
|
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
steps:
|
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
with:
|
|
registry: ${{ vars.REGISTRY }}
|
|
username: jcoffey-dev
|
|
password: ${{ secrets.GITEA_TOKEN }}
|
|
- name: take the files out of the image
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p out
|
|
docker pull -q --platform linux/amd64 "$IMAGE:$VERSION"
|
|
id="$(docker create --platform linux/amd64 "$IMAGE:$VERSION")"
|
|
docker cp "$id:/usr/share/nginx/html" console
|
|
docker rm -f "$id" >/dev/null
|
|
tar --owner=0 --group=0 -czf out/inbuxa-admin.tar.gz -C console .
|
|
(cd out && sha256sum inbuxa-admin.tar.gz > SHA256SUMS && cat SHA256SUMS)
|
|
# A re-run replaces the assets rather than adding a second file of the
|
|
# same name. Uploads cross Cloudflare: HTTP/1.1, with retries, as the
|
|
# server's binaries do.
|
|
- name: attach them to the release
|
|
run: |
|
|
set -euo pipefail
|
|
api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
|
auth="Authorization: token $GITEA_TOKEN"
|
|
retry=(--retry 5 --retry-all-errors --retry-delay 15)
|
|
rel="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/tags/$TAG" | jq -r .id)"
|
|
assets="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/$rel/assets")"
|
|
for f in out/inbuxa-admin.tar.gz out/SHA256SUMS; do
|
|
name="$(basename "$f")"
|
|
old="$(jq -r --arg n "$name" '.[] | select(.name == $n) | .id' <<<"$assets")"
|
|
for id in $old; do curl -fsS "${retry[@]}" -o /dev/null -X DELETE -H "$auth" "$api/releases/$rel/assets/$id"; done
|
|
curl -fsS --http1.1 "${retry[@]}" -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$rel/assets?name=$name"
|
|
echo "attached $name"
|
|
done
|
|
|
|
# ------------------------------------------------------ ghcr replica ------
|
|
# Copies the release image from the Gitea registry, which stays the
|
|
# authoritative one, to ghcr.io under the same version tag, and :latest as above. It is
|
|
# a copy, not a second build: the digest on GHCR is the digest on the
|
|
# registry, so `docker pull ghcr.io/...` gets exactly the same image. Left
|
|
# out of the report to Gitea, like the release copy, so a GHCR problem
|
|
# cannot fail a release.
|
|
ghcr:
|
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
|
needs: [version, publish]
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
steps:
|
|
- env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.version.outputs.version }}
|
|
LATEST: ${{ needs.version.outputs.latest }}
|
|
run: |
|
|
set -euo pipefail
|
|
src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}"
|
|
dst="ghcr.io/${GITHUB_REPOSITORY,,}"
|
|
tag="$TAG"
|
|
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
|
args=(-t "$dst:$tag"); if [ "$LATEST" = true ]; then args+=(-t "$dst:latest"); fi
|
|
docker buildx imagetools create "${args[@]}" "$src:$tag"
|
|
want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')"
|
|
got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')"
|
|
echo "registry $src:$tag = $want"
|
|
echo "ghcr $dst:$tag = $got"
|
|
[ "$want" = "$got" ] || echo "::warning::GHCR digest differs from the registry's"
|
|
docker logout ghcr.io
|
|
|
|
# ---------------------------------------------------- github release ------
|
|
# Copies this tag's Gitea release -- notes and files -- to a GitHub release,
|
|
# so the replica's Releases page, and anyone watching it, keeps up. Gitea's
|
|
# release is the real one; this is left out of the report to Gitea, so a
|
|
# failure here cannot fail a release. PR and issue numbers in the notes are
|
|
# rewritten to Gitea links: on GitHub a bare #16 is some other PR.
|
|
# It waits for `files` too: started after `publish` alone, it ran beside
|
|
# `files` and copied the Gitea release before its files were attached, so
|
|
# every GitHub copy had none.
|
|
github-release:
|
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
|
needs: [version, publish, files]
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
env:
|
|
GITEA_URL: ${{ vars.GITEA_URL }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ github.ref_name }}
|
|
LATEST: ${{ needs.version.outputs.latest }}
|
|
steps:
|
|
- run: |
|
|
set -euo pipefail
|
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
echo "GitHub already has a release for $TAG"; exit 0
|
|
fi
|
|
# The Gitea release exists by now if this run made it; if the weekly
|
|
# release job made it, it came before the tag. Allow a few minutes.
|
|
code=0
|
|
for _ in $(seq 1 15); do
|
|
code="$(curl -sS -o rel.json -w '%{http_code}' "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")"
|
|
[ "$code" = 200 ] && break
|
|
sleep 20
|
|
done
|
|
if [ "$code" != 200 ]; then echo "No Gitea release for $TAG; nothing to copy"; exit 0; fi
|
|
if [ "$(jq -r .draft rel.json)" = true ]; then echo "The Gitea release is a draft; not copying"; exit 0; fi
|
|
export BASE="$(jq -r '.html_url | sub("/releases/tag/.*$"; "")' rel.json)"
|
|
jq -r '.body // ""' rel.json | perl -pe 's{(?<![\w/&\[])#(\d+)\b}{[#$1]($ENV{BASE}/pulls/$1)}g' > notes.md
|
|
printf '\n\n_Mirrored from [the Gitea release](%s); report issues on [Gitea](%s/issues)._\n' \
|
|
"$(jq -r .html_url rel.json)" "$BASE" >> notes.md
|
|
files=()
|
|
mkdir -p files
|
|
while IFS=$'\t' read -r name url; do
|
|
curl -fsSL -o "files/$name" "$url"; files+=("files/$name")
|
|
done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json)
|
|
title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG"
|
|
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; elif [ "$LATEST" = true ]; then kind=--latest; else kind=--latest=false; fi
|
|
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \
|
|
--notes-file notes.md "$kind" "${files[@]}"
|
|
echo "created the GitHub release for $TAG with ${#files[@]} file(s)"
|
|
|
|
# One commit status on Gitea for the whole run: what Gitea's ci.yml waits
|
|
# for, and what a Gitea PR shows.
|
|
report:
|
|
if: ${{ always() && vars.BUILD_ON == 'github' }}
|
|
needs: [pending, build, version, image, publish, files]
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- env:
|
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
STATE: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }}
|
|
run: |
|
|
# A cancelled run was superseded by a newer run for the same commit (the
|
|
# mirror can push one commit twice); that run reports. Posting "failure"
|
|
# here would fail the Gitea check while the real build is still going.
|
|
if [ "$STATE" = cancelled ]; then echo "cancelled: leaving the result to the newer run"; exit 0; fi
|
|
jq -n --arg s "$STATE" --arg c "$STATUS_CONTEXT" \
|
|
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|
|
'{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' \
|
|
| curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/json" --data @- \
|
|
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
|
|
echo "reported $STATE as '$STATUS_CONTEXT'"
|