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"
+16 -5
View File
@@ -199,11 +199,15 @@ version newer than its own. So now:
A publish that fails after the local copy into `repo/packages` has spent A publish that fails after the local copy into `repo/packages` has spent
that number. Use the next one. that number. Use the next one.
### Canary ### Canary (Preview)
Since 0.2.6.6 releases normally go through Gitea: built once, recorded as
a Gitea release, published and promoted by workflows that call these same
scripts. [releasing.md](releasing.md) has the whole path. By hand:
``` ```
./update_canary.sh 0.2.6.1 # a maintenance release ./update_canary.sh 0.2.6.7 dist/ekklesia-classroom_0.2.6.7_amd64.deb # gate and publish THAT file
./update_canary.sh 0.2.7 # the next feature release ./update_canary.sh 0.2.7 # build, gate and publish
``` ```
The version is always given. With four parts there are two possible next The version is always given. With four parts there are two possible next
@@ -211,17 +215,24 @@ numbers, and which one a release is depends on what is in it. Run with no
argument it prints the current canary and both candidates, and builds argument it prints the current canary and both candidates, and builds
nothing. nothing.
Given a package, that file is what is gated and published; nothing is
rebuilt, because a second build is a second, untested file. The gate is
`tools/release-gate.sh`: a fresh install, and the upgrade from whatever
canary and stable serve, each on a room of that channel;
`RELEASE_GATE_FROM=0.2.2` adds an older release rooms are still on.
### Stable ### Stable
``` ```
./update_stable.sh 0.2.6.1 ./update_stable.sh 0.2.6.6 <sha256>
``` ```
Only with the owner's written approval for that release. Stable is never Only with the owner's written approval for that release. Stable is never
given a package of its own: it is pointed at one canary already published, given a package of its own: it is pointed at one canary already published,
byte for byte, and refuses a version that is not newer than stable's byte for byte, and refuses a version that is not newer than stable's
current one or whose package the server does not hold with the same current one or whose package the server does not hold with the same
checksum. checksum. With the sha256 — always give it — it refuses any file but the
one that was approved.
### Checking a channel without changing it ### Checking a channel without changing it
+512
View File
@@ -0,0 +1,512 @@
# Releasing Ekklesia Classroom
How a change becomes a package, how that package reaches a classroom, and
who decides each step. Written so it can be followed months from now by
somebody who was not there when it was set up.
The short version: **Gitea is the record, updates.riverofglorymn.org is
the delivery, and a person makes every decision that reaches a room.**
```
pull request ──► CI (checks, package, container gate) automatic, no secrets
│
merge to master or release/X.Y
│
Release 1 - build ──► Gitea pre-release vX.Y.Z: .deb, SHA256SUMS, a person runs it
│ build-info.json, gate report, notes
Release 2 - Preview ──► the same file on canary; read back; recorded a person runs it
│
physical checks on real hardware (docs/testing.md) a person does them
│
Release 3 - Stable ──► the same file on stable; read back; recorded a person runs it
```
Nothing is rebuilt after step 1. The file the gate passed is the file the
laptop installs and the file the desktops install, byte for byte, and its
SHA-256 is written down at every step.
## The tracks
| Track | What it is | Where classrooms get it | Who is on it |
| --- | --- | --- | --- |
| **Stable** | Approved for production: passed the gate, ran on Preview, and passed the physical checks on real hardware | `https://updates.riverofglorymn.org/stable/` | The three classroom desktops |
| **Preview** | A finished release candidate, frozen, waiting for its physical checks | `https://updates.riverofglorymn.org/canary/` — Preview **is** the canary channel | The prayer-room laptop |
| **Nightly** | Development builds of the next version, for lab machines | not yet — see [Nightly](#nightly-designed-not-yet-active) | Nobody yet; never a classroom |
"Preview" is a new name for an old channel. The URL, the manifest and the
laptop's `CHANNEL=canary` are unchanged: renaming a production path would
strand the one machine on it for no gain. The scripts still say canary.
A Gitea release is a **pre-release** while its version is only on Preview
and becomes a full release when it is promoted to Stable.
## Versions
As in `tools/release-lib.sh`: `0.2.6` is a feature release, `0.2.6.1` its
first maintenance release, `0.2.7` the next feature release. Three or four
numbers, no leading zeros, and ordering is always `dpkg --compare-versions`.
**A version is used once.** Once any file of that name has been on the
update server it is never rebuilt or replaced — 0.2.6 was, and no room that
had the first one could ever be offered the second. The release tag is
`v` + the version (`v0.2.6.6`) and is never moved.
## Branches
| Branch | What it holds | Who merges |
| --- | --- | --- |
| `master` | The integration branch: the next version's development (0.3.0 now). Every change arrives by pull request with CI green | Anyone with write access, after review |
| `release/X.Y` | A maintenance line (`release/0.2`): fixes only, each one a pull request, each release built from here. Created when a release has to be maintained while `master` moves on — which is now | Same |
| short-lived branches | One change each (`fix/…`, `feat/…`, `docs/…`), deleted after merge | — |
| `experiment/*` | Work that may never ship. Not built as releases | — |
Why this and not more: the repository has always been one line of work on
`master`, and what it lacked was only a place for 0.2.x to wait while 0.3.0
starts. One maintenance branch per line, created when it is needed, does
that. There is no `develop` branch, because `master` already is one, and no
branch per channel, because a channel is a manifest pointing at a file, not
a line of code.
**Releases are tags, not branches.** `v0.2.6.6` names the exact commit the
package was built from. Stable and Preview are not branches at all.
**Fixes flow forward.** A fix made on `release/0.2` is merged into `master`
afterwards (`git merge release/0.2`, as a pull request), never cherry-picked
backwards from `master`. A merge keeps both lines' histories honest and
makes it impossible to forget a fix in the newer line. If `master` has moved
too far for a clean merge, the fix is re-done on `master` in its own pull
request that says so.
**A frozen candidate.** 0.2.6.6 is tagged `v0.2.6.6` on `release/0.2`. While
it waits for its physical checks, 0.3.0 work lands on `master` and cannot
touch it. If the checks fail, the fix goes to `release/0.2`, becomes 0.2.6.7,
and is merged forward.
### Protecting the branches (Gitea settings)
For `master` and `release/*`: no force push, no deletion, and merging only
from a pull request whose **CI / checks** and **CI / package and container
gate** statuses are green. See [Gitea administration](#gitea-administration-still-to-do).
## Pull requests and commits: CI
`.gitea/workflows/ci.yml`, on every push to any branch and every pull
request, on the `ekklesia-ci` runner, with **no secrets**:
1. **checks** — `tools/check.sh --base <where the change started>`:
- every script's syntax by its `#!` line, every Python module, every JSON
policy and wallpaper metadata file;
- the change's hygiene: `git diff --check`, no `.deb`, `dist/`, `repo/` or
`packaging/root/` committed, and `VERSION` a valid release version;
- the teacher's manual, rebuilt and checked against itself;
- every `tests/test-*.sh`: configuration, migration, desktop layout and
wallpaper convergence, printing (no paper: the queue, the options, the
picture pipeline), Wonder Ink workspace, Presentations, audio, network
and Wi-Fi adoption, the updater and its schedule, release versions and
the release tooling, deployment support, and the Gitea release record.
The log of each is kept as the `check-logs` artifact, with a
`summary.md` table.
2. **package and container gate** — builds the package the commit's
`VERSION` describes, proves it says that version, and installs it on a
clean Debian 13 container with `tools/release-gate.sh --ci`: fresh, and as
an upgrade from whatever canary and stable serve. Between releases
`master`'s `VERSION` equals what canary already serves, so an upgrade
that is not possible is recorded as **SKIP**, with the reason, not
failed. The package contents and the gate report are kept; **the package
itself is not**, because it is not a release and must never be mistaken
for one.
A test added to `tests/` runs in both jobs, and in every release build, with
nothing else to edit: `tools/build-release.sh` runs the same
`tools/check.sh` before it builds.
What CI does not do: the GUI lab (`tools/lab/`), which needs the nested
Plasma session on the development VM, and anything on real hardware. Those
are run by hand before a release, and their results written into the
release commit and the Gitea release.
## Building a release
1. On `master` (a feature release) or `release/X.Y` (maintenance), make
the release commit: `VERSION` set to the new version and the manual
rebuilt, nothing else:
```
echo 0.2.6.7 > VERSION && tools/build-manual
git commit -m "release: 0.2.6.7, for the canary machine only" VERSION docs/manual/build
```
and merge it by pull request like anything else.
2. Run the GUI lab against a local build (`tools/lab/dirty-upgrade`, from
the release stable rooms are on and from canary's), and note the result.
3. In Gitea: **Actions → Release 1 - build → Run workflow**, on that branch,
with the version (and `0.2.2` or whatever older release rooms are still
on, to rehearse that upgrade too).
`tools/release-ci.sh build` then:
- refuses unless the branch is `master` or `release/*`, the checkout is
clean, and `VERSION` already says the version;
- refuses a version that has a Gitea release or tag, whose package is on the
update server, or that is not newer than what canary and stable serve;
- builds with `SOURCE_DATE_EPOCH` set to the commit's time, so the manual's
revision date is the commit's and the build changes nothing in the tree —
and **fails if it did change anything**, because then the package would not
be the commit;
- writes `SHA256SUMS` and `build-info.json` (version, package, sha256, full
commit, branch, time, the run that built it, and the version dpkg reads
from the file);
- runs the full gate, `tools/release-gate.sh`, on that file;
- creates the Gitea **pre-release** `vX.Y.Z` tagged at the commit, with the
package, `SHA256SUMS`, `build-info.json`, the gate report, the check
summary, and notes listing the changes since the previous tag.
The sha256 it prints is the release's name from then on.
## Publishing to Preview
**Actions → Release 2 - publish to Preview (canary) → Run workflow**, with the
version and its sha256 from the Gitea release. Two jobs:
- **gate**, on `ekklesia-ci` with no secret: downloads the release's `.deb`,
proves it is that sha256, that the release's `SHA256SUMS` says so, and that
dpkg reads the version from it; runs the full gate again, against what
canary and stable serve *now*; attaches the report to the release under a
new name.
- **publish**, on `ekklesia-release` with the update server's key:
downloads the same file and that report and runs
`update_canary.sh VERSION PACKAGE` with the report. `update_canary.sh`:
- refuses a version that has ever been published, or is not newer than
everything that has (asked of the server itself, over ssh);
- accepts the gate report only if it names this file's sha256, passed, and
upgraded from exactly the versions canary and stable serve now — a report
made before either moved is refused, and the gate has to be run again;
- uploads the package with `rsync --ignore-existing` and proves the server's
copy is the same bytes, writes the manifest **after** the package, swaps
`canary/latest.deb` by rename **after** the manifest, and reads all three
back over HTTPS the way a classroom does.
`tools/release-ci.sh preview` then checks that exactly one file was added
to the server's packages and none changed, and records on the Gitea release
that the file went to Preview, with its sha256 and the read-back.
The laptop takes it at its next update check (daily at 03:00, caught up after
a night off) and reboots once nobody is using it.
## Physical approval
The gate and the lab cannot stand in for a real television, a real lid, a
real DVD drive, real speakers or a real reboot. `docs/testing.md` lists what
a person checks for each release on real hardware — for 0.2.6.6, the section
[Before 0.2.6.6 goes to stable](testing.md#before-0266-goes-to-stable).
Only a person does these, and only a person records them. Nothing in CI
claims a physical check passed.
## Promoting to Stable
**Actions → Release 3 - promote to Stable → Run workflow**, with the version,
its sha256, the approval (who, when, which checks, on which machine), and the
confirmation `PROMOTE X.Y.Z TO STABLE`. On `ekklesia-release`:
- it refuses unless the Gitea release records **that sha256** going to
Preview: only what Preview ran can be promoted;
- `update_stable.sh VERSION SHA256` refuses any other file, refuses a version
not newer than stable, refuses unless the server already holds exactly that
package, then points `stable/manifest.json` and `stable/latest.deb` at it
and reads them back over HTTPS. **No build, no upload, no new file**;
- it proves no package on the server changed, records the promotion and the
approval on the Gitea release, and marks it no longer a pre-release.
The desktops take it at their next 03:00 check.
Merging code never promotes anything. There is no path from a push to
Stable that does not pass through a person running this workflow.
## Without a runner: the same steps by hand
Every workflow step is a script, so a release never depends on a runner
being up. From a checkout with the update server in `~/.ssh/config` as
`classroom-control` (as the development VM has always had) and a Gitea token
with repository write access:
```
export GITEA_URL=https://git.riverofglorymn.org
export GITEA_REPO=River_of_Glory_Church/Ekklesia_Classroom
export GITEA_TOKEN=… # never committed
tools/release-ci.sh build 0.2.6.7 0.2.2
tools/release-ci.sh gate 0.2.6.7 <sha256>
tools/release-ci.sh preview 0.2.6.7 <sha256> <gate-report name>
tools/release-ci.sh stable 0.2.6.7 <sha256> "physical checks passed on …, by …, on …"
```
And, as before and still true, the underlying tools alone:
```
./update_canary.sh 0.2.6.7 dist/ekklesia-classroom_0.2.6.7_amd64.deb # gate + publish that file
./update_stable.sh 0.2.6.7 <sha256> # promote that file
```
`update_canary.sh VERSION` with no package still builds one first, for
compatibility; handing it the file you tested is the better habit.
## Immutability: three locks
1. **The update server.** `release_check_new_version` refuses a version whose
file name has ever been used or that is not newer than every version ever
published; `rsync --ignore-existing` will not overwrite a package even if
that check were wrong; every upload is read back by sha256.
2. **The Gitea release.** Gitea itself lets anyone with write access delete an
asset and upload another of the same name, and Gitea 1.27 has no setting
to stop it. `tools/gitea-release` is the only thing the workflows use to
write releases, and it uploads an asset only under a name the release does
not have; the same bytes again are left alone; different bytes fail
loudly and change nothing. A tag is never moved. The record under
"## Record" is only appended to. A person with write access can still
delete things in the web UI — don't; nothing automated will put them back.
3. **The checksum everywhere.** The sha256 is in the Gitea release, its
`SHA256SUMS`, `build-info.json`, the gate reports, the update server's
manifest and the release commit's follow-up. Preview and Stable both
require it as an input and refuse a file that does not match.
## Gitea Releases and updates.riverofglorymn.org
| | Gitea release `vX.Y.Z` | updates.riverofglorymn.org |
| --- | --- | --- |
| Purpose | The permanent record: why, from what, how it was checked, what happened | Delivery to classrooms |
| Read by | People | Each classroom's updater, daily |
| Holds | The `.deb`, SHA256SUMS, build info, gate reports, notes, the record of Preview and Stable | `packages/*.deb`, and per channel `manifest.json` and `latest.deb` |
| Decides what a room installs | No | Yes: the channel's manifest |
The two always hold the same bytes. The update server stays the only thing
a classroom talks to: a Gitea outage, a Gitea upgrade or a Gitea migration
never touches a room.
## Rollback and recovery
**A classroom never installs an older version.** The updater only takes a
version newer than its own (it has to: otherwise two channels could
ping-pong a room), and `update_stable.sh` refuses to move stable backwards,
because a room would ignore it anyway. So:
- **A bad release on Preview only** (the laptop): do not promote it. Fix
forward: the fix, or a revert, becomes the next version, through the same
gate and Preview. The bad file stays on the server — it is a version that
was published, and deleting it would only hide what the laptop is running.
- **A bad release on Stable**: the same, faster. Build the next version from a
revert of the bad change (on `release/X.Y`), gate it, Preview it, and
promote it as soon as the specific failure is shown fixed on real hardware.
Stable points at a *new* version whose content is the last good one.
- **One room that must go back now**, before a fix exists: by hand on that
machine, `sudo apt-get install --allow-downgrades ./ekklesia-classroom_<good>_amd64.deb`
with the file from its Gitea release, then reboot. It stays there until a
newer version is published to its channel.
- **A publish interrupted half way**: the manifest is written after the
package and `latest.deb` after the manifest, so a room either sees the old
release or the whole new one. Re-running the same workflow is safe: the
version checks refuse to publish twice, so check the Gitea release's record
and `release_verify_channel` before re-running anything.
- **A lost Gitea**: the update server still has every package, and the tags
are in every clone. `Release - import an existing release` rebuilds the
record from the server's files.
## Secrets, variables and credentials
None of these are in the repository, and none are given to CI.
| Name | Kind | Holds | Used by |
| --- | --- | --- | --- |
| `CLASSROOM_UPDATES_SSH_KEY` | repository **secret** | The private key the release runner uses to publish to the update server | Release 2 (publish job), Release 3 |
| `CLASSROOM_UPDATES_ADDRESS` | repository variable | The update server's address from the runner (`10.40.0.2`) | Release 2, 3 |
| `CLASSROOM_UPDATES_USER` | repository variable | The account that key logs in as | Release 2, 3 |
| `CLASSROOM_UPDATES_KNOWN_HOSTS` | repository variable | The server's ssh host key line(s), so the job refuses any other host | Release 2, 3 |
| `RELEASE_API_TOKEN` | repository secret, **optional** | A token that may write releases. Only needed if the automatic Actions token ever cannot; measured on Gitea 1.27.2, it can | Release 1, 2, 3, import |
| automatic Actions token | provided by Gitea per job | Creates and records releases | Release 1, 2, 3, import |
Variables are not secret: a pull request's jobs can read them (measured). Put
nothing in a variable that would matter if it were printed.
**Least privilege for the publish key.** Today the development VM publishes
as `media@10.40.0.2`, and `media` is in the `sudo` and `docker` groups on that
host — which also runs Gitea, Home Assistant and Vaultwarden. That key must
**not** be the one put in Gitea. Before the release workflows are used:
1. On 10.40.0.2, create an account used for nothing else (say
`classroom-publish`), in no group but one that may write
`/srv/ekklesia/classroom-updates` — not `sudo`, not `docker`.
2. Generate a new key pair for it on a trusted machine; put the public half
in its `authorized_keys` with `restrict,from="<release runner address>"`;
put the private half in `CLASSROOM_UPDATES_SSH_KEY`; delete the local copy.
3. Set the three variables above.
The scripts need `ls`, `test`, `sha256sum`, `cat`, `ln`, `mv` and `rsync`
there, and nothing else.
## Runners
| Label | Runs | Needs | Must not have |
| --- | --- | --- | --- |
| `ekklesia-ci` | CI on every push and pull request; Release 1; the gate job of Release 2; import | Debian 13, Docker (the container gate), git, python3, dpkg-deb, curl, rsync, nodejs (for `actions/checkout`), the fonts and tools the tests use — in practice, a VM set up like the development VM | Any credential for the update server or a classroom; a route to classrooms it does not need |
| `ekklesia-release` | Release 2's publish job, Release 3 | ssh, rsync, curl, python3, dpkg-deb, git, nodejs; network to 10.40.0.2 | Docker; any key on its disk; any other label |
| `classroom-hw-*` (future) | Hardware checks, by hand only | A real classroom computer in the lab | Anything to do with production rooms |
**How the separation holds, and where it doesn't** — measured on a local
Gitea 1.27.2 with act_runner 0.6.1:
- A pull request from a **fork** does not run at all until a maintainer
approves it. Approved, it gets **no secrets**. It does get the variables
and a token, and it runs on whichever runner its workflow names —
including `ekklesia-release`, if it asks for that label.
- A branch pushed to this repository runs with the repository's secrets.
**Anyone with write access can publish**, by writing a workflow that uses the
key. Give write access accordingly.
So: the key lives only in Gitea's secret store, never on a runner's disk,
where an approved fork job could read it. The `ekklesia-release` runner runs
each job in a fresh container (`ekklesia-release:docker://node:22-trixie`,
without the Docker socket), so no job can leave anything behind for the
next. **Never approve a fork's run that changes `.gitea/workflows/`**
without reading it.
`ekklesia-ci` needs Docker for the container gate, and Docker is root on its
host. Treat that host as something any approved pull request can take over:
a VM with nothing on it worth taking, and no key to anything else.
**Registering a runner** (on the runner's host, as the account it runs as):
```
act_runner generate-config > config.yaml
# edit runner.labels in config.yaml: [ "ekklesia-ci:host" ]
# (measured: labels in the config file OVERRIDE --labels at registration;
# a generated config lists ubuntu-latest etc. and the runner then
# advertises those, and every job here waits for ever)
act_runner register --no-interactive -c config.yaml \
--instance https://git.riverofglorymn.org --token <from Gitea> --name ekklesia-ci-1
act_runner daemon -c config.yaml # as a systemd service
```
The registration token is under the repository's **Settings → Actions →
Runners**. Register at repository level, not instance level, so these
runners take no other repository's jobs.
Artifacts: `actions/upload-artifact@v3` works on Gitea 1.27.2; **v4 fails**
("not supported on GHES", measured). The workflows use v3.
## Nightly (designed, not yet active)
For installable 0.3.0 development builds, separate from a frozen Preview.
- **Source:** `master`, a commit whose CI passed.
- **When:** once a night if `master` changed since the last nightly, and on
demand. Not every commit: a lab machine that reboots into a new build every
hour tests nothing.
- **Version:** `<next>~nightly.<YYYYMMDD>.<run>`, for example
`0.3.0~nightly.20261014.3`. The `~` makes it sort **before** `0.3.0` and
after every 0.2.x, and each night after the last — so a lab machine moves
to the real 0.3.0 when it ships. The release version rules
(`tools/release-lib.sh`, and the same pattern in the updater) refuse `~`
today; they must accept it for the nightly channel only.
- **Server:** `/nightly/manifest.json`, `/nightly/latest.deb`, and packages in
`/nightly/packages/` — **not** `/packages/`. The rule that a release must be
newer than everything ever published reads `/packages/`; a nightly there
would make 0.2.6.7 "older" than a 0.3.0 nightly and block every
maintenance release.
- **Retention:** the last 14 nightlies on the server; older ones deleted from
`/nightly/packages/` only. No Gitea release per nightly; the build is a
workflow artifact, kept as long as Gitea keeps artifacts.
- **Opting in:** a lab machine's `device.conf` says `CHANNEL=nightly`, and its
first nightly is installed by hand.
- **Never a classroom:** the nightly updater refuses `CHANNEL=nightly` unless
`/etc/ekklesia-classroom/lab-machine` exists, and a nightly package refuses
to install without it. A nightly machine never reads the stable or canary
manifest, and it cannot drift onto them: its version is newer than any
0.2.x, so pointed at stable it would simply sit until stable passes it.
Taking it back to 0.2.x is a reinstall with `--allow-downgrades`, by hand.
- **Credential:** its own key, allowed to write `/srv/ekklesia/classroom-updates/nightly`
and nothing else (`CLASSROOM_NIGHTLY_SSH_KEY`).
What activating it needs, which is why it is not active: the updater and
release-lib changes above ship **inside the package**, so they are 0.3.0
work; plus the server directory, the key, and a `nightly.yml` workflow. That
is the next infrastructure task, after the backlog audit decides what 0.3.0
is.
## The hardware lab (designed, not yet built)
Spare classroom computers, each a runner labelled `classroom-hw-<kind>`
(`classroom-hw-desktop`, `classroom-hw-laptop`), registered to this
repository, in a lab network with no path to production rooms, on
`CHANNEL=nightly` or on whatever a job installs.
- **Triggered only by hand** (`workflow_dispatch`) with a version and sha256 —
never by a pull request or a push.
- **Installs the exact file** from its Gitea release through apt, proves dpkg
reports that version, then runs checks from `tools/hw/` (to be written):
HDMI connect/disconnect (the display count and the desktop's wallpaper
before and after), HDMI audio (the sink appears, is chosen, plays),
the DVD drive (seen, a disc read), the printer (queue state; a real page
only with an explicit input), Wi-Fi recovery (down, up, reconnected with
nobody typing), sleep/wake (`rtcwake`, and what is standing after), and
reboot/update (a job that reboots the machine and a second job that reads
what came back).
- **Reports** one line per check into `hardware-report-<machine>-<date>.txt`:
```
AUTO PASS hdmi-hotplug 2 -> 1 -> 2 outputs; desktop 1 kept its wallpaper
AUTO FAIL dvd-read no disc in /dev/sr0
MANUAL - picture-visible not checked by a machine
```
attached to the Gitea release, with a record line summarising it.
`AUTO` lines come only from a script that measured something. `MANUAL`
lines are only ever `-` from a machine; a person's result is written by the
person, in the Stable workflow's approval, and nowhere else. A machine
never ticks a physical check.
- **Does not block** CI or releases. Until it exists, physical checks are
done by a person from `docs/testing.md`, exactly as now.
## Gitea administration still to do
Things only a Gitea administrator can do, none of which this repository can
do for itself:
- [ ] Say which host the existing "Ekklesia" runner is on, and its labels. It
is not on 10.40.0.2, and the Gitea API does not show runners to anyone
but an administrator. Relabel it `ekklesia-ci:host` (in its config
file, see above) if it is fit for that role, or register a new one.
- [ ] Set up an `ekklesia-release` runner as above: container mode, no Docker
socket, repository level.
- [ ] Create the `classroom-publish` account and key on 10.40.0.2, and the
secret and three variables above.
- [ ] Protect `master` and `release/*`: no force push, no deletion, required
status checks **CI / checks** and **CI / package and container gate**.
- [ ] Under the repository's Actions settings, keep approval required for
runs from forks (the default, measured).
- [ ] Run **Release - import an existing release** for 0.2.6.6 (version,
sha256 and commit are in the release commit and in
`docs/testing.md`) so its Gitea release exists before its promotion.
## What was measured
On a throwaway Gitea 1.27.2 with act_runner 0.6.1 (the versions production
runs), on the development VM, 2026-09-29:
| Question | Answer |
| --- | --- |
| Do all five workflows parse, and plan only on their own events? | Yes: CI on push and pull_request; the release workflows only on a manual run |
| Does the CI checks job pass on a real runner? | Yes: 30 of 30, and its logs upload as an artifact |
| Can the automatic token create a release, attach an asset, edit the notes? | Yes |
| Does `tools/gitea-release` refuse different bytes under a published name, on real Gitea? | Yes |
| Do `github.event.inputs.X` and `inputs.X` both work? | Yes |
| Do job outputs pass from a job on one runner to a job on another? | Yes |
| Do `vars.*` and `secrets.*` reach a job, and is the secret masked? | Yes, and yes |
| `upload-artifact@v3` / `@v4`? | v3 works; v4 fails ("not supported on GHES") |
| Do labels given to `act_runner register` survive a generated config? | **No**: the config's labels win |
| Does a fork's pull request run without approval? | No: held until a maintainer approves |
| Approved, does it get secrets? | No. It gets variables, a token, and any runner it names |
Not measured, because it needs the real update server's key in a real
runner: the publish and promote jobs end to end. Their scripts are the ones
used by hand since 0.2.6, and `tests/test-release-ci.sh` runs them against a
stand-in server and a stand-in Gitea.
+276
View File
@@ -0,0 +1,276 @@
#!/bin/bash
# Preview, Stable and import, end to end, as the Gitea workflows run them.
#
# Nothing here reaches the real update server or the real Gitea. The
# update server is a directory served over HTTP on the loopback, with ssh
# and rsync stand-ins that act on it (as in test-release-versions.sh);
# Gitea is tests/fake-gitea.py. What runs is the real thing:
# tools/release-ci.sh, update_canary.sh, update_stable.sh,
# tools/release-gate.sh --still-current and tools/gitea-release.
#
# The container gate itself needs Docker and is not run here: a gate
# report stands in for it, and the question is whether a report that
# must not be relied on ever is.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
PIDS=()
cleanup() {
for pid in "${PIDS[@]}"; do kill "$pid" 2>/dev/null; done
rm -rf "$WORK"
}
trap cleanup EXIT
passed=0
failed=0
check() {
local label="$1"
shift
if "$@" >"$WORK/last.log" 2>&1; then
echo " ok $label"
passed=$((passed + 1))
else
echo " FAIL $label"
sed 's/^/ /' "$WORK/last.log" | tail -n 8
failed=$((failed + 1))
fi
}
refuses() {
! "$@"
}
# Refused, and for the reason given: a refusal for some other reason
# would pass a test that means nothing.
refuses_saying() {
local reason="$1" out
shift
out="$("$@" 2>&1)" && return 1
grep -qF -- "$reason" <<< "$out" || { printf '%s\n' "$out"; return 1; }
}
fake_deb() {
local version="$1" out="$2" stage="$WORK/stage-$version-$RANDOM"
mkdir -p "$stage/DEBIAN"
printf 'Package: ekklesia-classroom\nVersion: %s\nArchitecture: amd64\nMaintainer: test\nDescription: test %s\n' \
"$version" "${3:-}" > "$stage/DEBIAN/control"
dpkg-deb --root-owner-group --build "$stage" "$out" >/dev/null
}
sha() { sha256sum "$1" | awk '{print $1}'; }
# ---------------------------------------------------------------------------
# The update server: stable on 0.2.6.4, canary on 0.2.6.5.
SERVER="$WORK/server"
mkdir -p "$SERVER/packages" "$SERVER/stable" "$SERVER/canary" "$WORK/bin"
for version in 0.2.2 0.2.6.4 0.2.6.5; do
fake_deb "$version" "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb"
done
point() {
local channel="$1" version="$2" name="ekklesia-classroom_$2_amd64.deb"
printf '{\n "version": "%s",\n "package": "/packages/%s",\n "sha256": "%s",\n "requires_reboot": true\n}\n' \
"$version" "$name" "$(sha "$SERVER/packages/$name")" > "$SERVER/$channel/manifest.json"
ln -sfn "../packages/$name" "$SERVER/$channel/latest.deb"
}
point stable 0.2.6.4
point canary 0.2.6.5
cat > "$WORK/bin/ssh" <<'EOF'
#!/bin/bash
shift
exec bash -c "$*"
EOF
cat > "$WORK/bin/rsync" <<'EOF'
#!/bin/bash
keep=0
args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--ignore-existing) keep=1 ;;
-e) shift ;;
-*) ;;
*) args+=("$1") ;;
esac
shift
done
source="${args[0]}"
dest="${args[1]#*:}"
[[ -d "$dest" ]] && dest="$dest/$(basename "$source")"
[[ "$keep" == 1 && -e "$dest" ]] && exit 0
cp "$source" "$dest"
EOF
chmod +x "$WORK/bin/ssh" "$WORK/bin/rsync"
port() { python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])'; }
HTTP_PORT="$(port)"
python3 -m http.server --bind 127.0.0.1 --directory "$SERVER" "$HTTP_PORT" >/dev/null 2>&1 &
PIDS+=($!)
python3 "$ROOT/tests/fake-gitea.py" > "$WORK/gitea-port" 2>/dev/null &
PIDS+=($!)
for _ in $(seq 1 50); do
[[ -s "$WORK/gitea-port" ]] && curl -fsS "http://127.0.0.1:$HTTP_PORT/stable/manifest.json" >/dev/null 2>&1 && break
sleep 0.1
done
export PATH="$WORK/bin:$PATH"
export RELEASE_HOST=stand-in RELEASE_ROOT="$SERVER" RELEASE_URL="http://127.0.0.1:$HTTP_PORT"
export GITEA_URL="http://127.0.0.1:$(cat "$WORK/gitea-port")" GITEA_REPO=church/classroom GITEA_TOKEN=not-a-real-token
unset GITHUB_RUN_NUMBER GITHUB_REF GITHUB_OUTPUT RELEASE_SSH_KEY
CI="$ROOT/tools/release-ci.sh"
GITEA="$ROOT/tools/gitea-release"
COMMIT="$(git -C "$ROOT" rev-parse HEAD)"
ci() { RELEASE_CI_WORK="$(mktemp -d -p "$WORK")" "$CI" "$@"; }
notes() { "$GITEA" notes "$1"; }
serves() { python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["version"])' "$SERVER/$1/manifest.json"; }
packages() { (cd "$SERVER/packages" && sha256sum -- *.deb); }
# A release as "Release 1 - build" leaves it: the package and its
# SHA256SUMS, in a Gitea pre-release.
make_release() {
local version="$1" dir="$WORK/built-$1" name="ekklesia-classroom_$1_amd64.deb"
mkdir -p "$dir"
fake_deb "$version" "$dir/$name"
(cd "$dir" && sha256sum "$name" > SHA256SUMS)
printf 'notes\n' > "$dir/notes.md"
"$GITEA" create "v$version" "$COMMIT" "Ekklesia Classroom $version" "$dir/notes.md" --prerelease >/dev/null
"$GITEA" upload "v$version" "$dir/$name" "$dir/SHA256SUMS" >/dev/null
sha "$dir/$name"
}
# A gate report as tools/release-gate.sh writes one.
report() {
local out="$1" version="$2" hash="$3" canary="$4" stable="$5" mode="${6:-release}"
local name="ekklesia-classroom_${version}_amd64.deb"
{
echo "release gate for $name"
echo "sha256 $hash"
echo "mode $mode"
echo "started 2026-09-29T00:00:00Z"
echo "PASS fresh install of $version (a new machine, on stable) (5 min)"
echo "PASS upgrade $canary -> $version on a canary room (canary serves $canary) (5 min)"
echo "PASS upgrade $stable -> $version on a stable room (stable serves $stable) (5 min)"
echo "finished 2026-09-29T00:20:00Z: $name passed the release gate"
} > "$out"
}
H6="$(make_release 0.2.6.6)"
PKG6="$WORK/built-0.2.6.6/ekklesia-classroom_0.2.6.6_amd64.deb"
mkdir -p "$WORK/reports"
report "$WORK/reports/gate-report-preview-good.txt" 0.2.6.6 "$H6" 0.2.6.5 0.2.6.4
report "$WORK/reports/gate-report-preview-other.txt" 0.2.6.6 "$(printf 'f%.0s' {1..64})" 0.2.6.5 0.2.6.4
report "$WORK/reports/gate-report-preview-stale.txt" 0.2.6.6 "$H6" 0.2.6.3 0.2.6.4
report "$WORK/reports/gate-report-preview-ci.txt" 0.2.6.6 "$H6" 0.2.6.5 0.2.6.4 ci
head -n -1 "$WORK/reports/gate-report-preview-good.txt" > "$WORK/reports/gate-report-preview-unfinished.txt"
"$GITEA" upload v0.2.6.6 "$WORK"/reports/*.txt >/dev/null
before="$(packages)"
echo "1. a gate report is only relied on when it is about these bytes, now"
GATE="$ROOT/tools/release-gate.sh"
check "a passing release report for this file, from what the channels serve, is current" \
"$GATE" --still-current "$WORK/reports/gate-report-preview-good.txt" "$PKG6"
check "one about other bytes is not" \
refuses_saying "it is about other bytes" "$GATE" --still-current "$WORK/reports/gate-report-preview-other.txt" "$PKG6"
check "nor one made before canary moved" \
refuses_saying "canary serves 0.2.6.5 now" "$GATE" --still-current "$WORK/reports/gate-report-preview-stale.txt" "$PKG6"
check "nor a CI gate's report" \
refuses_saying "not a release gate's report" "$GATE" --still-current "$WORK/reports/gate-report-preview-ci.txt" "$PKG6"
check "nor one that never finished" \
refuses_saying "does not end in a pass" "$GATE" --still-current "$WORK/reports/gate-report-preview-unfinished.txt" "$PKG6"
echo "2. Preview: the release's file, to canary, on a current report"
check "a checksum that is not the release's is refused" \
refuses_saying "is not sha256 eeee" ci preview 0.2.6.6 "$(printf 'e%.0s' {1..64})" gate-report-preview-good.txt
check "a stale report is refused" \
refuses_saying "canary serves 0.2.6.5 now" ci preview 0.2.6.6 "$H6" gate-report-preview-stale.txt
check "a report for other bytes is refused" \
refuses_saying "it is about other bytes" ci preview 0.2.6.6 "$H6" gate-report-preview-other.txt
check "and after all of that, canary is where it was and no package was added" \
bash -c "[[ \"\$(python3 -c 'import json; print(json.load(open(\"$SERVER/canary/manifest.json\"))[\"version\"])')\" == 0.2.6.5 ]] \
&& [[ \"\$(cd '$SERVER/packages' && sha256sum -- *.deb)\" == \"$before\" ]]"
check "the release's file is published to canary" \
ci preview 0.2.6.6 "$H6" gate-report-preview-good.txt
check "canary serves it, and latest.deb is that file" \
bash -c "[[ \$(python3 -c 'import json; m=json.load(open(\"$SERVER/canary/manifest.json\")); print(m[\"version\"], m[\"sha256\"])') == '0.2.6.6 $H6' ]] \
&& cmp -s '$SERVER/canary/latest.deb' '$PKG6'"
check "exactly one package was added, and no other changed" \
bash -c "diff <(printf '%s\n' \"$before\") <(cd '$SERVER/packages' && sha256sum -- *.deb) | grep -c '^[<>]' | grep -qx 1 \
&& ! diff <(printf '%s\n' \"$before\") <(cd '$SERVER/packages' && sha256sum -- *.deb) | grep -q '^<'"
check "stable was not touched" test "$(serves stable)" = 0.2.6.4
check "the Gitea release records it, with the sha256" \
bash -c "'$GITEA' notes v0.2.6.6 | grep -qF 'published to Preview (canary), sha256 \`$H6\`'"
check "publishing it again is refused" \
refuses_saying "0.2.6.6 has already been published" ci preview 0.2.6.6 "$H6" gate-report-preview-good.txt
echo "3. Stable: only the file Preview ran, only with an approval"
H7="$(make_release 0.2.6.7)"
check "a release that never went to Preview is refused" \
refuses_saying "has no record of $H7 going to Preview" ci stable 0.2.6.7 "$H7" "approved"
check "a checksum other than the one Preview ran is refused" \
refuses_saying "has no record of $H7 going to Preview" ci stable 0.2.6.6 "$H7" "approved"
check "a promotion with no approval is refused" \
refuses_saying "names who approved it" ci stable 0.2.6.6 "$H6" ""
check "stable is still 0.2.6.4 after all of that" test "$(serves stable)" = 0.2.6.4
before="$(packages)"
check "the approved file is promoted" \
ci stable 0.2.6.6 "$H6" "physical checks passed on the laptop"
check "stable serves exactly the file canary does" \
bash -c "[[ \$(python3 -c 'import json; m=json.load(open(\"$SERVER/stable/manifest.json\")); print(m[\"version\"], m[\"sha256\"])') == '0.2.6.6 $H6' ]] \
&& cmp -s '$SERVER/stable/latest.deb' '$SERVER/canary/latest.deb'"
check "no package was added or changed by promoting" test "$(packages)" = "$before"
check "the record has the promotion and its approval" \
bash -c "'$GITEA' notes v0.2.6.6 | grep -qF 'Approval: physical checks passed on the laptop'"
check "and the release is no longer a pre-release" \
bash -c "curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/releases/tags/v0.2.6.6' | grep -q '\"prerelease\": false'"
check "the record reads in order: created, Preview, Stable" \
bash -c "'$GITEA' notes v0.2.6.6 | python3 -c 'import sys
t = sys.stdin.read()
order = [t.index(s) for s in (\"release created at\", \"published to Preview\", \"promoted to Stable\")]
assert order == sorted(order), order'"
echo "4. import: a release from before the record, as the server has it"
H5="$(sha "$SERVER/packages/ekklesia-classroom_0.2.6.5_amd64.deb")"
check "a checksum the server's file does not have is refused" \
refuses_saying "is not sha256 $H7" ci import 0.2.6.5 "$H7" "$COMMIT"
check "a commit that does not exist is refused" \
refuses_saying "is not a commit here" ci import 0.2.6.5 "$H5" "$(printf '0%.0s' {1..40})"
check "0.2.6.5 is recorded, with the server's own bytes" \
bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' import 0.2.6.5 '$H5' '$COMMIT' \
&& '$GITEA' download v0.2.6.5 ekklesia-classroom_0.2.6.5_amd64.deb '$WORK/imported.deb' \
&& cmp -s '$WORK/imported.deb' '$SERVER/packages/ekklesia-classroom_0.2.6.5_amd64.deb'"
check "no channel serves it now, so the record claims neither" \
bash -c "! '$GITEA' notes v0.2.6.5 | grep -qE 'published to Preview|promoted to Stable'"
# A room of another era: stable serving 0.2.6.4, as it did before 0.2.6.6.
point stable 0.2.6.4
H4="$(sha "$SERVER/packages/ekklesia-classroom_0.2.6.4_amd64.deb")"
check "a release a channel serves is recorded as on it, read off the server" \
bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' import 0.2.6.4 '$H4' '$COMMIT' \
&& '$GITEA' notes v0.2.6.4 | grep -qF 'promoted to Stable, sha256 \`$H4\`' \
&& ! '$GITEA' notes v0.2.6.4 | grep -q 'published to Preview' \
&& curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/releases/tags/v0.2.6.4' | grep -q '\"prerelease\": false'"
check "a release that already has a record is not imported over it" \
refuses_saying "its record is kept as it is" ci import 0.2.6.6 "$H6" "$COMMIT"
check "a Gitea that cannot be asked is never taken as having no release" \
refuses_saying "could not ask Gitea whether v0.2.6.9 exists" \
env GITEA_URL=http://127.0.0.1:9 bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' import 0.2.6.9 '$H5' '$COMMIT'"
echo "5. build: only from master or a maintenance branch"
check "a feature branch is refused before anything is built" \
bash -c "GITHUB_REF=refs/heads/feat/something RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' build 0.2.6.8 2>&1 \
| grep -q 'releases are built from master or release/\\*, not refs/heads/feat/something'"
echo
echo "$passed passed, $failed failed"
[[ $failed -eq 0 ]]
+145
View File
@@ -0,0 +1,145 @@
#!/bin/bash
# The Gitea workflows keep the rules docs/releasing.md makes of them.
#
# Read as text, job by job, because the runner that runs this has no YAML
# library and must not need one. The rules are the ones that matter if a
# workflow is edited carelessly later: what a pull request can reach,
# what publishes and when, and that the work stays in the scripts.
# Whether Gitea itself parses and plans them was measured with act_runner
# (docs/releasing.md, "What was measured").
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python3 - "$ROOT" <<'PY'
import re
import sys
from pathlib import Path
root = Path(sys.argv[1])
flows = root / ".gitea/workflows"
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
def read(name):
return (flows / name).read_text()
def triggers(text):
block = re.search(r"^on:\n((?: .*\n|\n)+)", text, re.M)
return set(re.findall(r"^ ([a-z_]+):", block.group(1), re.M)) if block else set()
def jobs(text):
"""{job id: its text}, from the jobs: block."""
body = text[text.index("\njobs:\n") + 7:]
found = {}
for match in re.finditer(r"^ ([a-z_]+):\n((?: .*\n|\n)*)", body, re.M):
found[match.group(1)] = match.group(2)
return found
def runs_on(job):
match = re.search(r"^ runs-on: (\S+)$", job, re.M)
return match.group(1) if match else None
names = sorted(p.name for p in flows.glob("*.yml"))
check("the workflows are the ones documented", names == [
"ci.yml", "release-build.yml", "release-import.yml",
"release-preview.yml", "release-stable.yml"], str(names))
releasing = (root / "docs/releasing.md").read_text()
print("1. CI: every change, no secrets, nothing kept that could be installed")
ci = read("ci.yml")
check("it runs on push and on pull requests", triggers(ci) == {"push", "pull_request"}, str(triggers(ci)))
check("it reads no secret and no token", "secrets." not in ci and "github.token" not in ci)
check("every job runs on the CI runner",
{runs_on(job) for job in jobs(ci).values()} == {"ekklesia-ci"})
check("the checks are tools/check.sh, the same list a release build runs",
"tools/check.sh --base" in ci
and '"$ROOT/tools/check.sh"' in (root / "tools/build-release.sh").read_text())
check("the container gate is the release gate, in its CI mode", "tools/release-gate.sh --ci" in ci)
check("the package is inspected, not kept",
"dist/" not in re.sub(r"(?s)^.*?Keep the package's contents", "", ci)
.split("path:", 1)[-1])
check("building proves the version and changes nothing else in the tree",
'dpkg-deb --field "$deb" Version)" = "$version"' in ci
and "git status --porcelain --untracked-files=no" in ci)
print("2. releases: only by hand, and only through the scripts")
release_flows = [n for n in names if n.startswith("release-")]
for name in release_flows:
text = read(name)
check(f"{name} only runs when a person runs it", triggers(text) == {"workflow_dispatch"},
str(triggers(text)))
# An input pasted into a script is a script anyone who can run the
# workflow can write. Inputs reach the shell as environment only.
runs = "\n".join(re.findall(r"run: \|?\n?((?: .*\n)+|.*\n)", text))
check(f"{name} never pastes an input into a command",
"${{" not in runs, re.findall(r"\$\{\{[^}]*\}\}", runs)[:3])
check(f"{name}'s work is tools/release-ci.sh", "tools/release-ci.sh " in text)
print("3. who holds what")
where = {}
for name in names:
for job_id, job in jobs(read(name)).items():
where[f"{name}:{job_id}"] = (runs_on(job), job)
key_jobs = {k for k, (_, job) in where.items() if "CLASSROOM_UPDATES_SSH_KEY" in job}
check("the update server's key is given to exactly Preview's publish and Stable's promote",
key_jobs == {"release-preview.yml:publish", "release-stable.yml:promote"}, str(key_jobs))
check("and those run only on the release runner",
all(where[k][0] == "ekklesia-release" for k in key_jobs))
check("nothing on the release runner builds or gates (it has no Docker)",
all("release-ci.sh build" not in job and "release-ci.sh gate" not in job
and "release-gate.sh" not in job
for runner, job in where.values() if runner == "ekklesia-release"))
check("nothing on the CI runner is given the key",
all("CLASSROOM_UPDATES_SSH_KEY" not in job
for runner, job in where.values() if runner == "ekklesia-ci"))
check("the key is a secret; where it goes is a variable",
all("secrets.CLASSROOM_UPDATES_SSH_KEY" in where[k][1]
and "vars.CLASSROOM_UPDATES_KNOWN_HOSTS" in where[k][1] for k in key_jobs))
print("4. Preview and Stable")
preview = jobs(read("release-preview.yml"))
check("Preview gates on the CI runner, then publishes on the release runner",
runs_on(preview["gate"]) == "ekklesia-ci" and runs_on(preview["publish"]) == "ekklesia-release"
and "needs: gate" in preview["publish"])
check("the publish job relies on that gate's report, by name",
"needs.gate.outputs.report" in preview["publish"])
check("Preview asks for the sha256 of the file", "sha256:" in read("release-preview.yml"))
stable = read("release-stable.yml")
check("Stable asks for the sha256, an approval and a typed confirmation",
all(f" {field}:" in stable for field in ("sha256", "approval", "confirm"))
and 'PROMOTE $VERSION TO STABLE' in stable)
check("Stable never builds or gates", "build" not in re.sub(r"#.*", "", stable).replace("rebuild", "")
and "release-gate" not in stable)
print("5. what the measurements found")
check("artifacts are uploaded with v3, which Gitea 1.27 takes (v4 is refused)",
all("upload-artifact@v4" not in read(n) for n in names)
and "upload-artifact@v3" in ci)
check("the release token falls back to Gitea's own, which was measured to work",
all("secrets.RELEASE_API_TOKEN || github.token" in read(n) for n in release_flows))
check("the docs say labels in a runner's config file win over --labels",
"OVERRIDE --labels" in releasing)
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+384
View File
@@ -0,0 +1,384 @@
#!/bin/bash
# THE RELEASE STEPS, AS THE GITEA WORKFLOWS TAKE THEM — OR A PERSON DOES.
#
# tools/release-ci.sh build VERSION [FROM_VERSION...]
# tools/release-ci.sh gate VERSION SHA256
# tools/release-ci.sh preview VERSION SHA256 GATE_REPORT_NAME
# tools/release-ci.sh stable VERSION SHA256 APPROVAL
# tools/release-ci.sh import VERSION SHA256 COMMIT [GATE_REPORT_FILE]
#
# Glue, and only glue. Building is tools/build-release.sh, the gate is
# tools/release-gate.sh, publishing is update_canary.sh and promoting is
# update_stable.sh, exactly as when a person runs them; the record is
# tools/gitea-release. Nothing here decides what a good release is, so
# nothing here can come to disagree with them. See docs/releasing.md.
#
# build from the commit checked out, whose VERSION says VERSION: the
# package, its SHA256SUMS and build-info.json, the gate, and a
# Gitea pre-release vVERSION holding all of it. Refuses a
# version that has a release, a tag or a package already.
# gate the gate again, on the file that release holds, for Preview;
# the report is attached to the release under a new name.
# preview that file, published to canary (Preview) by update_canary.sh
# on that gate report, read back, and recorded.
# stable that file, promoted by update_stable.sh VERSION SHA256 once a
# person has approved it, read back, and recorded.
# import a release published before Gitea kept the record (0.2.6.6):
# the file as the update server serves it, recorded as-is.
#
# CREDENTIALS. build, gate and import need only GITEA_TOKEN, for the
# release. preview and stable also need the update server, through
# RELEASE_SSH_KEY (the private key's text), RELEASE_SSH_KNOWN_HOSTS,
# RELEASE_SSH_ADDRESS and RELEASE_SSH_USER, which only the release runner's
# jobs are given. Run by hand, ~/.ssh/config is used as it always was.
set -euo pipefail
shopt -s inherit_errexit
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
source "$ROOT/tools/release-lib.sh"
GITEA="$ROOT/tools/gitea-release"
WORK="${RELEASE_CI_WORK:-$(mktemp -d)}"
mkdir -p "$WORK"
run_url() {
if [[ -n "${GITHUB_RUN_NUMBER:-}" ]]; then
printf '%s/%s/actions/runs/%s\n' "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_NUMBER"
else
printf 'by hand on %s by %s\n' "$(hostname)" "$(id -un)"
fi
}
actor() { printf '%s\n' "${GITHUB_ACTOR:-$(id -un)}"; }
fail() { echo "release-ci: $*" >&2; exit 1; }
# The version a channel serves, read the way a classroom reads it.
served() {
curl -fsS -H 'Cache-Control: no-cache' "$RELEASE_URL/$1/manifest.json" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])'
}
# Does Gitea have a release vVERSION? Yes, no, or stop: a question Gitea
# could not answer is never taken as "no".
has_release() {
local status=0
"$GITEA" show "v$1" >/dev/null 2>&1 || status=$?
case "$status" in
0) return 0 ;;
3) return 1 ;;
*) fail "could not ask Gitea whether v$1 exists" ;;
esac
}
# Is a package of this name on the update server? Only a 404 is a no.
on_server() {
local code
code="$(curl -sS -o /dev/null -w '%{http_code}' -I "$RELEASE_URL/packages/$1" || true)"
case "$code" in
200) return 0 ;;
404) return 1 ;;
*) fail "could not ask the update server about $1 (HTTP ${code:-none})" ;;
esac
}
require_sha256() {
[[ "$1" =~ ^[0-9a-f]{64}$ ]] || fail "not a SHA-256 checksum: $1"
}
# The release's package, downloaded and proven to be the file named: the
# checksum given, the checksum the release's own SHA256SUMS records, and
# the version dpkg reads out of it all have to agree.
fetch() {
local version="$1" hash="$2" name
name="$(release_package_name "$version")"
"$GITEA" download "v$version" "$name" "$WORK/$name" >&2
"$GITEA" download "v$version" SHA256SUMS "$WORK/SHA256SUMS" >&2
[[ "$(sha256sum "$WORK/$name" | awk '{print $1}')" == "$hash" ]] \
|| fail "v$version's $name is not sha256 $hash"
grep -qx "$hash $name" "$WORK/SHA256SUMS" \
|| fail "v$version's SHA256SUMS does not record $name as $hash"
[[ "$(release_package_version "$WORK/$name")" == "$version" ]] \
|| fail "$name does not say it is $version"
printf '%s\n' "$WORK/$name"
}
# The update server, for the release runner. The key is written to a file
# only this job can read, and is gone when the job's container is.
ssh_setup() {
if [[ -z "${RELEASE_SSH_KEY:-}" ]]; then
echo "release-ci: no RELEASE_SSH_KEY; using this machine's own ssh configuration" >&2
return
fi
[[ -n "${RELEASE_SSH_KNOWN_HOSTS:-}" ]] || fail "RELEASE_SSH_KNOWN_HOSTS is required with RELEASE_SSH_KEY"
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
(umask 077; printf '%s\n' "$RELEASE_SSH_KEY" > "$HOME/.ssh/release-key")
printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
cat > "$HOME/.ssh/config" <<EOF
Host $RELEASE_HOST
HostName ${RELEASE_SSH_ADDRESS:?RELEASE_SSH_ADDRESS is required}
User ${RELEASE_SSH_USER:?RELEASE_SSH_USER is required}
IdentityFile $HOME/.ssh/release-key
IdentitiesOnly yes
StrictHostKeyChecking yes
BatchMode yes
EOF
chmod 600 "$HOME/.ssh/config"
}
# Every package on the server and its checksum, to prove afterwards that
# publishing added one file and changed none.
server_packages() {
release_remote "cd '$RELEASE_ROOT/packages' && sha256sum -- *.deb" | sort
}
# ---------------------------------------------------------------------------
cmd_build() {
local version="$1" commit name out package hash previous
shift
release_require_version "$version"
# Releases are cut from the integration branch or a maintenance branch,
# never from a feature branch that nobody has reviewed.
local ref="${GITHUB_REF:-refs/heads/$(git rev-parse --abbrev-ref HEAD)}"
case "$ref" in
refs/heads/master|refs/heads/release/*) ;;
*) fail "releases are built from master or release/*, not $ref" ;;
esac
[[ -z "$(git status --porcelain --untracked-files=no)" ]] || fail "the checkout has uncommitted changes"
[[ "$(cat VERSION)" == "$version" ]] \
|| fail "VERSION says $(cat VERSION). Commit the release first: VERSION=$version, the manual rebuilt"
commit="$(git rev-parse HEAD)"
name="$(release_package_name "$version")"
# NEVER A VERSION TWICE. Not one Gitea has, not one the update server
# has, and not one that is not newer than what either channel serves.
# update_canary.sh asks the server the authoritative question again,
# over ssh, before anything is published.
if has_release "$version"; then
fail "v$version already has a Gitea release; a version is built once"
fi
if on_server "$name"; then
fail "$name is already on the update server"
fi
for channel in canary stable; do
release_version_gt "$version" "$(served "$channel")" \
|| fail "$channel serves $(served "$channel"); $version is not newer"
done
# The manual's revision date is the commit's, so the build reproduces
# the tree it was built from rather than stamping today on it.
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" CHECK_LOG_DIR="$WORK/checks" \
"$ROOT/tools/build-release.sh" "$version"
[[ -z "$(git status --porcelain --untracked-files=no)" ]] \
|| { git status --short >&2; fail "the build changed the tree; the package is not the commit's"; }
out="$ROOT/dist/release-$version"
rm -rf "$out"
mkdir -p "$out"
package="$out/$name"
cp "$ROOT/dist/$name" "$package"
hash="$(sha256sum "$package" | awk '{print $1}')"
(cd "$out" && sha256sum "$name" > SHA256SUMS)
python3 - "$out/build-info.json" <<PY
import json, sys
json.dump({
"version": "$version",
"package": "$name",
"sha256": "$hash",
"commit": "$commit",
"ref": "$ref",
"built": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"built_by": "$(run_url)",
"dpkg_version": "$(release_package_version "$package")",
}, open(sys.argv[1], "w"), indent=2)
PY
cp "$WORK/checks/summary.md" "$out/checks.md"
RELEASE_GATE_REPORT="$out/gate-report.txt" "$ROOT/tools/release-gate.sh" "$package" "$@"
previous="$(git describe --tags --abbrev=0 --match 'v*' HEAD^ 2>/dev/null || true)"
{
echo "Ekklesia Classroom $version, built from \`$commit\` on \`${ref#refs/heads/}\`."
echo
echo "| | |"
echo "| --- | --- |"
echo "| Package | \`$name\` |"
echo "| SHA-256 | \`$hash\` |"
echo "| Source | \`$commit\` |"
echo "| Built | $(run_url) |"
echo
echo "Status: **built and gated. Not published.** Preview and Stable are"
echo "separate, deliberate steps; see docs/releasing.md."
echo
echo "## Changes${previous:+ since $previous}"
echo
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}HEAD" | head -n 60
echo
echo "## Release gate"
echo
echo '```'
cat "$out/gate-report.txt"
echo '```'
} > "$out/notes.md"
"$GITEA" create "v$version" "$commit" "Ekklesia Classroom $version" "$out/notes.md" --prerelease
"$GITEA" upload "v$version" "$package" "$out/SHA256SUMS" "$out/build-info.json" \
"$out/gate-report.txt" "$out/checks.md"
"$GITEA" record "v$version" "built and gated, sha256 \`$hash\` ($(run_url))"
echo
echo "Built $name"
echo " sha256 $hash"
echo " commit $commit"
echo "Next, deliberately: the Preview workflow, with version $version and this sha256."
[[ -z "${GITHUB_OUTPUT:-}" ]] || printf 'sha256=%s\n' "$hash" >> "$GITHUB_OUTPUT"
}
cmd_gate() {
local version="$1" hash="$2" package report
require_sha256 "$hash"
package="$(fetch "$version" "$hash")"
# A new name every run: a report attached to a release is never replaced.
report="$WORK/gate-report-preview-$(date -u +%Y%m%dT%H%M%SZ).txt"
RELEASE_GATE_REPORT="$report" "$ROOT/tools/release-gate.sh" "$package" ${RELEASE_GATE_FROM:-}
"$GITEA" upload "v$version" "$report"
"$GITEA" record "v$version" "gated for Preview: $(basename "$report") ($(run_url))"
[[ -z "${GITHUB_OUTPUT:-}" ]] || printf 'report=%s\n' "$(basename "$report")" >> "$GITHUB_OUTPUT"
}
cmd_preview() {
local version="$1" hash="$2" report_name="$3" package before after added
require_sha256 "$hash"
package="$(fetch "$version" "$hash")"
"$GITEA" download "v$version" "$report_name" "$WORK/$report_name"
ssh_setup
export RELEASE_LOCAL="$WORK/published"
before="$(server_packages)"
RELEASE_GATE_REPORT_PASSED="$WORK/$report_name" "$ROOT/update_canary.sh" "$version" "$package"
# One file added, and not a byte of any other changed.
after="$(server_packages)"
added="$(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after"))"
[[ -z "$(comm -23 <(printf '%s\n' "$before") <(printf '%s\n' "$after"))" ]] \
|| fail "a package already on the server changed while publishing"
[[ "$added" == "$hash $(basename "$package")" ]] \
|| fail "publishing added [$added], not exactly $(basename "$package")"
release_verify_channel canary "$version" | tee "$WORK/preview.txt"
echo "stable still serves $(served stable)" | tee -a "$WORK/preview.txt"
"$GITEA" record "v$version" "published to Preview (canary), sha256 \`$hash\`, on gate report $report_name; manifest, package and latest.deb read back over HTTPS; no other package changed; stable still serves $(served stable) ($(run_url), $(actor))"
}
cmd_stable() {
local version="$1" hash="$2" approval="$3" package before after body
require_sha256 "$hash"
[[ -n "$approval" ]] || fail "a promotion names who approved it and on what checks"
# Only a file that went to Preview is promoted, and only that file.
body="$("$GITEA" notes "v$version")"
grep -qF "published to Preview (canary), sha256 \`$hash\`" <<< "$body" \
|| fail "v$version has no record of $hash going to Preview; promote only what Preview ran"
package="$(fetch "$version" "$hash")"
ssh_setup
export RELEASE_LOCAL="$WORK/published"
mkdir -p "$RELEASE_LOCAL/packages"
cp "$package" "$RELEASE_LOCAL/packages/"
before="$(server_packages)"
"$ROOT/update_stable.sh" "$version" "$hash"
after="$(server_packages)"
[[ "$before" == "$after" ]] || fail "a package on the server changed while promoting"
"$GITEA" record "v$version" "promoted to Stable, sha256 \`$hash\`, the file Preview ran; read back over HTTPS. Approval: $approval ($(run_url), $(actor))"
"$GITEA" stable "v$version"
}
cmd_import() {
local version="$1" hash="$2" commit="$3" report="${4:-}" name out
require_sha256 "$hash"
name="$(release_package_name "$version")"
out="$WORK/import-$version"
mkdir -p "$out"
git cat-file -e "$commit^{commit}" 2>/dev/null || fail "$commit is not a commit here"
# Only a release with no record yet. One that has a record keeps it.
if has_release "$version"; then
fail "v$version already has a Gitea release; its record is kept as it is"
fi
curl -fsS -o "$out/$name" "$RELEASE_URL/packages/$name"
[[ "$(sha256sum "$out/$name" | awk '{print $1}')" == "$hash" ]] \
|| fail "the update server's $name is not sha256 $hash"
[[ "$(release_package_version "$out/$name")" == "$version" ]] || fail "$name does not say it is $version"
(cd "$out" && sha256sum "$name" > SHA256SUMS)
{
echo "Ekklesia Classroom $version, from \`$commit\`."
echo
echo "| | |"
echo "| --- | --- |"
echo "| Package | \`$name\` |"
echo "| SHA-256 | \`$hash\` |"
echo "| Source | \`$commit\` |"
echo
echo "Built and published before Gitea kept the release record. The"
echo "package here is the file the update server serves, byte for byte."
echo
echo "## Changes"
echo
git log -1 --format=%B "$commit"
} > "$out/notes.md"
"$GITEA" create "v$version" "$commit" "Ekklesia Classroom $version" "$out/notes.md" --prerelease
"$GITEA" upload "v$version" "$out/$name" "$out/SHA256SUMS" ${report:+"$report"}
"$GITEA" record "v$version" "imported from the update server, sha256 \`$hash\` ($(run_url))"
# Where it stands is read off the server, never assumed: the lines the
# stable step looks for are only written if the channel really serves
# this file now.
for channel in canary stable; do
if release_verify_channel "$channel" "$version" >/dev/null 2>&1 \
&& [[ "$(curl -fsS "$RELEASE_URL/$channel/manifest.json" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["sha256"])')" == "$hash" ]]; then
case "$channel" in
canary) "$GITEA" record "v$version" "published to Preview (canary), sha256 \`$hash\`, by update_canary.sh before Gitea kept the record; canary serves it, read back over HTTPS at import" ;;
stable) "$GITEA" record "v$version" "promoted to Stable, sha256 \`$hash\`, by update_stable.sh before Gitea kept the record; stable serves it, read back over HTTPS at import"
"$GITEA" stable "v$version" ;;
esac
fi
done
}
case "${1:-}" in
build) shift; [[ $# -ge 1 ]] || fail "build VERSION [FROM_VERSION...]"; cmd_build "$@" ;;
gate) [[ $# -eq 3 ]] || fail "gate VERSION SHA256"; cmd_gate "$2" "$3" ;;
preview) [[ $# -eq 4 ]] || fail "preview VERSION SHA256 GATE_REPORT_NAME"; cmd_preview "$2" "$3" "$4" ;;
stable) [[ $# -eq 4 ]] || fail "stable VERSION SHA256 APPROVAL"; cmd_stable "$2" "$3" "$4" ;;
import) [[ $# -ge 4 && $# -le 5 ]] || fail "import VERSION SHA256 COMMIT [GATE_REPORT_FILE]"; shift; cmd_import "$@" ;;
*) sed -n '2,8p' "$0" >&2; exit 2 ;;
esac