feat: Gitea CI, and releases built once, previewed and promoted as one file

ci.yml runs tools/check.sh and the container gate on every push and pull
request, with no secrets. Release 1 builds a version from its release
commit on master or release/*, gates it, and keeps it in a Gitea
pre-release. Release 2 gates that file again on the CI runner and
publishes it to canary (Preview) from the release runner. Release 3
promotes the file Preview ran, on a person's approval. Import records a
release from before Gitea kept the record. All of it is
tools/release-ci.sh calling the existing scripts.

docs/releasing.md: tracks, branches, each step, immutability, rollback,
secrets, runners, Nightly and the hardware lab as designs, the Gitea
administration still to do, and what was measured to make it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-29 18:47:42 -05:00
co-authored by Claude Opus 5.5
parent 583f9b1809
commit 22f25854aa
10 changed files with 1643 additions and 5 deletions
+87
View File
@@ -0,0 +1,87 @@
# EVERY PUSH AND EVERY PULL REQUEST: THE REPOSITORY'S OWN CHECKS.
#
# This runs code nobody has reviewed yet. It is given no secret, it only
# ever runs on the ekklesia-ci runner, which holds no credential that
# reaches the update server or a classroom, and nothing it builds is kept
# as something that could be installed. See docs/releasing.md.
#
# The steps are the repository's own scripts. A test added to tests/ is
# run here with nothing in this file to change.
name: CI
on:
push:
branches: ['**']
pull_request:
jobs:
checks:
name: checks
runs-on: ekklesia-ci
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Sources, the manual, every test, and the change's hygiene
env:
PR_BASE: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
# What the change is compared with: the pull request's base, or
# what the branch was before this push, or where it left master.
base="${PR_BASE:-$PUSH_BEFORE}"
if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
base="$(git merge-base HEAD origin/master 2>/dev/null || git rev-list --max-parents=0 HEAD | tail -n 1)"
fi
CHECK_LOG_DIR="$RUNNER_TEMP/checks" tools/check.sh --base "$base"
- name: Summary
if: always()
run: cat "$RUNNER_TEMP/checks/summary.md" 2>/dev/null || echo "no summary was written"
- name: Keep every check's full output
if: always()
uses: actions/upload-artifact@v3
with:
name: check-logs
path: ${{ runner.temp }}/checks
package:
name: package and container gate
needs: checks
runs-on: ekklesia-ci
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build the package this commit describes
run: |
version="$(cat VERSION)"
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" tools/build-release.sh "$version"
deb="dist/ekklesia-classroom_${version}_amd64.deb"
# It is the package it says it is, and building it changed nothing
# in the tree but, perhaps, the manual's revision date.
test "$(dpkg-deb --field "$deb" Package)" = ekklesia-classroom
test "$(dpkg-deb --field "$deb" Version)" = "$version"
test -z "$(git status --porcelain --untracked-files=no -- . ':(exclude)docs/manual/build')"
mkdir -p "$RUNNER_TEMP/package"
{ dpkg-deb --info "$deb"; echo; dpkg-deb --contents "$deb"; } > "$RUNNER_TEMP/package/contents.txt"
sha256sum "$deb" > "$RUNNER_TEMP/package/sha256.txt"
- name: Install it on Debian Trixie, fresh and over what the rooms run
run: |
version="$(cat VERSION)"
# --ci: an upgrade from a release that is not older than this
# commit's VERSION (master between releases) is recorded as
# skipped, not failed. A release build is never --ci.
RELEASE_GATE_REPORT="$RUNNER_TEMP/package/gate-report.txt" \
tools/release-gate.sh --ci "dist/ekklesia-classroom_${version}_amd64.deb"
- name: Keep the package's contents and the gate report (not the package)
if: always()
uses: actions/upload-artifact@v3
with:
name: package-report
path: ${{ runner.temp }}/package
+54
View File
@@ -0,0 +1,54 @@
# BUILD A RELEASE: ONE COMMIT, ONE PACKAGE, KEPT FOR GOOD.
#
# Run by hand from master or a release/* branch, on the release commit:
# the one whose VERSION says the version being released. It builds the
# package from exactly that commit, runs every check and the full
# container gate on that file, and creates the Gitea pre-release
# vVERSION holding the package, SHA256SUMS, build-info.json and the
# reports. Nothing is published: Preview and Stable are their own
# workflows, and each is a person's decision. See docs/releasing.md.
#
# A version that already has a Gitea release, a tag, or a package on the
# update server is refused. A version is built once.
name: Release 1 - build
on:
workflow_dispatch:
inputs:
version:
description: 'The version to build; VERSION at this commit must say the same (e.g. 0.2.6.7)'
required: true
from_versions:
description: 'Older releases rooms are still on, rehearsed as upgrades too (e.g. 0.2.2)'
required: false
default: ''
jobs:
build:
name: build, check, gate and record
runs-on: ekklesia-ci
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build, check, gate, and create the Gitea pre-release
id: build
env:
VERSION: ${{ github.event.inputs.version }}
FROM_VERSIONS: ${{ github.event.inputs.from_versions }}
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
run: |
# shellcheck disable=SC2086 # FROM_VERSIONS is a list of versions
RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh build "$VERSION" $FROM_VERSIONS
- name: Keep the reports with this run as well
if: always()
uses: actions/upload-artifact@v3
with:
name: release-build-reports
path: |
dist/release-*/gate-report.txt
dist/release-*/checks.md
dist/release-*/build-info.json
${{ runner.temp }}/release/checks
+38
View File
@@ -0,0 +1,38 @@
# RECORD A RELEASE PUBLISHED BEFORE GITEA KEPT THE RECORD.
#
# For 0.2.6.6 and anything earlier worth recording: the package is taken
# from the update server as it is served, checked against the SHA-256
# given, and kept in a Gitea release tagged at the commit given. Whether
# it is on Preview or Stable is read off the server, never assumed.
# Nothing is built or published.
name: Release - import an existing release
on:
workflow_dispatch:
inputs:
version:
description: 'A version already on the update server'
required: true
sha256:
description: 'Its SHA-256'
required: true
commit:
description: 'The full commit it was built from'
required: true
jobs:
import:
name: record it
runs-on: ekklesia-ci
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Import
env:
VERSION: ${{ github.event.inputs.version }}
SHA256: ${{ github.event.inputs.sha256 }}
COMMIT: ${{ github.event.inputs.commit }}
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
run: RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh import "$VERSION" "$SHA256" "$COMMIT"
+74
View File
@@ -0,0 +1,74 @@
# PUBLISH TO PREVIEW: THE BUILT FILE, TO THE CANARY CHANNEL.
#
# "Preview" is the canary channel on updates.riverofglorymn.org under a
# friendlier name; the URL and the laptop's device.conf are unchanged.
#
# Two jobs, so that no job has both the update server's key and Docker:
#
# gate on ekklesia-ci: the full container gate, again, on the file
# the Gitea release holds, against what canary and stable
# serve right now. No secret.
# publish on ekklesia-release: update_canary.sh publishes that same
# file on that gate's report — it refuses a report for other
# bytes, or one made before canary or stable moved — then reads
# everything back over HTTPS and records it on the release.
#
# Never automatic. Nothing publishes because a branch was pushed.
name: Release 2 - publish to Preview (canary)
on:
workflow_dispatch:
inputs:
version:
description: 'The version, as built by "Release 1 - build"'
required: true
sha256:
description: 'Its SHA-256, from that release: nothing else is published'
required: true
from_versions:
description: 'Older releases rooms are still on, rehearsed as upgrades too (e.g. 0.2.2)'
required: false
default: ''
jobs:
gate:
name: gate the release's file
runs-on: ekklesia-ci
outputs:
report: ${{ steps.gate.outputs.report }}
steps:
- uses: actions/checkout@v4
- name: The container gate, on exactly that file
id: gate
env:
VERSION: ${{ github.event.inputs.version }}
SHA256: ${{ github.event.inputs.sha256 }}
RELEASE_GATE_FROM: ${{ github.event.inputs.from_versions }}
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
run: RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh gate "$VERSION" "$SHA256"
publish:
name: publish to canary and read it back
needs: gate
runs-on: ekklesia-release
steps:
- uses: actions/checkout@v4
- name: What publishing needs
run: |
command -v ssh rsync curl python3 dpkg-deb git >/dev/null \
|| { apt-get update -qq && apt-get install -y -qq --no-install-recommends \
openssh-client rsync curl python3 dpkg git ca-certificates >/dev/null; }
- name: Publish, on the gate's report, and record it
env:
VERSION: ${{ github.event.inputs.version }}
SHA256: ${{ github.event.inputs.sha256 }}
REPORT: ${{ needs.gate.outputs.report }}
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
RELEASE_SSH_KEY: ${{ secrets.CLASSROOM_UPDATES_SSH_KEY }}
RELEASE_SSH_KNOWN_HOSTS: ${{ vars.CLASSROOM_UPDATES_KNOWN_HOSTS }}
RELEASE_SSH_ADDRESS: ${{ vars.CLASSROOM_UPDATES_ADDRESS }}
RELEASE_SSH_USER: ${{ vars.CLASSROOM_UPDATES_USER }}
run: RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh preview "$VERSION" "$SHA256" "$REPORT"
+57
View File
@@ -0,0 +1,57 @@
# PROMOTE TO STABLE: THE FILE PREVIEW RAN, AND NOTHING ELSE.
#
# Only after a person has done the physical checks in docs/testing.md on
# real hardware and says so here. No build, no gate: stable is pointed at
# the package already on the server, and update_stable.sh refuses any
# file but the SHA-256 given. The Gitea release must already record that
# exact file going to Preview.
#
# Merging code never runs this. Nothing runs it but a person.
name: Release 3 - promote to Stable
on:
workflow_dispatch:
inputs:
version:
description: 'The version on Preview to promote'
required: true
sha256:
description: 'Its SHA-256, exactly as approved'
required: true
approval:
description: 'Who approved it, when, and which physical checks passed on which machine'
required: true
confirm:
description: 'Type: PROMOTE <version> TO STABLE'
required: true
jobs:
promote:
name: promote the approved file and read it back
runs-on: ekklesia-release
steps:
- uses: actions/checkout@v4
- name: What promoting needs
run: |
command -v ssh rsync curl python3 dpkg-deb git >/dev/null \
|| { apt-get update -qq && apt-get install -y -qq --no-install-recommends \
openssh-client rsync curl python3 dpkg git ca-certificates >/dev/null; }
- name: Promote, and record it
env:
VERSION: ${{ github.event.inputs.version }}
SHA256: ${{ github.event.inputs.sha256 }}
APPROVAL: ${{ github.event.inputs.approval }}
CONFIRM: ${{ github.event.inputs.confirm }}
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
RELEASE_SSH_KEY: ${{ secrets.CLASSROOM_UPDATES_SSH_KEY }}
RELEASE_SSH_KNOWN_HOSTS: ${{ vars.CLASSROOM_UPDATES_KNOWN_HOSTS }}
RELEASE_SSH_ADDRESS: ${{ vars.CLASSROOM_UPDATES_ADDRESS }}
RELEASE_SSH_USER: ${{ vars.CLASSROOM_UPDATES_USER }}
run: |
if [ "$CONFIRM" != "PROMOTE $VERSION TO STABLE" ]; then
echo "Confirmation must read exactly: PROMOTE $VERSION TO STABLE" >&2
exit 1
fi
RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh stable "$VERSION" "$SHA256" "$APPROVAL"