ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m23s
ci / announce (pull_request) Skipped
The Gitea registry stays authoritative; GHCR becomes a copy of it, the way the GitHub repository is a copy of the Gitea one. After the tag build has pushed the release image to the registry, a new ghcr job copies it to ghcr.io under the same version tag and :latest with `imagetools create` -- a copy, not a rebuild, so the digest on GHCR is the digest on the registry. Anything still pulling the old ghcr.io name, including the TrueNAS app submission, keeps receiving releases. The job uses the run's own token and is left out of the status reported to Gitea, so a GHCR problem cannot fail a release.
346 lines
16 KiB
YAML
346 lines
16 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 are cut by pushing a tag named `inbuxa-v<version>` (see
|
|
# .gitea/workflows/ci.yml for why the prefix matters: this repository carries
|
|
# upstream ihasmail's own `v...` tags, and only `inbuxa-v` tags publish).
|
|
#
|
|
# 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, releases 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: ['**']
|
|
tags: ['**']
|
|
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/ihasmail-inbuxa
|
|
# A tag's context names the tag: upstream v* and inbuxa-v* tags can sit on
|
|
# the same commit, and Gitea must not read one tag's result as the other's.
|
|
STATUS_CONTEXT: github/ci (${{ github.ref_type == 'tag' && format('tag {0}', github.ref_name) || 'branch' }})
|
|
|
|
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"
|
|
|
|
# -------------------------------------------------------------- test ------
|
|
# version.test.ts shells out to git to resolve a build version from the
|
|
# history, so the checkout is a full one. The hosted runner runs as an
|
|
# unprivileged user, so config.test.ts's read-only directory holds here
|
|
# without the `su node` Gitea needs.
|
|
node:
|
|
if: ${{ vars.BUILD_ON == 'github' }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
with:
|
|
node-version: 26
|
|
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 test
|
|
- run: npm run build
|
|
|
|
# ------------------------------------------------------------- build ------
|
|
# Proves the Dockerfile still builds on every change, without pushing.
|
|
docker-build:
|
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
|
|
needs: [node]
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
- run: docker build -t "ihasmail:ci-${GITHUB_SHA::8}" .
|
|
|
|
# ----------------------------------------------------------- release ------
|
|
# Only for `inbuxa-v` tags. The tag has to name its own commit's version, so
|
|
# the image, the release and the About screen all agree, and the commit has
|
|
# to be on main, so a release never describes code that was not reviewed
|
|
# onto the default branch.
|
|
version:
|
|
if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
version: ${{ steps.v.outputs.version }}
|
|
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
with:
|
|
node-version: 26
|
|
- id: v
|
|
env:
|
|
TAG: ${{ github.ref_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
V="$(node scripts/version.mjs)"
|
|
want="inbuxa-v${V/+/-}"
|
|
[ "$TAG" = "$want" ] || { echo "::error::$TAG does not name this commit's version; expected $want"; exit 1; }
|
|
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|
|
|| { echo "::error::$TAG is not on main"; exit 1; }
|
|
echo "version=$V" >> "$GITHUB_OUTPUT"
|
|
# A Docker tag may not contain '+', so build metadata becomes '-'.
|
|
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
|
|
echo "version $V -> tag ${V/+/-}"
|
|
|
|
# Each architecture on its own native runner, pushed as an untagged image by
|
|
# digest; `publish` joins the two digests into one multi-arch tag.
|
|
build:
|
|
if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
|
needs: [node, 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 }}
|
|
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
|
|
# 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`, links the package to
|
|
# the repository on Gitea, then creates the release there -- last, so a
|
|
# release on the page always has its image behind it. The release is made
|
|
# with GITEA_TOKEN, a user's token, so Gitea's announce.yml fires for it;
|
|
# Gitea's ci.yml announces as well once this run's status arrives, and the
|
|
# announce action posts once per tag whichever gets there first.
|
|
publish:
|
|
if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
|
needs: [version, build]
|
|
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:
|
|
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
|
|
run: |
|
|
refs=()
|
|
for f in /tmp/digests/*; do refs+=("${IMAGE}@sha256:$(basename "$f")"); done
|
|
docker buildx imagetools create -t "${IMAGE}:${DOCKER_TAG}" -t "${IMAGE}:latest" "${refs[@]}"
|
|
docker buildx imagetools inspect "${IMAGE}:${DOCKER_TAG}"
|
|
# 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/ihasmail-inbuxa/-/link/ihasmail-inbuxa" \
|
|
|| echo "package already linked (or link refused); not fatal"
|
|
- env:
|
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
TAG: ${{ github.ref_name }}
|
|
VERSION: ${{ needs.version.outputs.version }}
|
|
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
|
|
run: |
|
|
set -eu
|
|
API="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
|
|
# A re-run finds the release already there.
|
|
if curl -fsS -o /dev/null -H "Authorization: token $GITEA_TOKEN" "$API/tags/$TAG"; then
|
|
echo "release $TAG already exists"; exit 0
|
|
fi
|
|
body="$(printf 'INBUXA webmail %s.\n\nImage: `%s:%s` (linux/amd64, linux/arm64), also tagged `latest`.' "$VERSION" "$IMAGE" "$DOCKER_TAG")"
|
|
jq -n --arg tag "$TAG" --arg body "$body" '{tag_name:$tag, name:$tag, body:$body}' \
|
|
| curl -fsS -o /dev/null -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
|
|
--data @- "$API"
|
|
echo "release $TAG created"
|
|
|
|
# ------------------------------------------------------ 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. 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.docker_tag }}
|
|
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
|
|
docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$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.
|
|
github-release:
|
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
|
needs: [publish]
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
env:
|
|
GITEA_URL: ${{ vars.GITEA_URL }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ github.ref_name }}
|
|
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; else kind=--latest; 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, node, docker-build, version, build, publish]
|
|
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'"
|