Merge pull request 'ci: build on GitHub via the mirror, switchable with BUILD_ON' (#31) from ci/build-on-github into main
ci / version (push) Skipped
ci / github (push) Skipped
ci / node (push) Successful in 2m58s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 39s
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 2m4s
ci / announce (pull_request) Skipped
ci / version (push) Skipped
ci / github (push) Skipped
ci / node (push) Successful in 2m58s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 39s
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 2m4s
ci / announce (pull_request) Skipped
Reviewed-on: inbuxa/ihasmail-inbuxa#31
This commit was merged in pull request #31.
This commit is contained in:
commit
401c32a3e6
6 files changed
+301
-513
No files matched your search
+59
-7
@@ -1,6 +1,19 @@
|
||||
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
|
||||
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
|
||||
# this directory exists; .github/workflows stays as it was for GitHub.
|
||||
# this directory exists; .github/workflows is the GitHub side, below.
|
||||
#
|
||||
# WHERE THE BUILD RUNS. Gitea push-mirrors this repository to GitHub, and the
|
||||
# org variable BUILD_ON picks which forge does the heavy work:
|
||||
# * unset (or anything but `github`): every job here runs, as it always did,
|
||||
# and GitHub's workflow skips all of its jobs.
|
||||
# * `github`: the test, build and publish jobs here are skipped, GitHub
|
||||
# Actions runs .github/workflows/ci.yml on its hosted runners (native
|
||||
# arm64, no QEMU), and the `github` job below waits for the commit status
|
||||
# that run posts back, passing or failing with it. So this run's result is
|
||||
# still the one that counts, for a PR's checks as for anything that merges
|
||||
# on green CI. The variable is set on both forges, and must agree.
|
||||
# If GitHub is ever unavailable, unsetting BUILD_ON here is the whole
|
||||
# fallback: the jobs below take over again unchanged.
|
||||
#
|
||||
# Releases are cut by pushing a tag named `inbuxa-v<version>`, where
|
||||
# <version> is what scripts/version.mjs says for the tagged commit with the
|
||||
@@ -35,6 +48,7 @@ concurrency:
|
||||
jobs:
|
||||
# -------------------------------------------------------------- test ------
|
||||
node:
|
||||
if: ${{ vars.BUILD_ON != 'github' }}
|
||||
runs-on: light
|
||||
container:
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
@@ -73,7 +87,7 @@ jobs:
|
||||
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step. The
|
||||
# Dockerfile builds everything itself; `needs` only keeps the order.
|
||||
docker-build:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
if: ${{ vars.BUILD_ON != 'github' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: [node]
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -93,7 +107,7 @@ jobs:
|
||||
# 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: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||
if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||
runs-on: light
|
||||
container:
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
@@ -125,7 +139,7 @@ jobs:
|
||||
# the job's own token is refused by the container registry. The release is
|
||||
# created last, so a release on the page always has its image behind it.
|
||||
publish:
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||
if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||
needs: [node, version]
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -180,10 +194,13 @@ jobs:
|
||||
|
||||
# The release above is made with the job's own token, and Gitea starts no
|
||||
# workflow for events the Actions bot causes -- announce.yml's
|
||||
# 'on: release' never fires for it -- so announce it from here.
|
||||
# 'on: release' never fires for it -- so announce it from here. With
|
||||
# BUILD_ON=github the release is created by GitHub's run instead, and this
|
||||
# follows the `github` job. Announcing stays on Gitea either way; the
|
||||
# action posts once per tag, so a second attempt is a no-op.
|
||||
announce:
|
||||
needs: [publish]
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||
needs: [publish, github]
|
||||
if: ${{ always() && startsWith(github.ref, 'refs/tags/inbuxa-v') && (needs.publish.result == 'success' || needs.github.result == 'success') }}
|
||||
runs-on: light
|
||||
steps:
|
||||
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
||||
@@ -191,3 +208,38 @@ jobs:
|
||||
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
||||
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
tag: ${{ github.ref_name }}
|
||||
|
||||
# ------------------------------------------------------------ github ------
|
||||
# With BUILD_ON=github, the work above happens in GitHub Actions, which
|
||||
# posts one commit status back here when it finishes: "github/ci (branch)"
|
||||
# for a branch push, "github/ci (tag)" for a tag. This job waits for that
|
||||
# status on the commit under test -- the PR's head for a pull request -- and
|
||||
# passes or fails with it. Nothing arriving within the timeout means GitHub
|
||||
# never built the commit (a mirror that failed to sync, or GitHub being
|
||||
# down): check the mirror, or unset BUILD_ON to build here.
|
||||
github:
|
||||
if: ${{ vars.BUILD_ON == 'github' }}
|
||||
runs-on: light
|
||||
timeout-minutes: 150
|
||||
container:
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
CONTEXT: github/ci (${{ github.ref_type == 'tag' && 'tag' || 'branch' }})
|
||||
steps:
|
||||
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl jq >/dev/null
|
||||
- shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
url="$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/commits/$SHA/statuses?limit=50"
|
||||
echo "waiting for '$CONTEXT' on $SHA"
|
||||
while :; do
|
||||
state="$(curl -fsS -H "Authorization: token $TOKEN" "$url" \
|
||||
| jq -r --arg c "$CONTEXT" '[.[] | select(.context == $c)] | sort_by(.id) | last | .status // empty')"
|
||||
case "$state" in
|
||||
success) echo "GitHub reported success"; exit 0 ;;
|
||||
failure|error) echo "GitHub reported $state -- see the status's link for the run" >&2; exit 1 ;;
|
||||
esac
|
||||
sleep 20
|
||||
done
|
||||
@@ -1,44 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# The npm entry sits at the root because that is where the single lockfile
|
||||
# is: root, server and web are one npm workspace, so one entry covers all
|
||||
# three. Pointing entries at server/ or web/ would find package.json files
|
||||
# with no lockfile beside them and update nothing.
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
# Everything routine arrives as one PR a week, so the dashboard is not
|
||||
# the only place these get noticed. Majors are deliberately left out of
|
||||
# the group: they are migrations, not bumps -- vitest 3 to 4 is one --
|
||||
# and each deserves its own PR and its own CI run.
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
# The runtime and build stages both pin node:22-alpine, so this is what
|
||||
# keeps the published container images off a stale base between the weekly
|
||||
# releases.
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
+242
-24
@@ -1,39 +1,257 @@
|
||||
name: CI
|
||||
# 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: [main]
|
||||
pull_request:
|
||||
# Lets CI be run by hand against any ref, including a specific commit.
|
||||
# Without this there is no way to re-run a check that never started: a run
|
||||
# GitHub queues and then orphans -- as it did to every run created during the
|
||||
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
|
||||
# nor canceled ("already completed"), and the workflow has no other trigger
|
||||
# to reach for. Useful too for putting a check on a commit that predates a CI
|
||||
# change, without pushing an empty commit to move it.
|
||||
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
|
||||
STATUS_CONTEXT: github/ci (${{ github.ref_type }})
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# 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:
|
||||
# Every `uses:` in this repository is pinned to a full commit SHA, with
|
||||
# the release it belongs to in the trailing comment, and the repository
|
||||
# requires it -- an unpinned ref fails the run rather than quietly
|
||||
# resolving. A tag is a mutable pointer: `@v7` is whatever the publisher
|
||||
# last moved it to, so trusting one is trusting every future version of
|
||||
# that action, including the one pushed by whoever compromises the
|
||||
# account. Read the comment for the version; the SHA is what runs.
|
||||
#
|
||||
# Dependabot updates both halves together on its weekly github-actions
|
||||
# run, so this costs nothing to keep current -- do not "simplify" a pin
|
||||
# back to a tag.
|
||||
- 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
|
||||
- name: Docker build
|
||||
run: docker build -t ihasmail:ci .
|
||||
|
||||
# ------------------------------------------------------------- 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/*
|
||||
retention-days: 1
|
||||
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"
|
||||
|
||||
# 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') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }}
|
||||
run: |
|
||||
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'"
|
||||
@@ -1,69 +0,0 @@
|
||||
# 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: Coffey-Labs
|
||||
package: ihasmail
|
||||
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 }}
|
||||
@@ -1,206 +0,0 @@
|
||||
# Publish the container image to GHCR.
|
||||
#
|
||||
# The README and the docs site have told people to run
|
||||
# `ghcr.io/coffey-labs/ihasmail: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: a registry path must
|
||||
# be lowercase and the owner is spelled `Coffey-Labs`, so deriving it means
|
||||
# remembering to lowercase it. This is the string the docs already name.
|
||||
# inbuxa: this fork publishes to INBUXA's own path. Inherited from public
|
||||
# ihasmail, which publishes ghcr.io/coffey-labs/ihasmail -- leaving that
|
||||
# here would push INBUXA's webmail over the image every public ihasmail
|
||||
# install pulls, which SPEC.md 5 exists to prevent.
|
||||
IMAGE: ghcr.io/inbuxa/ihasmail-inbuxa
|
||||
|
||||
jobs:
|
||||
# The version is worked out once and handed to both builds, so the two
|
||||
# architectures cannot disagree about what they are. scripts/version.mjs
|
||||
# reads the commit date and how the commit arrived, so it needs real history
|
||||
# rather than a shallow clone.
|
||||
version:
|
||||
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:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
- id: v
|
||||
run: |
|
||||
V="$(node scripts/version.mjs)"
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
# A Docker tag may not contain '+', so build metadata becomes '-'.
|
||||
# The build is still *told* the real form, which is what About and
|
||||
# /api/health report.
|
||||
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
|
||||
echo "version $V -> tag ${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 }}
|
||||
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
|
||||
# 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/coffey-labs/ihasmail:<tag>` 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.docker_tag }}")
|
||||
# :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.docker_tag }}"
|
||||
|
||||
# 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
|
||||
@@ -1,163 +0,0 @@
|
||||
# Cut a release once a week, but only if there is something in it.
|
||||
#
|
||||
# Releases had drifted 184 commits behind main, which made `:latest` describe
|
||||
# a build nobody was running -- the demo, prod and anyone building from source
|
||||
# were all ahead of it. Publishing on release is the right trigger only if
|
||||
# releases actually happen, so this is the part that makes that true without
|
||||
# anyone having to remember.
|
||||
#
|
||||
# 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.
|
||||
name: Weekly release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays, 09:17 UTC. GitHub runs scheduled jobs on a best-effort basis and
|
||||
# can delay a run by a good while when the queue is busy, so do not read
|
||||
# the exact minute as a promise. The odd minute is deliberate: the top of
|
||||
# the hour is when most schedules fire, and at 09:00 the first scheduled
|
||||
# run started almost six hours late and the second had not started at all
|
||||
# four and a half hours in. Moving off the hour does not make GitHub keep
|
||||
# time, but it stops competing for the busiest slot. A missed week can be
|
||||
# cut by hand with workflow_dispatch; a late scheduled run that follows
|
||||
# finds the tag already there and does nothing.
|
||||
#
|
||||
# Note also that GitHub disables scheduled workflows in a repository with
|
||||
# no activity for 60 days -- not a concern while this one is being worked
|
||||
# on weekly, but it is why a silent stop is worth checking for before
|
||||
# assuming the file is broken.
|
||||
- cron: "17 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 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 }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
title: ${{ steps.decide.outputs.title }}
|
||||
sha: ${{ steps.decide.outputs.sha }}
|
||||
previous: ${{ steps.decide.outputs.previous }}
|
||||
count: ${{ steps.decide.outputs.count }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
- 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
|
||||
|
||||
version="$(node scripts/version.mjs)"
|
||||
# A Docker tag may not contain '+', and neither should the git tag,
|
||||
# so the two always agree about what to call a build.
|
||||
tag="v${version/+/-}"
|
||||
title="v${version%%+*}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
|
||||
should_release=true
|
||||
reason=""
|
||||
if [ "$count" -eq 0 ]; then
|
||||
should_release=false
|
||||
reason="no commits since ${previous}"
|
||||
elif git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
|
||||
# Same commit, different week: the version is derived from the
|
||||
# commit, so nothing new means the tag already exists.
|
||||
should_release=false
|
||||
reason="tag ${tag} already exists"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "should_release=$should_release"
|
||||
echo "tag=$tag"
|
||||
echo "title=$title"
|
||||
echo "sha=$sha"
|
||||
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 **${tag}** — ${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
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=(--target "${{ needs.check.outputs.sha }}"
|
||||
--title "${{ needs.check.outputs.title }}"
|
||||
--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 with older 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.check.outputs.sha }}
|
||||
tag_latest: true
|
||||
Reference in new issue
Block a user