ci: build on GitHub via the mirror, switchable with BUILD_ON #7

Merged
jcoffey-dev merged 1 commits from ci/build-on-github into main 2026-09-30 06:52:47 +00:00
4 changed files with 239 additions and 121 deletions

No files matched your search

+60 -11
View File
@@ -1,6 +1,6 @@
# 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 GitHub's side of the switch below.
#
# Every job runs in an image pinned by digest (tag in the trailing comment),
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
@@ -12,12 +12,21 @@
# links; Gitea attaches the tarballs to the Release itself, as GitHub did, so
# the build and the release are one job and nothing is handed between jobs.
#
# e2e.yml is deliberately NOT ported. e2e/public.sh publishes 25, 80, 443,
# 465, 993, 995 and 4190 on the machine it runs on, which on GitHub was a
# throwaway VM and here would be the CI host -- where 80 and 443 are nginx
# and the mail ports belong to the mail netns. It stays a manual check on a
# disposable host until there is a runner that can safely be given those
# ports.
# BUILD ON GITHUB. The org variable BUILD_ON decides which forge builds.
# Unset (or anything but 'github'), the test and release jobs below run here,
# as they always have. Set to 'github', they skip and .github/workflows/ci.yml
# does the same work on GitHub's runners -- GitHub holds a push mirror of this
# repository, updated on every commit -- and reports back as the commit status
# "github/ci (branch)" or "github/ci (tag)". The `github` job waits for that
# status and passes or fails with it, so a run here still says whether the
# commit is good. If GitHub is ever unavailable, unset BUILD_ON and everything
# builds here again.
#
# The end-to-end test is deliberately NOT here. e2e/public.sh publishes 25,
# 80, 443, 465, 993, 995 and 4190 on the machine it runs on, which here would
# be the CI host -- where 80 and 443 are nginx and the mail ports belong to the
# mail netns. It lives in .github/workflows/ci.yml as a job run by hand, on a
# GitHub runner, which is a throwaway VM.
name: ci
on:
@@ -32,6 +41,7 @@ concurrency:
jobs:
test:
if: ${{ vars.BUILD_ON != 'github' }}
runs-on: light
container:
image: golang:1.26-bookworm@sha256:a688600ca24f8a4d3ca77f95b0dd40704a9fc787c826660eb7ba0b641b8b175d # 1.26-bookworm
@@ -45,7 +55,7 @@ jobs:
- run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
release:
if: startsWith(github.ref, 'refs/tags/')
if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/') }}
needs: [test]
runs-on: light
container:
@@ -102,12 +112,51 @@ jobs:
|| { curl --silent "${auth[@]}" -X DELETE "$API/releases/$id"; exit 1; }
done
# Stands in for test and release while GitHub builds: waits for the status
# GitHub's ci.yml posts on this commit and passes or fails with it. A pull
# request is checked at its head commit, which is what GitHub built when
# the branch reached the mirror. Two and a half hours covers a slow queue;
# a timeout here with BUILD_ON=github usually means GitHub never got the
# push -- check the mirror's last error in the repository settings.
github:
if: ${{ vars.BUILD_ON == 'github' }}
runs-on: light
timeout-minutes: 150
container:
image: golang:1.26-bookworm@sha256:a688600ca24f8a4d3ca77f95b0dd40704a9fc787c826660eb7ba0b641b8b175d # 1.26-bookworm
steps:
- shell: bash
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }}
run: |
set -euo pipefail
apt-get update -qq && apt-get install -y -qq --no-install-recommends jq >/dev/null
ctx="github/ci (branch)"; [ "$IS_TAG" = true ] && ctx="github/ci (tag)"
echo "waiting for '$ctx' on $SHA"
while :; do
s="$(curl -fsS -H "Authorization: token $TOKEN" \
"$CI_SERVER_INTERNAL/api/v1/repos/$REPO/commits/$SHA/statuses?limit=50" \
| jq -c --arg c "$ctx" '[.[] | select(.context == $c)] | sort_by(.id) | last // empty')" || s=""
state="$(printf '%s' "$s" | jq -r '.status // .state // empty')"
case "$state" in
success) echo "GitHub: success"; exit 0 ;;
failure|error) echo "GitHub: $state -- $(jq -r '.target_url' <<<"$s")"; exit 1 ;;
esac
sleep 20
done
# 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. When GitHub
# builds, the Release is made there and published only once its files are
# attached; this waits for GitHub's success and announces then. The action
# makes one topic per tag, so a second announce for the same tag is a no-op.
announce:
needs: [release]
if: startsWith(github.ref, 'refs/tags/')
needs: [release, github]
if: ${{ always() && startsWith(github.ref, 'refs/tags/') && (needs.release.result == 'success' || needs.github.result == 'success') }}
runs-on: light
steps:
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
+179
View File
@@ -0,0 +1,179 @@
# CI on GitHub Actions, for the GitHub copy of this repository.
#
# The repository lives on the self-hosted Gitea; GitHub holds a push mirror
# that Gitea updates on every commit. Nothing is merged here -- pull requests
# happen on Gitea, and their branches reach GitHub as ordinary pushes, which is
# why this workflow runs on `push` and not on `pull_request`.
#
# Which forge does the building is one switch, the variable BUILD_ON, set on
# both forges at the organization level:
#
# BUILD_ON=github every job here runs; .gitea/workflows/ci.yml skips its own
# test and release jobs and waits for the status this
# workflow reports back instead.
# unset / other every job here skips; Gitea builds as it always has.
#
# The result goes back to Gitea as one commit status, "github/ci (branch)" or
# "github/ci (tag)", which is what Gitea's `github` job waits on.
#
# Tags do what the Gitea release job does: check the tag is on main, build
# the reproducible archives, and attach them to the Gitea Release -- Gitea is
# where the install guide points, so that is where the files go. The Release
# is created as a draft, filled, then published, so it is never visible with
# assets missing; if an upload fails, a Release this run created is deleted.
# Gitea announces the release once this run has reported success.
#
# Go cross-compiles both architectures from one amd64 runner, so there is no
# arm64 job.
#
# The end-to-end test (e2e job) runs only when this workflow is started by
# hand. e2e/public.sh publishes 25, 80, 443, 465, 993, 995 and 4190 on the
# machine it runs on, so it needs a throwaway VM -- which a GitHub runner is and
# the self-hosted runners are not. It used to run every Monday on its own
# schedule; schedules belong to Gitea now, so for the moment it is a manual
# check: run this workflow from main after ihasmail's weekly release.
#
# Configuration, all at the organization level on GitHub: variables BUILD_ON,
# GITEA_URL; secret GITEA_TOKEN (write:repository on Gitea).
#
# Every `uses:` is pinned to a full commit SHA, with the release it was in the
# trailing comment. Nothing schedules here: schedules belong to Gitea.
name: ci
on:
push:
branches: ['**']
tags: ['**']
# For a run GitHub queued and then orphaned, and for the end-to-end test.
# Run it from a tag to redo that tag's release; attaching skips any file
# already on the Release.
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref_type != 'tag' }}
permissions:
contents: read
env:
GITEA_URL: ${{ vars.GITEA_URL }}
CONTEXT: github/ci (${{ github.ref_type }})
jobs:
# Tells Gitea a result is on its way, so a status that is still missing
# reads as "running" rather than "never started".
pending:
if: vars.BUILD_ON == 'github'
runs-on: ubuntu-latest
steps:
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
-d "$(jq -n --arg c "$CONTEXT" --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}')"
test:
if: vars.BUILD_ON == 'github'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- run: go vet ./...
- run: go test ./...
# `@latest` on purpose: a vulnerability check should use today's
# database, not a pinned copy of last month's.
- run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
release:
if: vars.BUILD_ON == 'github' && github.ref_type == 'tag'
needs: [test]
runs-on: ubuntu-latest
env:
TAG: ${{ github.ref_name }}
steps:
# Full history: the ancestry check cannot be answered from a shallow
# clone.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
# A release can never describe code that was not reviewed onto main.
- run: |
git fetch --quiet origin main
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|| { echo "::error::$TAG is not on main"; exit 1; }
# SOURCE_DATE_EPOCH makes the tarballs reproducible: two builds of one
# tag give byte-identical archives.
- run: |
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$TAG")" scripts/build-release.sh "$TAG" dist
sha256sum dist/*.tar.gz
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
API="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
auth=(-H "Authorization: token $GITEA_TOKEN")
# Reuse the Release if the tag already has one (a re-run), else make a
# draft of our own.
created=0
id="$(curl -fsS "${auth[@]}" "$API/releases/tags/$TAG" 2>/dev/null | jq -r '.id // empty' || true)"
if [ -z "$id" ]; then
id="$(curl -fsS "${auth[@]}" -H 'Content-Type: application/json' \
-d "$(jq -n --arg t "$TAG" '{tag_name:$t, name:$t, draft:true,
body:"Binaries for linux/amd64 and linux/arm64. Verify with SHA256SUMS."}')" \
"$API/releases" | jq -r '.id // empty')"
[ -n "$id" ] || { echo "::error::could not create the release"; exit 1; }
created=1
fi
have="$(curl -fsS "${auth[@]}" "$API/releases/$id/assets" | jq -r '.[].name')"
for f in dist/*; do
n="$(basename "$f")"
if grep -qxF "$n" <<<"$have"; then echo "already attached: $n"; continue; fi
echo "uploading $n"
curl -fsS -o /dev/null "${auth[@]}" --form "attachment=@$f" "$API/releases/$id/assets?name=$n" || {
[ "$created" = 1 ] && curl -sS -o /dev/null "${auth[@]}" -X DELETE "$API/releases/$id"
exit 1
}
done
if [ "$created" = 1 ]; then
curl -fsS -o /dev/null "${auth[@]}" -H 'Content-Type: application/json' -X PATCH \
-d '{"draft":false}' "$API/releases/$id"
fi
# A deploy with no --ihasmail-image takes whatever ihasmail release is
# newest, so this is what notices when a new release stops working with the
# Stalwart and Caddy versions pinned here. Pebble stands in for Let's
# Encrypt and a DNS stub answers every name: nothing but images comes from
# the internet.
e2e:
if: vars.BUILD_ON == 'github' && github.event_name == 'workflow_dispatch'
needs: [test]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- run: e2e/public.sh
report:
if: always() && vars.BUILD_ON == 'github'
needs: [pending, test, release, e2e]
runs-on: ubuntu-latest
steps:
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
STATE: ${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }}
run: |
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
-d "$(jq -n --arg s "$STATE" --arg c "$CONTEXT" --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}')"
-41
View File
@@ -1,41 +0,0 @@
# The end-to-end test, every week, against ihasmail's newest release.
#
# A deploy with no --ihasmail-image takes whatever ihasmail release is newest
# when it runs, rather than one this repository pinned. That is only safe if
# something notices when a new release stops working with the Stalwart and
# Caddy versions pinned here -- this is that something. It runs on Mondays
# three hours after ihasmail's weekly release (09:00 UTC), so a release that
# breaks the one shot shows up the same day, before most people deploy it.
#
# e2e/public.sh needs nothing from the internet but images: Pebble stands in for
# Let's Encrypt and a DNS stub answers every name, and it publishes 25, 80, 443,
# 465, 993, 995 and 4190 on the runner while it runs.
name: End-to-end
on:
schedule:
- cron: "0 12 * * 1"
workflow_dispatch:
concurrency:
group: e2e
cancel-in-progress: false
permissions:
contents: read
jobs:
public:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Vet and test
run: |
go vet ./...
go test ./...
- name: Deploy and check a public stack
run: e2e/public.sh
-69
View File
@@ -1,69 +0,0 @@
# Publish a release when a version tag is pushed: test, check for known
# vulnerabilities, build the Linux binaries, attach them with their checksums.
#
# Tags are the date of the commit, as ihasmail's are: v2026.9.13, and
# v2026.9.13.1 for a second release on the same day.
name: Release
on:
push:
tags: ["v*"]
# For a tag whose run never started. GitHub has queued and then orphaned
# runs before, and a pushed tag has no other way to trigger this again.
workflow_dispatch:
inputs:
tag:
description: "Existing tag to release, e.g. v2026.9.13"
required: true
type: string
concurrency:
group: release-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
env:
TAG: ${{ github.event.inputs.tag || github.ref_name }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.tag || github.ref }}
fetch-depth: 0
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Check the tag names a commit on main
run: |
set -euo pipefail
git fetch --quiet origin main
git merge-base --is-ancestor "$(git rev-parse "$TAG^{commit}")" origin/main \
|| { echo "::error::$TAG is not on main"; exit 1; }
- name: Vet and test
run: |
go vet ./...
go test ./...
- name: Known vulnerabilities
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
- name: Build
run: |
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$TAG")" scripts/build-release.sh "$TAG" dist
- name: Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" dist/* --clobber
else
gh release create "$TAG" dist/* --verify-tag --title "$TAG" --generate-notes
fi