17 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 14291fde37 fix: a release job uses only the key it is given, never the runner's own
CI / checks (push) Failing after 2m13s
CI / package and container gate (push) Skipped
Measured on a throwaway Gitea: with the key secret unset, Preview's
publish job would have fallen back to the runner machine's own ssh
configuration — on the development VM, the production publish key — and
published a throwaway 9.9.9 to the real server. It was cancelled before
it ran. And with the key set, the job wrote over the runner account's own
~/.ssh/config and known_hosts.

Now, under Actions, a job without RELEASE_SSH_KEY stops; with it, the key,
its known_hosts and an ssh configuration live in the job's own directory
and every ssh and rsync goes through `ssh -F` (RELEASE_SSH_CONFIG in
release-lib), which reads nothing else. The key is deleted when the job
ends. By hand, nothing changes. Also records the end-to-end measurements
of Release 1 and of Preview's gate job in docs/releasing.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:24:50 -05:00
ekklesiaandClaude Opus 5.5 22f25854aa 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]>
2026-09-29 18:47:42 -05:00
ekklesiaandClaude Opus 5.5 583f9b1809 feat: a CI mode for the gate, and a gate report publishing can rely on
--ci records an upgrade no room could take (master between releases, its
VERSION already on canary) as SKIP instead of failing every commit; its
report can never stand in for a release's. --still-current accepts a
report only for these exact bytes, passed, from exactly what canary and
stable serve now. update_canary.sh takes one through
RELEASE_GATE_REPORT_PASSED, so Preview's publish job needs the key and
not Docker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:47:42 -05:00
ekklesiaandClaude Opus 5.5 0f96019714 feat: gitea-release, a release record that never replaces published bytes
Gitea lets anyone with write access swap a release asset for another of
the same name. tools/gitea-release, which the workflows use for every
release write, uploads only under a name the release lacks, leaves
identical bytes alone, refuses different ones, never moves a tag, and
only appends to the record. Measured on a throwaway Gitea 1.27.2 with the
automatic Actions token; tested here against a stand-in API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:47:42 -05:00
ekklesiaandClaude Opus 5.5 5416112db5 build: one list of checks, run by the release build and by CI alike
tools/check.sh is what build-release.sh ran before building — every
script's syntax, the manual, every tests/test-*.sh — moved, not copied,
so a new test is run by both with nothing else to edit. It now runs every
test even after one fails, keeps each one's output and a summary table
for a CI run, and with --base checks the change's hygiene too.

The manual's revision date honours SOURCE_DATE_EPOCH, so a release built
from a commit a day later ships the manual that commit holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:47:42 -05:00
ekklesiaandClaude Opus 5.5 4413114d2a docs: what a person checks before 0.2.6.6 goes to stable, and the file it is
Six boxes on the laptop: the reboot that waits for an idle, silent
computer, and the television switched on after the computer. The exact
file and its sha256, so that promotion names the bytes canary ran.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:39:37 -05:00
ekklesiaandClaude Opus 5.5 897a5f28ed fix: the gate installs a fresh machine as the stable room it is
A fresh device.conf says CHANNEL=stable, and the gate asked the fresh
install's updater about canary's manifest, which that machine never reads.
Found running the gate on 0.2.6.6: the updater check failed before any
upgrade was tried. The package was not at fault.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:16:10 -05:00
ekklesiaandClaude Opus 5.5 1a338b2d38 release: 0.2.6.6, for the canary machine only
A desktop Plasma makes after the layout has run — the television arriving
after a night-time reboot, as on Tiny Pebbles — now shows the classroom's
wallpaper, and every existing desktop is repaired before the shell starts.
Carries 0.2.6.5's reboot that waits until nobody is using the computer.

Built from this commit, gated on that file, and published to canary as it
is; the file and its sha256 are recorded in the Gitea release v0.2.6.6 and
in docs/testing.md. Stable stays on 0.2.6.4.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:06:18 -05:00
ekklesiaandClaude Opus 5.5 aaa3f8d063 docs: a desktop the layout never saw, and the television check only a room can do
What Tiny Pebbles showed, what the lab measured (a new activity gives the
same bare desktop; a file path as the fallback is ignored; live D-Bus is
refused by the lockdown), the two parts of the fix, and the check with a
real television switched on after a reboot — the one thing the lab cannot
do, because KWin will not start with no screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:06:08 -05:00
ekklesiaandClaude Opus 5.5 a9700536bf fix: the file the gate passed is the file published, and stable takes only the approved one
tools/release-gate.sh is the one gate: a fresh install, and the upgrade
from what canary and stable serve, each on a room of that channel, plus
any older release rooms are still on (RELEASE_GATE_FROM). The releases it
upgrades from are fetched over HTTPS and checked against their manifests,
so it needs no credential and a CI runner can run it.

update_canary.sh VERSION PACKAGE gates and publishes that file without
rebuilding it, since a second build is a second, untested file; the hash
is taken before the gate and checked after it. update_stable.sh VERSION
SHA256 refuses anything but the file that was approved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:54 -05:00
ekklesiaandClaude Opus 5.5 6c04f743ed test: every rehearsal installs with apt and proves which version landed
The dirty-upgrade lab installed the new package with dpkg -i. Since
0.2.6.2 the package conflicts with khelpcenter, which dpkg refuses and apt
resolves, so the 0.2.2 rehearsal left the machine on 0.2.2 and asked every
question of the old release. Old package, new package and the reinstall
now all go through apt, as ekklesia-classroom-update does, and each stops
the run unless dpkg reports exactly that version, fully installed. The
container gate asserts the same of its previous release and its reinstall.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:54 -05:00
ekklesiaandClaude Opus 5.5 9fc86f9d4c test: the wallpaper repair, on Tiny Pebbles' own file, in the lab and on a real install
Unit: Tiny Pebbles' file (desktop 10, panel 1), a healthy River Kids file
left byte for byte, a late desktop beside a good one, idempotence, the
refusal under a running shell. Lab: Plasma makes a bare desktop itself
through a new activity, and the screen is read, not the file. Container
gate: the installed package's fallback is the package, and its repair
fixes the room's file and leaves the panel alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:54 -05:00
ekklesiaandClaude Opus 5.5 7b4ac39cd2 fix: a desktop Plasma makes after the layout has run gets the classroom's wallpaper
Tiny Pebbles came through the 0.2.2 upgrade on Plasma's own purple
wallpaper: its desktop was containment 10, made after the layout had run
(most likely the television arriving after a night-time reboot), with no
wallpaper configured. The look and feel's fallback could not catch it,
because it named a file and Plasma only accepts a wallpaper package there.

The fallback is now the EkklesiaClassroom package, whose one image links to
the classroom's picture. classroom-desktop-wallpaper, run before every
plasmashell start, writes the picture into every desktop containment the
file already has: found by plugin, never by number; panels and everything
else byte for byte; a file that is already right not written at all.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:44 -05:00
ekklesiaandClaude Opus 5.5 530f6f6563 release: 0.2.6.5, for the canary machine only
An update reboots the computer only once nobody has used it for half an
hour and nothing is playing, instead of the moment it is installed. The
update check stays daily at 03:00 local time, Persistent.

Published to canary: ekklesia-classroom_0.2.6.5_amd64.deb, sha256
48919410492d398d103c2fd2e4e5bd0a97761e7032f9438fbb08f1a29c045050, gated
as a fresh install and as an upgrade from 0.2.6.4 (which stable serves),
and rehearsed separately as the upgrade from 0.2.2. Upgraded live in the
lab 0.2.6.2 -> 0.2.6.4 -> 0.2.6.5: the timer stayed enabled, daily at
03:00, Persistent, with its last-check record kept. Stable still answers
0.2.6.4, promoted earlier today; nothing here wrote to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 23:10:26 -05:00
ekklesiaandClaude Opus 5.5 b2cde1f414 docs: when a classroom updates, and when it reboots
Daily at 03:00 local time spread over half an hour, caught up once after a
night switched off, on the device's own channel only; and since 0.2.6.5 a
reboot that waits for half an hour with nobody using the computer and
nothing playing. With the journal commands to see each, and the checks
only real hardware can make.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 22:56:08 -05:00
ekklesiaandClaude Opus 5.5 7586326902 test: the lab measures the update schedule on its own systemd, powered off and on
Switched on at the time: the timer fires once and is next due tomorrow.
Switched off: the lab machine is really stopped across the time and
started after it; the missed check runs once, 50 s after starting, and
never loops. And the reboot-when-idle watcher against real kernel input,
a mouse made with /dev/uinput: no reboot while it moves, a reboot once it
is left alone. 11 checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 22:56:08 -05:00
ekklesiaandClaude Opus 5.5 3c40e849ea fix: an update reboots the computer only once nobody is using it
The update check has been daily at 03:00, Persistent, since 0.1.5 — the
lab's installed stable machine logged it at 03:01 and 03:04 on successive
days — and it stays so. What changes is the reboot after an update. It
happened the moment the update was installed: harmless at 03:00, and a
room restarting itself mid-morning when a computer switched off at 03:00
caught up after being switched on.

Now the updater installs, with every check it had (the device's own
channel, a newer version, the manifest's SHA-256, name and version), and
starts classroom-reboot-when-idle, which reboots once no input device has
produced an event for 30 minutes and no sound card has played for as
long. At 03:00 that is about 03:30; switched off first, the next start is
on the new version. The service has no [Install] and nothing enables it.

The release gate checks after every install and upgrade that the timer is
enabled, daily at 03:00, Persistent, with no boot or repeat trigger and
no drop-in, and that the updater no longer reboots by itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 22:56:08 -05:00
42 changed files with 4660 additions and 122 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"
+1 -1
View File
@@ -1 +1 @@
0.2.6.4 0.2.6.6
+11 -1
View File
@@ -24,8 +24,18 @@ cursorTheme=breeze_cursors
library=org.kde.breeze library=org.kde.breeze
theme=Breeze theme=Breeze
# The picture a desktop gets when nothing has said which. That is every
# desktop Plasma makes on its own after the layout has run: a television
# switched on after the computer, a second screen, a new activity.
#
# A wallpaper PACKAGE NAME, not a file. Plasma's DefaultWallpaper looks it
# up as wallpapers/<name> and nothing else. This line was a path until
# 0.2.6.6, which Plasma cannot load as a package, so every such desktop
# fell back to Plasma's own purple one — measured, and it is what Tiny
# Pebbles showed after the 0.2.2 upgrade. The package is a directory whose
# one image is a link to the classroom's file; see build-release.sh.
[Wallpaper] [Wallpaper]
Image=/usr/share/backgrounds/ekklesia/classroom-calm.png Image=EkklesiaClassroom
[KSplash] [KSplash]
Theme=None Theme=None
@@ -37,6 +37,13 @@ var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
var PANEL_HEIGHT = 76; var PANEL_HEIGHT = 76;
// The idle desktop: the wallpaper, and nothing on it. // The idle desktop: the wallpaper, and nothing on it.
//
// Only the desktops that exist right now. A screen that appears later —
// the television switched on after a reboot at three in the morning, which
// is what Tiny Pebbles had — gets a desktop Plasma makes without this
// file. That one takes the look and feel's default wallpaper, which is the
// classroom's package, and classroom-desktop-wallpaper writes it in by
// name before the shell next starts.
var screens = desktops(); var screens = desktops();
for (var i = 0; i < screens.length; i++) { for (var i = 0; i < screens.length; i++) {
var desktop = screens[i]; var desktop = screens[i];
@@ -0,0 +1,12 @@
{
"KPlugin": {
"Authors": [
{
"Name": "Ekklesia"
}
],
"Id": "EkklesiaClassroom",
"License": "GPLv2+",
"Name": "Ekklesia Classroom"
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
<body> <body>
<div class="sheet"> <div class="sheet">
<h1>Classroom manual</h1> <h1>Classroom manual</h1>
<p class="revision">River of Glory &middot; Revision 26 September 2026</p> <p class="revision">River of Glory &middot; Revision 29 September 2026</p>
<nav id='contents'></nav> <nav id='contents'></nav>
<section id="00-welcome"> <section id="00-welcome">
<h2>Your classroom computer</h2> <h2>Your classroom computer</h2>
+1 -1
View File
@@ -46,7 +46,7 @@ endobj
stream stream
BT /F2 26 Tf 1 0 0 1 62.0 720.0 Tm (Classroom manual) Tj ET BT /F2 26 Tf 1 0 0 1 62.0 720.0 Tm (Classroom manual) Tj ET
BT /F1 13 Tf 1 0 0 1 62.0 677.1 Tm (River of Glory) Tj ET BT /F1 13 Tf 1 0 0 1 62.0 677.1 Tm (River of Glory) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 26 September 2026) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 29 September 2026) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 623.7 Tm (This manual covers every classroom computer in the church. Not every room has every button, so) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 623.7 Tm (This manual covers every classroom computer in the church. Not every room has every button, so) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 608.8 Tm (some pages begin by saying when they apply. If your computer does not have what a page describes,) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 608.8 Tm (some pages begin by saying when they apply. If your computer does not have what a page describes,) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 593.9 Tm (that page is not for your room.) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 593.9 Tm (that page is not for your room.) Tj ET
+60 -5
View File
@@ -71,6 +71,50 @@ The boot provisioning command never upgrades or reconfigures anything already in
never touches a repository it did not add. Classroom software updates never touches a repository it did not add. Classroom software updates
are the OTA channel's job, not this. are the OTA channel's job, not this.
## When a classroom updates
**Every day at 03:00**, local time, spread over half an hour so the rooms
do not all ask at once: `ekklesia-classroom-update.timer`,
`OnCalendar=*-*-* 03:00:00`, `RandomizedDelaySec=30m`. It has been so
since 0.1.5; the lab's installed stable machine logged its checks at 03:01
on 26 September and 03:04 on 27 September.
**A computer that was switched off at 03:00** checks once, soon after it
is next switched on (`Persistent=true`). Measured on the lab's systemd
with the machine really stopped across the time: the missed check ran 50 s
after it started, once, and not again. Nothing else starts the check —
no boot trigger, no repeat — so it cannot loop.
Each check follows only the device's own `CHANNEL` (a stable room stays
on stable), installs only a newer version, and only a package whose
SHA-256, name and version are what the manifest says.
**The reboot after an update waits for nobody to be using the computer**
(since 0.2.6.5). It used to happen the moment the update was installed:
harmless at 03:00, and a room restarting itself mid-morning after a night
switched off. Now `classroom-reboot-when-idle` reboots once no keyboard,
mouse, touchpad or touchscreen has been touched for 30 minutes *and* no
sound has played for as long — a video to the room is not an idle
computer. At 03:00 that is about 03:30. Switched off before then, nothing
is lost: the update is installed and the next start is on it. Until the
reboot the session carries on as it was; the only other thing an update
starts at once, provisioning, does nothing on a machine that is already
set up.
Input is read from the kernel's input devices, whatever the desktop is
doing; sound from the kernel's own playback state (`/proc/asound`). Both
measured in the lab — a mouse made with `/dev/uinput` kept it from
rebooting, and leaving it alone let it — except the sound card, which the
lab does not have: that is a check on real hardware.
The schedule is a file the package installs, not a configuration file,
so every upgrade brings the packaged schedule. The release gate checks it
after a fresh install and after each upgrade.
systemctl list-timers ekklesia-classroom-update.timer # next and last check
journalctl -u ekklesia-classroom-update # what each check did
journalctl -u ekklesia-classroom-reboot-when-idle # a reboot that is waiting
## Permanent download addresses ## Permanent download addresses
``` ```
@@ -155,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
@@ -167,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
+525
View File
@@ -0,0 +1,525 @@
# 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.
The release scripts never use a runner's own ssh keys: under Actions the
key comes only from the secret, is written to the job's own directory with
its own `known_hosts` and an ssh configuration used with `ssh -F` (which
ignores `~/.ssh/config`), and is deleted when the job ends. A job without
the secret stops. **Never put a runner on a machine that holds the publish
key by hand** — the development VM does — in case that rule is ever broken.
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: all 31 checks, and their logs upload as an artifact |
| Does CI's package job build, and run the gate in its CI mode? | Yes: built, the tree unchanged, fresh install and both upgrades passed, the report marked `mode ci` |
| Does **Release 1 - build** work end to end? | Yes, at a throwaway version 9.9.9 against what canary (0.2.6.6) and stable (0.2.6.4) really serve: built from the commit with the tree unchanged, full gate passed, pre-release `v9.9.9` tagged at exactly that commit with the package, SHA256SUMS, build-info.json, gate report and check summary |
| Is a second build of the same version refused? | Yes: "v9.9.9 already has a Gitea release; a version is built once" |
| Does Preview's gate job work? | Yes: it downloaded the release's file, proved it against the sha256 given and SHA256SUMS, passed the full gate, attached a uniquely named report, recorded it, and handed its name to the publish job |
| If the key secret is missing, what would the publish job use? | **This machine's own ssh configuration** — on the development VM, the production publish key: a throwaway 9.9.9 would have gone to the real server. The job was cancelled before it ran, and since then a job under Actions without the key refuses, and one with it uses only its own configuration (`ssh -F`), never the runner's |
| Does a failing check stop a release build? | Yes: an undocumented workflow left in the tree failed `test-workflows`, and nothing was built |
| 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: Preview's publish job and Stable's promote job 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.
+72 -1
View File
@@ -3,6 +3,36 @@
Run against the exact artifact published to canary, on the real endpoint, Run against the exact artifact published to canary, on the real endpoint,
before promoting to stable with `./update_stable.sh`. before promoting to stable with `./update_stable.sh`.
## Before 0.2.6.6 goes to stable
The container gate and the lab passed both of these. Neither can prove
them on real hardware, so a person does, on the prayer-room laptop, which
takes 0.2.6.6 from canary. If every box is ticked, 0.2.6.6 is promoted
**as the same file**, never rebuilt:
```
ekklesia-classroom_0.2.6.6_amd64.deb
sha256 2b972391974265ba220c58ae0a8f287664a42228f062278956b89017bd5a54f7
./update_stable.sh 0.2.6.6 2b972391974265ba220c58ae0a8f287664a42228f062278956b89017bd5a54f7
```
**Waiting to reboot (since 0.2.6.5)** — details under [OTA](#ota):
- [ ] Using the computer after an update has installed: it does not restart
- [ ] A video playing with sound, mouse left alone: it does not restart
- [ ] Left untouched and silent, it restarts about 30 minutes later, on
the new version
- [ ] Switched off before that restart: it starts normally next time, on
the new version, and does not restart again
**The television arriving late (0.2.6.6)** — details under
[the television switched on after a night-time reboot](#the-television-switched-on-after-a-night-time-reboot):
- [ ] Rebooted with the television off at the set (if the room allows it),
then switched on: the classroom's landscape, not Plasma's purple
- [ ] The panel is the room's own: same launchers, same order, clock bottom
right
## Boot ## Boot
- [ ] Cold boot reaches the classroom shell with no terminal or window junk - [ ] Cold boot reaches the classroom shell with no terminal or window junk
@@ -11,6 +41,39 @@ before promoting to stable with `./update_stable.sh`.
- [ ] Dock appears, centred - [ ] Dock appears, centred
- [ ] Clock appears, bottom right - [ ] Clock appears, bottom right
### The television switched on after a night-time reboot
This is how Tiny Pebbles lost its wallpaper after the 0.2.2 upgrade, and
the lab cannot do it: KWin will not start a session with no screen, and
the lab cannot plug one in. The lab proves the same code path through a
new activity (`tools/lab/wallpaper-check`); only a real television
proves that switching it on takes that path. See
[ui-scale.md](ui-scale.md#a-desktop-the-layout-never-saw-0266). Do it once, on a
desktop whose television is switched off at the set, not the remote:
1. [ ] Television **off** at the set. `sudo reboot`, and wait five minutes.
2. [ ] Television **on**. The classroom's landscape is on the screen,
**not** Plasma's purple wallpaper. The panel is the room's own.
3. [ ] From the maintenance terminal (Meta+Ctrl+Alt+Shift+K),
`classroom-desktop-wallpaper --check`. Note what it says. If it
names a desktop that "had no wallpaper configured", the television
really did arrive after the shell, and the screen in step 2 is the
fix working
4. [ ] `systemctl --user restart plasma-plasmashell.service`: the panel
blinks and comes back, open windows stay, the wallpaper is unchanged
5. [ ] `classroom-desktop-wallpaper --check` now says "the classroom
wallpaper already"
6. [ ] Reboot with the television on: the same picture and the same panel
Tiny Pebbles can confirm the cause **before** it takes the release, because
the update reboots it and a login rebuilds the file. If its panel was built
with no screen, the panel is `[Containments][1]` next to the desktop's 10:
```
grep -A6 '^\[Containments\]\[[0-9]*\]$' \
~/.config/plasma-org.kde.plasma.desktop-appletsrc | grep -E '^\[|^plugin='
```
## Provisioning ## Provisioning
- [ ] `journalctl -u ekklesia-classroom-provision` shows a clean run - [ ] `journalctl -u ekklesia-classroom-provision` shows a clean run
@@ -466,7 +529,15 @@ With `LIVE_ENABLED=true`:
- [ ] `./update_canary.sh` publishes package first, manifest last - [ ] `./update_canary.sh` publishes package first, manifest last
- [ ] The endpoint on the canary channel picks the update up - [ ] The endpoint on the canary channel picks the update up
- [ ] SHA-256 verification passes - [ ] SHA-256 verification passes
- [ ] Install succeeds and the endpoint reboots - [ ] Install succeeds; `systemctl list-timers ekklesia-classroom-update.timer`
shows the next check at about 03:00 tomorrow
- [ ] (0.2.6.5) Switched on in the morning after a night off, with an update
waiting: it installs, `journalctl -u ekklesia-classroom-reboot-when-idle`
says it is waiting, and it does **not** restart while you use it
- [ ] Play a video with sound and leave the mouse alone for 40 minutes: no
restart while it plays; half an hour after it stops, it restarts
- [ ] Left untouched with nothing playing, it restarts about 30 minutes after
the update, and comes back on the new version
- [ ] Everything above still works after the update - [ ] Everything above still works after the update
- [ ] Upgrade from 0.1.36 preserves every existing `device.conf` value - [ ] Upgrade from 0.1.36 preserves every existing `device.conf` value
- [ ] `LIVE_ENABLED=false` is appended exactly once on upgrade and remains - [ ] `LIVE_ENABLED=false` is appended exactly once on upgrade and remains
+63
View File
@@ -47,6 +47,69 @@ package installs it at `/usr/share/backgrounds/ekklesia/classroom-calm.png`, the
path used by the packaged Plasma layout. The layout is rebuilt at login, so path used by the packaged Plasma layout. The layout is rebuilt at login, so
upgraded classrooms receive the package wallpaper without per-room copying. upgraded classrooms receive the package wallpaper without per-room copying.
### A desktop the layout never saw (0.2.6.6)
Tiny Pebbles came through the 0.2.2 → 0.2.6.4 upgrade showing Plasma's own
purple wallpaper. Its desktop was containment 10 with no `Wallpaper` group;
River Kids' is containment 1 with one. The package and the picture were the
same on both.
The layout sets the wallpaper on the desktops that exist *while it runs*.
Plasma makes a desktop for every screen or activity that appears later, and
it makes those without the layout. 0.2.2's updater rebooted as soon as it
had installed, at 03:00–03:30. A television switched off at the set is
usually no screen at all, so the layout would have run with no desktop, and
the desktop Plasma made when the television came on was bare. A panel built
with no screen takes number 1 and its eight applets 2–9, which is exactly
how a desktop ends up as 10. **The television is inferred, not observed**.
See the hardware check in [testing.md](testing.md#the-television-switched-on-after-a-night-time-reboot).
Measured in the lab (Plasma 6.3.6, Debian 13):
| What was tried | What happened |
| --- | --- |
| Activity manager delayed 20 s at login | Plasma waited for it; desktop 1, wallpaper right. Not the cause |
| A new activity created mid-session | Plasma made desktop 11 with no `Wallpaper` group, the same shape as Tiny Pebbles' 10. The screen showed Plasma's *Next*; the panel was untouched |
| Look-and-feel default `Image=` set to a **file path** (0.2.2–0.2.6.5) | Ignored. `DefaultWallpaper` in libkworkspace6 looks the value up as a wallpaper **package** under `wallpapers/<name>`, the way Breeze says `Image=Next` |
| The same default set to a **package name**, whose one image links to the file | Desktop 11, still with no `Image`, showed the classroom's picture after a shell restart |
| Setting it live over D-Bus (`evaluateScript`, `plasma-apply-wallpaperimage`) | Refused: "Widgets are locked". The classroom's lockdown forbids the scripting console, and it stays forbidden |
| Disabling the only screen to reproduce a screenless start | KWin refuses ("Disabling all outputs … is not allowed"), and the nested lab cannot hot-plug a screen |
So there are two parts, and they do different jobs:
* **The default is now a package**, `/usr/share/wallpapers/EkklesiaClassroom`.
Its image is a link to the file above, and the look and feel names it.
Any desktop Plasma makes without the layout shows the classroom's picture
with nothing having to run. This is the fix for Tiny Pebbles.
* **`classroom-desktop-wallpaper`** writes `wallpaperplugin=org.kde.image` and
the classroom `Image` into every desktop containment already in the file,
so the configuration says what the screen shows. It finds desktops by
plugin (`org.kde.plasma.folder`, `org.kde.desktopcontainment`), never by
number. It changes nothing else, and it does not write a file that is
already right. It runs as plasmashell's `ExecStartPre`, the one moment
the shell does not own the file: every login, every reboot, every restart
after a crash. At a login it usually finds nothing, because
`classroom-desktop-reset` has just removed the file for the layout to
rebuild.
To apply it in a running session without logging out, from the maintenance
terminal:
```
classroom-desktop-wallpaper --check # read-only
systemctl --user restart plasma-plasmashell.service # the panel blinks, windows stay
```
Run without `--check` while the shell is up, it refuses and prints that
second line rather than edit the file underneath the shell.
Tests: `tests/test-classroom-desktop-wallpaper.sh` (in the build) covers
Tiny Pebbles' own file, a healthy River Kids file, differing numbers,
idempotence and the guard. `tools/lab/wallpaper-check` covers real Plasma,
reading the colour off the screen, and runs inside `tools/lab/dirty-upgrade`.
`tools/validate-trixie-release.sh` covers the installed package when
upgrading from a previous release.
## What the one number reaches ## What the one number reaches
`classroom-ui-scale` prints it, clamped, and everything that cannot read `classroom-ui-scale` prints it, clamped, and everything that cannot read
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Put the classroom's wallpaper on every desktop the shell has.
Tiny Pebbles came up after the 0.2.2 -> 0.2.6.4 upgrade on Plasma's own
purple wallpaper, with the right panel and the right package installed.
Its desktop was containment 10, with no Wallpaper group at all:
[Containments][10]
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][10][General]
positions={"1920x1080":[]}
The layout script writes the wallpaper, but only onto the desktops that
exist while it runs. Plasma makes a desktop for every screen, and every
activity, that appears afterwards — and it makes them without the layout.
0.2.2's updater rebooted at three in the morning with the television
switched off at the set, so the layout ran with no screen and no desktop
at all, and the desktop Plasma made when the television came on was
bare. Reproduced in the lab through a new activity, which takes the same
path through the shell: containment 11, the same four lines, Plasma's
wallpaper.
Two things now close that. The look and feel's default wallpaper is a
real wallpaper package, so a desktop Plasma makes on its own comes up
with the classroom's picture — that is the fix, and it needs nothing to
run. And this, which writes the picture into every desktop the file
already has, so that the configuration says what the screen shows.
WHICH CONTAINMENT IS THE DESKTOP is answered by what it is, never by its
number: a top-level containment whose plugin is one of Plasma's desktop
containments. Tiny Pebbles' desktop was 10 and River Kids' is 1, and the
panel's number is whatever was free when it was built.
ONLY THE DESKTOPS ARE TOUCHED. Every panel, applet and line that is not a
desktop's wallpaper is written back byte for byte, and a file that is
already right is not written at all.
WHEN IT RUNS. Before plasmashell starts, as the shell's own ExecStartPre,
because the shell owns this file: editing it underneath a live shell was
measured to destroy the panel. That is every start — a login, a reboot,
and the restart after a crash — and at a login it usually finds nothing,
because classroom-desktop-reset has just removed the file for the layout
to rebuild. Run by hand, it refuses while the shell is running and says
how to apply it. Live D-Bus scripting is not an option: the classroom
locks the desktop and forbids the scripting console, and Plasma answers
"Widgets are locked" — measured.
classroom-desktop-wallpaper repair, if the shell is stopped
classroom-desktop-wallpaper --check say what is wrong, change nothing
classroom-desktop-wallpaper FILE work on FILE instead
"""
import os
import re
import sys
WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png"
WALLPAPER_PLUGIN = "org.kde.image"
# Plasma's two desktop containments: Folder View, which is what the
# classroom's desktops are, and the plain Desktop. Panels, the system tray
# a 0.2.2-era profile may still carry and anything else are not desktops.
DESKTOP_PLUGINS = ("org.kde.plasma.folder", "org.kde.desktopcontainment")
# What the layout writes onto a desktop, so a repaired desktop and a
# freshly built one say the same thing.
WALLPAPER_KEYS = (
("FillMode", "2"),
("Image", f"file://{WALLPAPER}"),
)
APPLETSRC = "plasma-org.kde.plasma.desktop-appletsrc"
CONTAINMENT = re.compile(r"^\[Containments\]\[(\d+)\]$")
# key=value, with KConfig's optional [$i]-style flags or [locale] after
# the key. The flags are kept when a value is replaced.
ENTRY = re.compile(r"^([^=\[\s]+)((?:\[[^\]]*\])*)=(.*)$")
def say(message):
print(f"classroom-desktop-wallpaper: {message}", flush=True)
def groups(lines):
"""[(name, header, end)]: lines[header] is the group's header line, or
-1 for the lines before the first group, and end is exclusive."""
found = []
name, header = None, -1
for index, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
found.append((name, header, index))
name, header = stripped, index
found.append((name, header, len(lines)))
return found
def find_group(lines, name):
for group, header, end in groups(lines):
if group == name:
return header, end
return None
def entry(line):
match = ENTRY.match(line.rstrip("\n"))
return (match.group(1), match.group(2), match.group(3)) if match else None
def desktops(lines):
"""The ids of the desktop containments, whatever they are numbered."""
ids = []
for name, header, end in groups(lines):
match = CONTAINMENT.match(name or "")
if not match:
continue
for line in lines[header + 1:end]:
parsed = entry(line)
if parsed and parsed[0] == "plugin" and parsed[2] in DESKTOP_PLUGINS:
ids.append(match.group(1))
break
return ids
def set_entry(lines, group, key, value):
"""Make `key=value` true in an existing group. Returns what changed,
or None when it already was."""
header, end = find_group(lines, group)
last = header
for index in range(header + 1, end):
parsed = entry(lines[index])
if lines[index].strip():
last = index
if parsed and parsed[0] == key:
if parsed[2] == value:
return None
lines[index] = f"{key}{parsed[1]}={value}\n"
return f"{key} was [{parsed[2]}]"
lines.insert(last + 1, f"{key}={value}\n")
return f"{key} was missing"
def add_group(lines, containment, group):
"""A new group, placed after the last of its containment's own."""
prefix = f"[Containments][{containment}]"
after = None
for name, header, end in groups(lines):
if name == prefix or (name or "").startswith(prefix + "["):
after = end
# Back over the blank lines that separate it from the next group, so
# the new one sits with its containment and the spacing stays as
# KConfig writes it.
while after > 0 and not lines[after - 1].strip():
after -= 1
if after > 0 and not lines[after - 1].endswith("\n"):
lines[after - 1] += "\n"
block = ["\n", f"{group}\n"]
block += [f"{key}={value}\n" for key, value in WALLPAPER_KEYS]
lines[after:after] = block
def converge(text):
"""The file as it should be, and what was changed to make it so."""
lines = text.splitlines(keepends=True)
changes = []
for containment in desktops(lines):
top = f"[Containments][{containment}]"
change = set_entry(lines, top, "wallpaperplugin", WALLPAPER_PLUGIN)
if change:
changes.append(f"desktop {containment}: {change}")
group = f"{top}[Wallpaper][{WALLPAPER_PLUGIN}][General]"
if find_group(lines, group) is None:
add_group(lines, containment, group)
changes.append(f"desktop {containment}: had no wallpaper configured")
continue
for key, value in WALLPAPER_KEYS:
change = set_entry(lines, group, key, value)
if change:
changes.append(f"desktop {containment}: {change}")
return "".join(lines), changes
def default_path():
home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
return os.path.join(home, APPLETSRC)
def shell_running(proc="/proc"):
"""plasmashell running as this user. Read from /proc rather than asked
of systemd, so a shell started any way at all counts, and rather than
of pgrep, which nothing guarantees is installed."""
uid = os.getuid()
for pid in os.listdir(proc):
if not pid.isdigit():
continue
try:
with open(os.path.join(proc, pid, "comm"), encoding="utf-8") as handle:
name = handle.read().strip()
if name == "plasmashell" and os.stat(os.path.join(proc, pid)).st_uid == uid:
return True
except OSError:
# Gone while it was being looked at.
continue
return False
def write(path, text):
"""Replace the file whole, so a full disk or a power cut leaves the old
one rather than half of a new one."""
temporary = f"{path}.classroom-new"
mode = os.stat(path).st_mode & 0o7777
with open(temporary, "w", encoding="utf-8") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, mode)
os.replace(temporary, path)
def main(argv):
check = "--check" in argv
paths = [arg for arg in argv if not arg.startswith("--")]
path = paths[0] if paths else default_path()
try:
with open(path, "r", encoding="utf-8") as handle:
before = handle.read()
except FileNotFoundError:
say("no desktop configuration yet; the layout writes the wallpaper")
return 0
except OSError as error:
say(f"cannot read {path}: {error}")
return 0
after, changes = converge(before)
found = desktops(before.splitlines(keepends=True))
if not found:
say("no desktop in the configuration yet; Plasma's default is the "
"classroom's wallpaper package")
return 0
if not changes:
say(f"desktop {', '.join(found)}: the classroom wallpaper already")
return 0
for change in changes:
say(change)
if check:
say("not changed (--check)")
return 1
# The shell owns the live file. Anything else is just a file.
if not paths and shell_running():
say("plasmashell is running, and editing its file underneath it")
say("destroys the panel. Apply it with:")
say(" systemctl --user restart plasma-plasmashell.service")
say("which repairs the file before the shell starts again.")
return 1
try:
write(path, after)
except OSError as error:
say(f"cannot write {path}: {error}")
return 1
say(f"the wallpaper is now configured on desktop {', '.join(found)}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Reboot after an update, but only once nobody is using the computer.
The nightly update check (ekklesia-classroom-update.timer, 03:00, and
Persistent so that a computer switched off at 03:00 checks when it is next
switched on) used to reboot the moment an update was installed. At 03:00
that is harmless. Switched on in the morning after a night off, it meant a
classroom computer restarting itself half an hour into the day, in front
of a teacher.
So the updater installs, then starts this, and this reboots only when:
nothing has touched any keyboard, mouse, touchpad or touchscreen for
IDLE_SECONDS — seen here as kernel input events, which is what every
one of them produces, whatever the desktop is doing; and
no sound has played for as long — a video to the room has no input for
an hour and is not an idle computer, and the end of it is not the
moment to restart either. Seen as the kernel's own playback state
(/proc/asound/.../status says RUNNING while a stream plays).
At 03:00 that is about 03:30. In the middle of a morning it is half an
hour after the teacher last touched it. If the computer is switched off
first, nothing is lost: the update is installed, and the next start is on
it. Until then the running session carries on as it was.
The time starts when this starts: input from before cannot be seen, so a
computer is only called idle after this has watched it be idle.
classroom-reboot-when-idle [--idle-seconds N] [--reboot-command CMD...]
The options are for the lab; the service runs it with none.
"""
import argparse
import glob
import os
import select
import subprocess
import sys
import time
IDLE_SECONDS = 30 * 60
# How often it looks: input is noticed at once, the rest once a minute is
# plenty for a half-hour decision.
CHECK_SECONDS = 60
INPUT_GLOB = "/dev/input/event*"
ASOUND_ROOT = "/proc/asound"
def log(message):
print(f"[classroom-reboot-when-idle] {message}", flush=True)
def audio_playing(root=ASOUND_ROOT):
"""Is any sound card playing right now?"""
for status in glob.glob(os.path.join(root, "card*", "pcm*p", "sub*", "status")):
try:
with open(status, encoding="utf-8", errors="replace") as handle:
if handle.readline().strip() == "state: RUNNING":
return True
except OSError:
continue
return False
def should_reboot(now, last_input, idle_seconds, playing):
"""The decision: long enough without input, and nothing playing."""
return not playing and now - last_input >= idle_seconds
class Inputs:
"""Every input device the kernel has, reopened as they come and go."""
def __init__(self, pattern=INPUT_GLOB):
self.pattern = pattern
self.open = {}
def rescan(self):
present = set(glob.glob(self.pattern))
for fd, path in list(self.open.items()):
if path not in present:
os.close(fd)
del self.open[fd]
known = set(self.open.values())
for path in sorted(present - known):
try:
self.open[os.open(path, os.O_RDONLY | os.O_NONBLOCK)] = path
except OSError:
continue
def wait(self, seconds):
"""True if anything happened on any device within SECONDS."""
if not self.open:
time.sleep(seconds)
return False
ready, _, _ = select.select(list(self.open), [], [], seconds)
seen = False
for fd in ready:
try:
while os.read(fd, 4096):
seen = True
except BlockingIOError:
pass
except OSError:
# A device that went away mid-read; the next rescan drops it.
pass
return seen
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--idle-seconds", type=float, default=IDLE_SECONDS)
parser.add_argument("--check-seconds", type=float, default=CHECK_SECONDS)
parser.add_argument("--input-glob", default=INPUT_GLOB)
parser.add_argument("--asound-root", default=ASOUND_ROOT)
parser.add_argument("--reboot-command", nargs="+", default=["systemctl", "reboot"])
args = parser.parse_args(argv)
inputs = Inputs(args.input_glob)
inputs.rescan()
last_input = time.monotonic()
log(f"an update is installed; rebooting once nobody has used this computer for "
f"{round(args.idle_seconds / 60)} minutes and nothing is playing "
f"(watching {len(inputs.open)} input devices)")
while True:
deadline = time.monotonic() + args.check_seconds
while time.monotonic() < deadline:
if inputs.wait(max(0.0, min(5.0, deadline - time.monotonic()))):
last_input = time.monotonic()
inputs.rescan()
playing = audio_playing(args.asound_root)
if playing:
# Sound counts as someone there: a 40-minute video ending is
# not the moment to restart, so the half hour starts after it.
last_input = time.monotonic()
if should_reboot(time.monotonic(), last_input, args.idle_seconds, playing):
log("nobody has used it, and nothing is playing: rebooting")
return subprocess.run(args.reboot_command).returncode
if __name__ == "__main__":
sys.exit(main())
+7 -2
View File
@@ -14,6 +14,7 @@ from urllib.parse import urljoin
CONFIG = "/etc/ekklesia-classroom/device.conf" CONFIG = "/etc/ekklesia-classroom/device.conf"
LOCKFILE = "/run/ekklesia-classroom-update.lock" LOCKFILE = "/run/ekklesia-classroom-update.lock"
REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"
CACHE_DIR = "/var/cache/ekklesia-classroom" CACHE_DIR = "/var/cache/ekklesia-classroom"
PACKAGE = "ekklesia-classroom" PACKAGE = "ekklesia-classroom"
@@ -291,8 +292,12 @@ def main():
if args.no_reboot: if args.no_reboot:
log("Update requires reboot, but --no-reboot was specified.") log("Update requires reboot, but --no-reboot was specified.")
else: else:
log("Update requires reboot. Rebooting now.") # Not now. At 03:00 nobody is there; after a missed 03:00,
subprocess.run(["systemctl", "reboot"]) # checked when the computer is next switched on, somebody
# usually is. classroom-reboot-when-idle waits until nobody
# has touched it for half an hour and nothing is playing.
log("Update requires reboot. Rebooting once nobody is using this computer.")
subprocess.run(["systemctl", "start", "--no-block", REBOOT_WHEN_IDLE])
return 0 return 0
@@ -0,0 +1,10 @@
[Unit]
Description=Reboot after an Ekklesia Classroom update, once nobody is using the computer
Documentation=file:///usr/local/bin/classroom-reboot-when-idle
# Started by ekklesia-classroom-update after it installs an update that
# needs a reboot; never at boot. A computer switched off before it
# reboots simply starts on the new version.
[Service]
Type=simple
ExecStart=/usr/local/bin/classroom-reboot-when-idle
@@ -16,3 +16,9 @@ StartLimitIntervalSec=0
[Service] [Service]
Restart=always Restart=always
RestartSec=2 RestartSec=2
# Every desktop the configuration already has gets the classroom's
# wallpaper written into it, in the one moment the shell is not running to
# own the file — at a login, and at every restart after a crash. See
# classroom-desktop-wallpaper. The "-" is because a panel matters more than
# a wallpaper: nothing that script can do is allowed to stop the shell.
ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""A stand-in for the part of Gitea 1.27's API that tools/gitea-release uses.
Tags, releases and assets in memory. Writes need the token
"not-a-real-token", as Gitea needs a real one. Like Gitea, it takes a
second asset under a name a release already has: refusing that is
tools/gitea-release's job, and the tests check that it does. GET
/test/tamper/TAG/NAME changes an asset's bytes, the way somebody could in
the web UI. Prints the port it listens on, then serves until killed.
"""
import json, re
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs, unquote
TOKEN = "token not-a-real-token"
PREFIX = "/api/v1/repos/church/classroom"
state = {"tags": {"v0.1.5": "1" * 40}, "releases": [], "next": 1}
def find(tag):
return next((r for r in state["releases"] if r["tag_name"] == tag), None)
class Gitea(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def reply(self, status, body=None, raw=None):
data = raw if raw is not None else json.dumps(body).encode() if body is not None else b""
self.send_response(status)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def authorised(self):
if self.headers.get("Authorization") != TOKEN:
self.reply(401, {"message": "token is required"})
return False
return True
def release_json(self, r):
port = self.server.server_port
return {**{k: v for k, v in r.items() if k != "assets"},
"html_url": f"http://127.0.0.1:{port}/church/classroom/releases/tag/{r['tag_name']}"}
def do_GET(self):
path = urlparse(self.path).path
if m := re.fullmatch(PREFIX + r"/releases/tags/(.+)", path):
r = find(unquote(m.group(1)))
return self.reply(200, self.release_json(r)) if r else self.reply(404, {})
if m := re.fullmatch(PREFIX + r"/tags/(.+)", path):
sha = state["tags"].get(unquote(m.group(1)))
return self.reply(200, {"name": m.group(1), "commit": {"sha": sha}}) if sha else self.reply(404, {})
if m := re.fullmatch(PREFIX + r"/releases/(\d+)/assets", path):
r = next((r for r in state["releases"] if r["id"] == int(m.group(1))), None)
if not r:
return self.reply(404, {})
port = self.server.server_port
return self.reply(200, [{"id": i, "name": n, "size": len(d),
"browser_download_url": f"http://127.0.0.1:{port}/download/{r['id']}/{n}"}
for i, (n, d) in enumerate(r["assets"].items())])
if m := re.fullmatch(r"/download/(\d+)/(.+)", path):
r = next((r for r in state["releases"] if r["id"] == int(m.group(1))), None)
data = r and r["assets"].get(unquote(m.group(2)))
return self.reply(200, raw=data) if data is not None else self.reply(404, {})
# For the test: somebody replaces an asset by hand in the web UI.
if m := re.fullmatch(r"/test/tamper/(.+)/(.+)", path):
find(unquote(m.group(1)))["assets"][unquote(m.group(2))] = b"tampered"
return self.reply(200, {})
return self.reply(404, {})
def do_POST(self):
if not self.authorised():
return
url = urlparse(self.path)
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
if url.path == PREFIX + "/releases":
data = json.loads(body)
if find(data["tag_name"]):
return self.reply(409, {"message": "release exists"})
state["tags"].setdefault(data["tag_name"], data["target_commitish"])
r = {"id": state["next"], "tag_name": data["tag_name"], "name": data["name"],
"body": data["body"], "prerelease": data["prerelease"], "draft": False, "assets": {}}
state["next"] += 1
state["releases"].append(r)
return self.reply(201, self.release_json(r))
if m := re.fullmatch(PREFIX + r"/releases/(\d+)/assets", url.path):
r = next(r for r in state["releases"] if r["id"] == int(m.group(1)))
name = parse_qs(url.query)["name"][0]
boundary = self.headers["Content-Type"].split("boundary=")[1].encode()
part = body.split(b"--" + boundary)[1]
content = part.split(b"\r\n\r\n", 1)[1].rsplit(b"\r\n", 1)[0]
# Gitea itself happily takes a second asset of the same name.
r["assets"][name] = content
return self.reply(201, {"name": name})
return self.reply(404, {})
def do_PATCH(self):
if not self.authorised():
return
path = urlparse(self.path).path
if m := re.fullmatch(PREFIX + r"/releases/(\d+)", path):
r = next(r for r in state["releases"] if r["id"] == int(m.group(1)))
data = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
r.update({k: v for k, v in data.items() if k in ("body", "prerelease")})
return self.reply(200, self.release_json(r))
return self.reply(404, {})
server = HTTPServer(("127.0.0.1", 0), Gitea)
print(server.server_port, flush=True)
server.serve_forever()
+408
View File
@@ -0,0 +1,408 @@
#!/bin/bash
# Every desktop shows the classroom's wallpaper, whatever it is numbered.
#
# Tiny Pebbles came through the 0.2.2 upgrade with its desktop as
# containment 10 and no wallpaper configured on it, and showed Plasma's own
# purple one. These run the real repair against that file, against a
# machine that was already right, and against the shapes in between, and
# then check that every place naming the wallpaper names the same one.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python3 - "$ROOT" <<'PY'
import importlib.util
import json
import os
import re
import sys
import tempfile
from importlib.machinery import SourceFileLoader
from pathlib import Path
root = Path(sys.argv[1])
loader = SourceFileLoader("wallpaper", str(root / "scripts/classroom-desktop-wallpaper"))
spec = importlib.util.spec_from_loader("wallpaper", loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
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
IMAGE = "Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png"
def group_texts(text):
"""{group name: its lines}, for saying what did and did not change."""
lines = text.splitlines(keepends=True)
return {
name: "".join(lines[header:end]).rstrip("\n")
for name, header, end in module.groups(lines)
}
def containment_ids(text):
return sorted(re.findall(r"^\[Containments\]\[(\d+)\]$", text, re.M))
# A panel built with no screen present takes id 1 and its applets 2 to 9,
# which is how Tiny Pebbles' desktop came to be 10. Its containment is as
# it was read off the machine.
PANEL_1 = """[ActionPlugins][0]
MiddleButton;NoModifier=org.kde.paste
RightButton;NoModifier=org.kde.contextmenu
[ActionPlugins][1]
RightButton;NoModifier=org.kde.contextmenu
[Containments][1]
activityId=
formfactor=2
immutability=1
lastScreen=0
location=4
plugin=org.kde.panel
wallpaperplugin=org.kde.image
[Containments][1][Applets][2]
immutability=1
plugin=org.kde.plasma.quicklaunch
[Containments][1][Applets][2][Configuration][General]
launcherUrls=file:///usr/share/applications/ekklesia-parent-call.desktop
[Containments][1][Applets][3]
immutability=1
plugin=org.kde.plasma.panelspacer
[Containments][1][Applets][4]
immutability=1
plugin=org.kde.plasma.icontasks
[Containments][1][Applets][4][Configuration][General]
launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop,applications:ekklesia-dvd.desktop
sortingStrategy=1
[Containments][1][Applets][5]
immutability=1
plugin=org.kde.plasma.panelspacer
[Containments][1][Applets][6]
immutability=1
plugin=org.kde.plasma.networkmanagement
[Containments][1][Applets][7]
immutability=1
plugin=org.kde.plasma.volume
[Containments][1][Applets][8]
immutability=1
plugin=org.kde.plasma.notifications
[Containments][1][Applets][9]
immutability=1
plugin=org.kde.plasma.digitalclock
[Containments][1][General]
AppletOrder=2;3;4;5;6;7;8;9
"""
TINY_PEBBLES = PANEL_1 + """
[Containments][10]
activityId=62d3d55d-bd31-4fc8-9a7b-a5b9b2b99b60
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][10][General]
positions={"1920x1080":[]}
[ScreenMapping]
itemsOnDisabledScreens=
"""
# The healthy machine, as the layout builds it with a screen present and
# as the lab's own session wrote it: desktop 1, panel 2, applets 3 to 10.
# The clock is applet 10 — the number that is Tiny Pebbles' desktop.
RIVER_KIDS = """[ActionPlugins][0]
MiddleButton;NoModifier=org.kde.paste
RightButton;NoModifier=org.kde.contextmenu
[Containments][1]
ItemGeometries-1920x1080=
ItemGeometriesHorizontal=
activityId=a9db02f6-926f-4650-b05f-de3a9f0b73fe
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][1][Wallpaper][org.kde.image][General]
FillMode=2
Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png
SlidePaths=
[Containments][2]
activityId=
formfactor=2
immutability=1
lastScreen=0
location=4
plugin=org.kde.panel
wallpaperplugin=org.kde.image
[Containments][2][Applets][10]
immutability=1
plugin=org.kde.plasma.digitalclock
[Containments][2][Applets][10][Configuration][Appearance]
showDate=true
[Containments][2][Applets][3]
immutability=1
plugin=org.kde.plasma.quicklaunch
[Containments][2][Applets][5]
immutability=1
plugin=org.kde.plasma.icontasks
[Containments][2][General]
AppletOrder=3;4;5;6;7;8;9;10
[ScreenMapping]
itemsOnDisabledScreens=
"""
# What the lab produced when Plasma made a desktop mid-session: the good
# one kept its picture, and the new one, 11, had none.
LATE_DESKTOP = RIVER_KIDS.replace("[ScreenMapping]", """[Containments][11]
activityId=f992593c-2347-4e8d-be82-f80d02e71158
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][11][General]
positions={"1920x1080":[]}
[ScreenMapping]""")
print("1. Tiny Pebbles: a desktop numbered 10, with no wallpaper at all")
check("the desktop is found by what it is, not its number",
module.desktops(TINY_PEBBLES.splitlines(True)) == ["10"],
str(module.desktops(TINY_PEBBLES.splitlines(True))))
after, changes = module.converge(TINY_PEBBLES)
groups_before, groups_after = group_texts(TINY_PEBBLES), group_texts(after)
wallpaper = groups_after.get("[Containments][10][Wallpaper][org.kde.image][General]", "")
check("it gets the classroom's image", IMAGE in wallpaper, wallpaper)
check("scaled and cropped, as the layout writes it", "FillMode=2" in wallpaper)
check("and it says what it did", changes == ["desktop 10: had no wallpaper configured"],
str(changes))
check("the wallpaper plugin is still the image one",
"wallpaperplugin=org.kde.image" in groups_after["[Containments][10]"])
check("one group added and nothing else changed",
set(groups_after) - set(groups_before)
== {"[Containments][10][Wallpaper][org.kde.image][General]"}
and all(groups_after[name] == text for name, text in groups_before.items()))
check("every containment keeps its number", containment_ids(after) == ["1", "10"])
check("the panel is still containment 1, applets 2 to 9, in order",
groups_after["[Containments][1][General]"].endswith("AppletOrder=2;3;4;5;6;7;8;9")
and groups_after["[Containments][1]"] == groups_before["[Containments][1]"])
check("the new group sits with its own containment",
after.index("[Containments][10][General]")
< after.index("[Containments][10][Wallpaper]")
< after.index("[ScreenMapping]"))
again, changes_again = module.converge(after)
check("a second run changes nothing", again == after and changes_again == [], str(changes_again))
print("2. River Kids: already right")
after, changes = module.converge(RIVER_KIDS)
check("the file comes back byte for byte", after == RIVER_KIDS)
check("and nothing is reported as changed", changes == [], str(changes))
check("applet 10 on the panel is not mistaken for a desktop",
module.desktops(RIVER_KIDS.splitlines(True)) == ["1"])
print("3. two desktops, one bare, as the lab made them")
after, changes = module.converge(LATE_DESKTOP)
groups_before, groups_after = group_texts(LATE_DESKTOP), group_texts(after)
check("only the bare one is written",
changes == ["desktop 11: had no wallpaper configured"], str(changes))
check("the good one is untouched",
groups_after["[Containments][1][Wallpaper][org.kde.image][General]"]
== groups_before["[Containments][1][Wallpaper][org.kde.image][General]"])
check("and the panel, numbered 2, is untouched",
all(groups_after[name] == text for name, text in groups_before.items()
if name and name.startswith("[Containments][2]")))
check("the new one has the image",
IMAGE in groups_after.get("[Containments][11][Wallpaper][org.kde.image][General]", ""))
print("4. a desktop some other profile left with another picture")
OTHER = """[Containments][4]
formfactor=0
location=0
plugin=org.kde.desktopcontainment
wallpaperplugin=org.kde.color
[Containments][4][Wallpaper][org.kde.image][General]
Image[$e]=file:///usr/share/wallpapers/Next/contents/images/1920x1080.png
SlidePaths=/usr/share/wallpapers/
[Containments][7]
formfactor=2
location=4
plugin=org.kde.plasma.private.systemtray
[Containments][9]
formfactor=2
location=4
plugin=org.kde.panel
"""
after, changes = module.converge(OTHER)
groups_after = group_texts(after)
check("the plain Desktop containment counts as a desktop",
module.desktops(OTHER.splitlines(True)) == ["4"])
check("its plugin becomes the image one",
"wallpaperplugin=org.kde.image" in groups_after["[Containments][4]"])
wallpaper = groups_after["[Containments][4][Wallpaper][org.kde.image][General]"]
check("its image becomes the classroom's, keeping the key's flags",
"Image[$e]=file:///usr/share/backgrounds/ekklesia/classroom-calm.png" in wallpaper,
wallpaper)
check("a missing key is added to the existing group", "FillMode=2" in wallpaper)
check("a key the classroom does not set is left alone",
"SlidePaths=/usr/share/wallpapers/" in wallpaper)
check("a system tray is not a desktop",
groups_after["[Containments][7]"] == group_texts(OTHER)["[Containments][7]"])
check("nor is a panel",
groups_after["[Containments][9]"] == group_texts(OTHER)["[Containments][9]"])
check("and it settles", module.converge(after) == (after, []))
print("5. the command, on files")
work = Path(tempfile.mkdtemp())
def run(*args):
return module.main([str(arg) for arg in args])
target = work / "tiny-pebbles"
target.write_text(TINY_PEBBLES)
os.chmod(target, 0o600)
check("--check says it is wrong", run("--check", target) == 1)
check("and changes nothing", target.read_text() == TINY_PEBBLES)
check("the repair succeeds", run(target) == 0)
check("the file is repaired", IMAGE in target.read_text())
check("the file keeps its permissions", (target.stat().st_mode & 0o777) == 0o600)
check("no temporary file is left behind", sorted(p.name for p in work.iterdir()) == ["tiny-pebbles"])
stamp = (target.stat().st_ino, target.stat().st_mtime_ns)
check("a second run succeeds", run(target) == 0)
check("and does not write the file at all",
(target.stat().st_ino, target.stat().st_mtime_ns) == stamp)
check("--check then says it is right", run("--check", target) == 0)
healthy = work / "river-kids"
healthy.write_text(RIVER_KIDS)
stamp = (healthy.stat().st_ino, healthy.stat().st_mtime_ns)
check("a machine that is right is left alone", run(healthy) == 0
and (healthy.stat().st_ino, healthy.stat().st_mtime_ns) == stamp)
check("no file at all is not an error", run(work / "absent") == 0)
check("and none is created", not (work / "absent").exists())
empty = work / "no-desktop"
empty.write_text(PANEL_1)
check("a file with only a panel is not an error", run(empty) == 0)
check("and is not changed", empty.read_text() == PANEL_1)
print("6. never underneath a running shell")
config_home = work / "config"
config_home.mkdir()
live = config_home / module.APPLETSRC
live.write_text(TINY_PEBBLES)
os.environ["XDG_CONFIG_HOME"] = str(config_home)
check("the default file is the one in XDG_CONFIG_HOME", module.default_path() == str(live))
proc = work / "proc"
for pid, name in (("1", "systemd"), ("812", "kwin_wayland"), ("self", "python3")):
(proc / pid).mkdir(parents=True)
(proc / pid / "comm").write_text(name + "\n")
check("no plasmashell in the process table is no shell",
module.shell_running(str(proc)) is False)
(proc / "1044").mkdir()
(proc / "1044" / "comm").write_text("plasmashell\n")
check("this user's plasmashell is found", module.shell_running(str(proc)) is True)
check("and the real process table can be read",
module.shell_running() in (True, False))
module.shell_running = lambda: True
check("with plasmashell running it refuses", run() == 1)
check("and the shell's file is untouched", live.read_text() == TINY_PEBBLES)
check("--check still answers while it runs", run("--check") == 1)
module.shell_running = lambda: False
check("with the shell stopped it repairs", run() == 0 and IMAGE in live.read_text())
print("7. one wallpaper, named the same everywhere")
layout = (root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js").read_text()
defaults = (root / "config/plasma/lookandfeel/contents/defaults").read_text()
build = (root / "tools/build-release.sh").read_text()
dropin = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text()
package = root / "config/wallpapers/EkklesiaClassroom"
metadata = json.loads((package / "metadata.json").read_text())
check("the layout and the repair name the same file",
f'var WALLPAPER = "{module.WALLPAPER}";' in layout)
check("and the same fill mode", 'desktop.writeConfig("FillMode", 2);' in layout
and ("FillMode", "2") in module.WALLPAPER_KEYS)
check("the packaged file is where they say", f'"$STAGE{module.WALLPAPER}"' in build)
default = re.search(r"^\[Wallpaper\]\nImage=(.*)$", defaults, re.M)
check("the look and feel's default is a package name, not a path",
default is not None and "/" not in default.group(1),
default.group(1) if default else "no [Wallpaper] Image")
check("the package it names is the classroom's",
default is not None and default.group(1) == metadata["KPlugin"]["Id"] == package.name)
check("the build installs that package's metadata",
f'"$ROOT/config/wallpapers/{package.name}/metadata.json"' in build
and f'WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/{package.name}"' in build)
check("its image is a link to the one file, not a copy",
f"ln -s {module.WALLPAPER}" in build)
check("the repair is packaged",
'"$STAGE/usr/local/bin/classroom-desktop-wallpaper"' in build)
check("it runs before every shell start",
"ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper" in dropin)
check("and a failure in it cannot stop the shell",
not re.search(r"^ExecStartPre=/", dropin, re.M))
check("the layout still writes the wallpaper for a fresh desktop",
'desktop.writeConfig("Image", "file://" + WALLPAPER);' in layout)
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+182
View File
@@ -0,0 +1,182 @@
#!/bin/bash
# When a classroom checks for updates, and when it reboots after one.
#
# The check: daily at 03:00, caught up once after a computer that was off
# at 03:00 is switched on. The reboot: only once nobody has used the
# computer for half an hour and nothing is playing. The timer itself is
# measured in the lab (tools/lab/update-schedule-check); here are its
# settings, and classroom-reboot-when-idle run for real with a pipe
# standing in for a keyboard and files standing in for sound cards.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
WORK="$WORK" python3 - "$ROOT" <<'PY'
import configparser
import importlib.machinery
import importlib.util
import os
import sys
import threading
import time
from pathlib import Path
root = Path(sys.argv[1])
work = Path(os.environ["WORK"])
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
loader = importlib.machinery.SourceFileLoader("idle", str(root / "scripts/classroom-reboot-when-idle"))
spec = importlib.util.spec_from_loader("idle", loader)
idle = importlib.util.module_from_spec(spec)
loader.exec_module(idle)
def unit(path):
parser = configparser.ConfigParser(strict=False, interpolation=None)
parser.optionxform = str
parser.read(path)
return parser
print("1. the check: daily at 03:00, caught up once after being off")
timer = unit(root / "systemd/updater/ekklesia-classroom-update.timer")
check("every day at 03:00", timer["Timer"].get("OnCalendar") == "*-*-* 03:00:00", dict(timer["Timer"]))
check("a computer off at 03:00 checks when it is next on (Persistent)", timer["Timer"].get("Persistent") == "true")
check("spread over half an hour, so rooms do not all ask at once", timer["Timer"].get("RandomizedDelaySec") == "30m")
check("and nothing else starts it: no boot or repeating trigger to loop on",
not any(k in timer["Timer"] for k in ("OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec", "OnActiveSec")))
check("enabled by the package on every install and upgrade",
"systemctl enable ekklesia-classroom-update.timer" in (root / "packaging/debian/postinst").read_text())
check("and installed as a package file, so an upgrade always brings the packaged schedule",
"systemd/updater/ekklesia-classroom-update.timer" in (root / "tools/build-release.sh").read_text()
and not (root / "packaging/debian/conffiles").exists())
print()
print("2. the updater: the same checks, and no reboot of its own")
updater = (root / "scripts/ekklesia-classroom-update").read_text()
code = "\n".join(l for l in updater.splitlines() if not l.lstrip().startswith("#"))
check("only the device's own channel", 'f"{channel}/manifest.json"' in code and 'channel = config["CHANNEL"]' in code)
check("only a newer version", "version_is_newer(candidate, current)" in code)
check("only a manifest that makes sense", "manifest_problem(manifest)" in code)
check("only a file with the manifest's SHA-256", "actual_hash = sha256(temporary_path)" in code)
check("only the package and version the manifest names", "if name != PACKAGE or version != candidate:" in code)
check("never reboots by itself any more", '["systemctl", "reboot"]' not in code)
check("but hands the reboot to classroom-reboot-when-idle, without waiting for it",
'"systemctl", "start", "--no-block", REBOOT_WHEN_IDLE' in code
and 'REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"' in code)
service = unit(root / "systemd/updater/ekklesia-classroom-reboot-when-idle.service")
check("which is never started at boot (no [Install])", "Install" not in service.sections())
check("and is not enabled by the package",
"enable ekklesia-classroom-reboot-when-idle" not in (root / "packaging/debian/postinst").read_text())
build = (root / "tools/build-release.sh").read_text()
check("both are packaged", "scripts/classroom-reboot-when-idle" in build
and "systemd/updater/ekklesia-classroom-reboot-when-idle.service" in build)
print()
print("3. is anything playing?")
def cards(states):
base = work / f"asound-{len(list(work.glob('asound-*')))}"
for n, state in enumerate(states):
status = base / f"card{n}" / "pcm0p" / "sub0" / "status"
status.parent.mkdir(parents=True)
status.write_text(state + "\n")
return base
check("nothing: no cards at all", not idle.audio_playing(work / "none"))
check("nothing: a card that is closed", not idle.audio_playing(cards(["closed"])))
check("nothing: a card that is open but not running", not idle.audio_playing(cards(["state: SETUP"])))
check("playing: a card that is running", idle.audio_playing(cards(["state: RUNNING\nowner_pid : 1"])))
check("playing: one of two cards running (the television's)", idle.audio_playing(cards(["closed", "state: RUNNING"])))
capture = work / "asound-capture" / "card0" / "pcm0c" / "sub0" / "status"
capture.parent.mkdir(parents=True)
capture.write_text("state: RUNNING\n")
check("a microphone recording is not playing", not idle.audio_playing(work / "asound-capture"))
print()
print("4. the decision")
check("half an hour untouched, nothing playing: reboot", idle.should_reboot(2000, 100, 1800, False))
check("29 minutes: not yet", not idle.should_reboot(1840, 100, 1800, False))
check("an hour untouched but a video playing: not while it plays", not idle.should_reboot(4000, 100, 1800, True))
check("half an hour by default", idle.IDLE_SECONDS == 30 * 60)
print()
print("5. run for real: a keyboard that is used, then left")
keyboard = work / "input" / "event0"
keyboard.parent.mkdir()
os.mkfifo(keyboard)
os.mkfifo(work / "input" / "event1") # a second device, never used
marker = work / "rebooted"
asound = cards(["closed"])
holder = os.open(keyboard, os.O_RDWR | os.O_NONBLOCK) # keeps the pipe open, as a device is
holder1 = os.open(work / "input" / "event1", os.O_RDWR | os.O_NONBLOCK)
result = {}
def run():
result["code"] = idle.main(["--idle-seconds", "2", "--check-seconds", "0.2",
"--input-glob", str(work / "input" / "event*"),
"--asound-root", str(asound),
"--reboot-command", "touch", str(marker)])
thread = threading.Thread(target=run, daemon=True)
started = time.monotonic()
thread.start()
for _ in range(10): # typing every 0.4 s for 4 s
os.write(holder, b"k" * 24)
time.sleep(0.4)
check("while the keyboard is used, no reboot (4 s against a 2 s limit)", not marker.exists())
thread.join(8)
waited = time.monotonic() - started
check(f"left alone, it reboots once the limit has passed ({waited:.1f} s)",
marker.exists() and 5.5 <= waited <= 8, result)
marker.unlink(missing_ok=True)
playing = cards(["state: RUNNING"])
result.clear()
def run_playing():
result["code"] = idle.main(["--idle-seconds", "1", "--check-seconds", "0.2",
"--input-glob", str(work / "input" / "event*"),
"--asound-root", str(playing),
"--reboot-command", "touch", str(marker)])
thread = threading.Thread(target=run_playing, daemon=True)
thread.start()
time.sleep(3)
check("untouched but playing for 3 s against a 1 s limit: no reboot", not marker.exists())
((playing / "card0" / "pcm0p" / "sub0" / "status")).write_text("closed\n")
stopped = time.monotonic()
thread.join(6)
after = time.monotonic() - stopped
# Sound is seen at each check, so the limit runs from the last check that
# heard it: at most one check (0.2 s here, a minute in a classroom) early.
check(f"the sound stops: it waits the limit again, less at most one check, before rebooting ({after:.1f} s)",
marker.exists() and 0.75 <= after <= 3, result)
os.close(holder)
os.close(holder1)
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+37 -6
View File
@@ -244,20 +244,51 @@ check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in vali
print() print()
print("6. canary release gate") print("6. canary release gate")
validation = canary.index('"$ROOT/tools/validate-trixie-release.sh"') validation = canary.index('"$ROOT/tools/release-gate.sh"')
package_copy = canary.index('cp -n "$package_path"') package_copy = canary.index('cp -n "$package_path"')
first_upload = canary.index("release_upload_package") first_upload = canary.index("release_upload_package")
check("Trixie validation runs before the repository copy", validation < package_copy) check("the release gate runs before the repository copy", validation < package_copy)
check("Trixie validation runs before any upload", validation < first_upload) check("the release gate runs before any upload", validation < first_upload)
# A version is checked before anything is built for it, and again after # A version is checked before anything is built for it, and again after
# the long gate, immediately before the first upload. # the long gate, immediately before the first upload.
check("a spent version is refused before the build", check("a spent version is refused before the build",
canary.index("release_check_new_version") < canary.index("build-release.sh")) canary.index("release_check_new_version") < canary.index("build-release.sh"))
check("and again after the gate", check("and again after the gate",
validation < canary.rindex("release_check_new_version") < first_upload) validation < canary.rindex("release_check_new_version") < first_upload)
check("the stable rooms' upgrade is rehearsed on the same file before publishing", # A package handed in is the one published: a second build is a second,
"VALIDATION_CHANNEL=stable" in canary # untested file.
and canary.index("VALIDATION_CHANNEL=stable") < first_upload) build_at = canary.index('"$ROOT/tools/build-release.sh"')
check("a package handed in is not rebuilt",
canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at) < build_at
and "else" in canary[canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at):build_at])
check("and must say it is the version being published",
'release_package_version "$package_path")" != "$version"' in canary)
check("the file is hashed before the gate and checked after it",
canary.index('hash="$(sha256sum "$package_path"') < validation
< canary.index("changed while it was being gated") < first_upload)
check("the repository copy is proven to be the gated file",
package_copy < canary.index("is not the file that passed the gate") < first_upload)
gate = (root / "tools/release-gate.sh").read_text()
gate_code = "\n".join(line for line in gate.splitlines()
if not line.lstrip().startswith("#"))
check("the gate runs a fresh install, as the stable room a new machine is",
'env VALIDATION_CHANNEL=stable \\\n "$ROOT/tools/validate-trixie-release.sh" "$candidate"\n'
in gate_code)
check("which is what a fresh device.conf says",
"CHANNEL=stable" in (root / "lib/classroom_config.py").read_text())
check("and the upgrade from what canary and stable serve, as rooms of each",
"for channel in canary stable" in gate_code
and 'VALIDATION_CHANNEL="$channel"' in gate_code)
check("the stable rooms' older releases can be rehearsed too",
"VALIDATION_CHANNEL=stable" in gate_code and "RELEASE_GATE_FROM" in canary)
check("what it upgrades from is checked against its manifest's checksum",
'if [[ "$got" != "$from_hash" ]]' in gate_code)
check("the gate needs no credential and writes nothing to the server",
"release_remote" not in gate_code and "ssh" not in gate_code
and "rsync" not in gate_code)
check("the gate stops at the first failure",
"set -euo pipefail" in gate and "inherit_errexit" in gate)
check("the published channel is read back afterwards", check("the published channel is read back afterwards",
canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary")) canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary"))
# The gate checks convergence, not just that the package installs: an # The gate checks convergence, not just that the package installs: an
+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
# The Gitea release record, against a stand-in for Gitea's API.
#
# Nothing here reaches the real Gitea. The stand-in keeps tags, releases
# and assets in memory, answers the handful of calls tools/gitea-release
# makes the way Gitea 1.27 answers them, and refuses any write that does
# not carry the token. The questions are the ones that matter for a
# permanent record: a tag is never moved, an asset's bytes are never
# replaced, and the record only ever grows.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
SERVER_PID=""
cleanup() {
[[ -z "$SERVER_PID" ]] || kill "$SERVER_PID" 2>/dev/null
rm -rf "$WORK"
}
trap cleanup EXIT
passed=0
failed=0
check() {
local label="$1"
shift
if "$@" >/dev/null 2>&1; then
echo " ok $label"
passed=$((passed + 1))
else
echo " FAIL $label"
failed=$((failed + 1))
fi
}
refuses() {
! "$@"
}
python3 "$ROOT/tests/fake-gitea.py" > "$WORK/port" 2>"$WORK/gitea.log" &
SERVER_PID=$!
for _ in $(seq 1 50); do
[[ -s "$WORK/port" ]] && break
sleep 0.1
done
export GITEA_URL="http://127.0.0.1:$(cat "$WORK/port")"
export GITEA_REPO=church/classroom
export GITEA_TOKEN=not-a-real-token
RELEASE="$ROOT/tools/gitea-release"
A=$(printf 'a%.0s' {1..40})
B=$(printf 'b%.0s' {1..40})
printf 'the package\n' > "$WORK/ekklesia-classroom_0.9.1_amd64.deb"
printf '# 0.9.1\n\nNotes.\n' > "$WORK/notes.md"
echo "1. a release, created once, at one commit"
check "no release yet is its own answer" \
bash -c "'$RELEASE' show v0.9.1; [[ \$? -eq 3 ]]"
check "it is created at the commit named" \
"$RELEASE" create v0.9.1 "$A" "0.9.1" "$WORK/notes.md" --prerelease
check "and its tag names that commit" \
bash -c "[[ \$(curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/tags/v0.9.1' | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"commit\"][\"sha\"])') == $A ]]"
check "it is a pre-release until it is promoted" \
bash -c "api() { curl -fsS '$GITEA_URL/api/v1/repos/church/classroom'\$1; }; api /releases/tags/v0.9.1 | grep -q '\"prerelease\": true'"
check "creating it again at the same commit changes nothing" \
"$RELEASE" create v0.9.1 "$A" "0.9.1" "$WORK/notes.md" --prerelease
check "a tag is never moved to another commit" \
refuses "$RELEASE" create v0.9.1 "$B" "0.9.1" "$WORK/notes.md"
check "nor is a tag that already exists taken over" \
refuses "$RELEASE" create v0.1.5 "$B" "0.1.5" "$WORK/notes.md"
echo "2. assets: attached once, never replaced"
check "the package is attached" \
"$RELEASE" upload v0.9.1 "$WORK/ekklesia-classroom_0.9.1_amd64.deb"
check "and reads back as the same bytes" \
bash -c "'$RELEASE' download v0.9.1 ekklesia-classroom_0.9.1_amd64.deb '$WORK/back.deb' \
&& cmp -s '$WORK/back.deb' '$WORK/ekklesia-classroom_0.9.1_amd64.deb'"
check "the same file again is left alone, and is not an error" \
bash -c "'$RELEASE' upload v0.9.1 '$WORK/ekklesia-classroom_0.9.1_amd64.deb' | grep -q 'left alone'"
mkdir -p "$WORK/rebuilt"
printf 'a different build\n' > "$WORK/rebuilt/ekklesia-classroom_0.9.1_amd64.deb"
check "different bytes under the same name are refused" \
refuses "$RELEASE" upload v0.9.1 "$WORK/rebuilt/ekklesia-classroom_0.9.1_amd64.deb"
check "and the published bytes are still the first ones" \
bash -c "'$RELEASE' download v0.9.1 ekklesia-classroom_0.9.1_amd64.deb '$WORK/back2.deb' \
&& cmp -s '$WORK/back2.deb' '$WORK/ekklesia-classroom_0.9.1_amd64.deb'"
check "an asset the release does not have is refused, not invented" \
refuses "$RELEASE" download v0.9.1 nothing.deb "$WORK/nothing"
curl -fsS "$GITEA_URL/test/tamper/v0.9.1/ekklesia-classroom_0.9.1_amd64.deb" >/dev/null
check "an asset changed by hand is caught at the next upload" \
refuses "$RELEASE" upload v0.9.1 "$WORK/ekklesia-classroom_0.9.1_amd64.deb"
echo "3. the record only grows"
check "a line is recorded" \
"$RELEASE" record v0.9.1 "published to Preview (canary)"
check "and another" \
"$RELEASE" record v0.9.1 "promoted to Stable"
check "the notes, the creation line and both records are all there, in order" \
bash -c "curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/releases/tags/v0.9.1' \
| python3 -c 'import json,sys
b = json.load(sys.stdin)[\"body\"]
order = [b.index(t) for t in (\"Notes.\", \"## Record\", \"release created at\", \"Preview (canary)\", \"promoted to Stable\")]
assert order == sorted(order), order
assert b.count(\"## Record\") == 1'"
check "the notes and record read back as text" \
bash -c "'$RELEASE' notes v0.9.1 | grep -q 'Preview (canary)'"
check "and asking for a release there is none of is refused" \
refuses "$RELEASE" notes v0.0.0
check "promoted, it is no longer a pre-release" \
bash -c "'$RELEASE' stable v0.9.1 && curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/releases/tags/v0.9.1' | grep -q '\"prerelease\": false'"
echo "4. credentials"
check "a write without the token is refused by Gitea, and reported" \
refuses env GITEA_TOKEN= "$RELEASE" record v0.9.1 "nobody"
check "the token never appears in anything it prints" \
bash -c "! { '$RELEASE' show v0.9.1; '$RELEASE' record v0.9.1 x; '$RELEASE' upload v0.9.1 '$WORK/rebuilt/ekklesia-classroom_0.9.1_amd64.deb'; } 2>&1 | grep -q not-a-real-token"
check "nothing works without knowing which Gitea" \
refuses env -u GITEA_URL -u GITHUB_SERVER_URL "$RELEASE" show v0.9.1
echo
echo "$passed passed, $failed failed"
[[ $failed -eq 0 ]]
+307
View File
@@ -0,0 +1,307 @@
#!/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
# ssh [-F CONFIG] HOST COMMAND: run COMMAND here, and note which
# configuration it was given ("-" for the machine's own).
config=-
if [[ "$1" == -F ]]; then config="$2"; shift 2; fi
echo "ssh $config" >> "${SSH_LOG:-/dev/null}"
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) echo "rsync $2" >> "${SSH_LOG:-/dev/null}"; 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. the release runner uses the key it is given, and only that"
H8="$(make_release 0.2.6.8)"
report "$WORK/reports/gate-report-preview-0.2.6.8.txt" 0.2.6.8 "$H8" "$(serves canary)" "$(serves stable)"
"$GITEA" upload v0.2.6.8 "$WORK/reports/gate-report-preview-0.2.6.8.txt" >/dev/null
before="$(packages)"
check "under Actions, a job with no key stops before anything is published" \
refuses_saying "A job never uses the runner's own ssh keys" \
env GITHUB_ACTIONS=true bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' preview 0.2.6.8 '$H8' gate-report-preview-0.2.6.8.txt"
check "and nothing was" test "$(packages)" = "$before"
job="$(mktemp -d -p "$WORK")"
export SSH_LOG="$WORK/ssh.log"
: > "$SSH_LOG"
check "with a key, it publishes" \
env GITHUB_ACTIONS=true RELEASE_CI_WORK="$job" RELEASE_SSH_KEY="not a real key" \
RELEASE_SSH_KNOWN_HOSTS="10.0.0.1 ssh-ed25519 AAAA" RELEASE_SSH_ADDRESS=10.0.0.1 \
RELEASE_SSH_USER=classroom-publish "$CI" preview 0.2.6.8 "$H8" gate-report-preview-0.2.6.8.txt
check "every ssh and rsync it made used the job's own configuration" \
bash -c "[[ -s '$SSH_LOG' ]] && ! grep -v -e 'ssh $job/ssh/config' -e 'rsync ssh -F $job/ssh/config' '$SSH_LOG'"
check "which names the key, the host key and the account it was given" \
bash -c "grep -qx ' IdentityFile $job/ssh/key' '$job/ssh/config' \
&& grep -qx ' UserKnownHostsFile $job/ssh/known_hosts' '$job/ssh/config' \
&& grep -qx ' User classroom-publish' '$job/ssh/config' \
&& grep -qx ' StrictHostKeyChecking yes' '$job/ssh/config'"
check "and the key is gone when the job ends" test ! -e "$job/ssh/key"
unset SSH_LOG
echo "6. 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 ]]
+35 -3
View File
@@ -86,6 +86,11 @@ cp "$SERVER/canary/manifest.json" "$LOCAL/canary/"
# to one host, and it is this directory. # to one host, and it is this directory.
cat > "$WORK/bin/ssh" <<'EOF' cat > "$WORK/bin/ssh" <<'EOF'
#!/bin/bash #!/bin/bash
# ssh [-F CONFIG] HOST COMMAND: run COMMAND here, and note which
# configuration it was given ("-" for the machine's own).
config=-
if [[ "$1" == -F ]]; then config="$2"; shift 2; fi
echo "ssh $config" >> "${SSH_LOG:-/dev/null}"
shift shift
exec bash -c "$*" exec bash -c "$*"
EOF EOF
@@ -98,7 +103,7 @@ args=()
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--ignore-existing) keep=1 ;; --ignore-existing) keep=1 ;;
-e) shift ;; -e) echo "rsync $2" >> "${SSH_LOG:-/dev/null}"; shift ;;
-*) ;; -*) ;;
*) args+=("$1") ;; *) args+=("$1") ;;
esac esac
@@ -274,8 +279,21 @@ fake_deb 0.2.9 "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
check "refuses a package that was built but never uploaded" \ check "refuses a package that was built but never uploaded" \
refuses "$ROOT/update_stable.sh" 0.2.9 refuses "$ROOT/update_stable.sh" 0.2.9
rm "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb" rm "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
check "refuses a checksum that is not one" \
refuses "$ROOT/update_stable.sh" 0.2.6.1 not-a-checksum
check "refuses a file that is not the one approved" \
refuses "$ROOT/update_stable.sh" 0.2.6.1 "$(printf '0%.0s' {1..64})"
check "stable is still 0.2.2 after all of that" \ check "stable is still 0.2.2 after all of that" \
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before" test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
approved="$(sha256sum "$LOCAL/packages/ekklesia-classroom_0.2.6.1_amd64.deb" | awk '{print $1}')"
check "promotes the approved file, named by its checksum" \
"$ROOT/update_stable.sh" 0.2.6.1 "$approved"
check "and stable now serves exactly that file" \
bash -c "grep -q '\"sha256\": \"$approved\"' '$SERVER/stable/manifest.json' \
&& cmp -s '$SERVER/stable/latest.deb' '$LOCAL/packages/ekklesia-classroom_0.2.6.1_amd64.deb'"
# Back to 0.2.2 for the updater's cases below.
printf '%s\n' "$stable_before" > "$SERVER/stable/manifest.json"
ln -sfn ../packages/ekklesia-classroom_0.2.2_amd64.deb "$SERVER/stable/latest.deb"
echo echo
echo "7. update_canary.sh" echo "7. update_canary.sh"
@@ -286,6 +304,17 @@ check "without a version it builds nothing and names both choices" \
check "a published version is refused before anything is built" \ check "a published version is refused before anything is built" \
bash -c "! '$ROOT/update_canary.sh' 0.2.6 2>&1 | grep -q 'Validating sources'" bash -c "! '$ROOT/update_canary.sh' 0.2.6 2>&1 | grep -q 'Validating sources'"
check "a malformed version is refused" refuses "$ROOT/update_canary.sh" 0.2.6.1.1 check "a malformed version is refused" refuses "$ROOT/update_canary.sh" 0.2.6.1.1
# A package handed in is gated and published as it is, so it has to be
# the release named, before anything is gated at all.
fake_deb 0.2.6.3 "$WORK/ekklesia-classroom_0.2.6.2_amd64.deb"
check "a package handed in that says it is another version is refused" \
bash -c "out=\$('$ROOT/update_canary.sh' 0.2.6.2 '$WORK/ekklesia-classroom_0.2.6.2_amd64.deb' 2>&1); \
[[ \$? -ne 0 ]] && grep -q 'says it is 0.2.6.3' <<< \"\$out\" && ! grep -q 'release gate' <<< \"\$out\""
fake_deb 0.2.6.2 "$WORK/renamed.deb"
check "and so is one that is not named as the release" \
bash -c "out=\$('$ROOT/update_canary.sh' 0.2.6.2 '$WORK/renamed.deb' 2>&1); \
[[ \$? -ne 0 ]] && grep -q 'is not named' <<< \"\$out\" && ! grep -q 'release gate' <<< \"\$out\""
rm -f "$WORK/ekklesia-classroom_0.2.6.2_amd64.deb" "$WORK/renamed.deb"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo echo
@@ -441,8 +470,11 @@ publish("canary", "0.2.7")
status, out = run() status, out = run()
check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it", check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it",
status == 0 and installed() == "0.2.7", out) status == 0 and installed() == "0.2.7", out)
check("and reboots, because the manifest asks it to", # Since 0.2.6.5 not there and then: the reboot is handed to a service that
(work / "systemctl.log").read_text().strip() == "reboot") # waits until nobody is using the computer (classroom-reboot-when-idle).
check("and asks for the reboot the manifest wants, once nobody is using the computer",
(work / "systemctl.log").read_text().strip()
== "start --no-block ekklesia-classroom-reboot-when-idle.service")
publish("canary", "0.2.6.1") publish("canary", "0.2.6.1")
device("canary", "0.2.7") device("canary", "0.2.7")
+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
+9 -2
View File
@@ -37,9 +37,10 @@ import argparse
import base64 import base64
import html import html
import mimetypes import mimetypes
import os
import re import re
import sys import sys
from datetime import date from datetime import date, datetime
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -704,7 +705,13 @@ def main():
if problems: if problems:
return 1 return 1
revision = f"Revision {date.today():%d %B %Y}" # The day the source was committed, when the build says so, rather than
# the day it was built: a release built from a commit a day later must
# still ship the manual that commit holds, or the build changes the
# tree it was built from. SOURCE_DATE_EPOCH is the usual name for it.
epoch = os.environ.get("SOURCE_DATE_EPOCH")
day = datetime.fromtimestamp(int(epoch)).date() if epoch else date.today()
revision = f"Revision {day:%d %B %Y}"
if arguments.check: if arguments.check:
for name in ("classroom-manual.html", "classroom-manual.pdf"): for name in ("classroom-manual.html", "classroom-manual.pdf"):
+43 -49
View File
@@ -19,57 +19,12 @@ release_require_version "$VERSION" || exit 1
STAGE="$ROOT/packaging/root" STAGE="$ROOT/packaging/root"
OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb" OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
# Syntax check everything the package ships, by shebang, so this cannot # Sources, the teacher's manual and every test, before anything is built.
# drift out of date as classroom commands are added. # The same list CI runs on every change; see tools/check.sh.
echo "Validating sources" "$ROOT/tools/check.sh" || {
echo "Checks failed; nothing was built."
for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/* "$ROOT"/tests/*; do
[[ -f "$script" ]] || continue
case "$(head -n 1 "$script")" in
*python3*)
python3 -m py_compile "$script"
;;
*bash*|*bin/sh*)
bash -n "$script"
;;
esac
done
for module in "$ROOT"/lib/*.py; do
[[ -f "$module" ]] || continue
python3 -m py_compile "$module"
done
for policy in "$ROOT"/config/chrome/policies/*/*.json; do
[[ -f "$policy" ]] || continue
python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$policy"
done
find "$ROOT/scripts" "$ROOT/lib" "$ROOT/tests" \
-name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
# THE MANUAL IS PART OF THE RELEASE, NOT A JOB FOR AFTERWARDS.
#
# Built from docs/manual before the tests run, so that the test which
# checks the digital and printed versions against each other is checking
# what this package is about to ship rather than whatever was built last.
# A page that would make the printed manual wrong fails the build here.
echo "Building the teacher's manual"
"$ROOT/tools/build-manual"
for test in "$ROOT"/tests/test-*.sh; do
[[ -x "$test" ]] || continue
echo "Running $(basename "$test")"
"$test" > /dev/null || {
echo "Tests failed. Run it directly to see why:"
echo " $test"
exit 1 exit 1
} }
done
echo "Building Ekklesia Classroom ${VERSION}" echo "Building Ekklesia Classroom ${VERSION}"
@@ -176,6 +131,32 @@ install -Dm644 \
"$ROOT/config/wallpapers/classroom-calm.png" \ "$ROOT/config/wallpapers/classroom-calm.png" \
"$STAGE/usr/share/backgrounds/ekklesia/classroom-calm.png" "$STAGE/usr/share/backgrounds/ekklesia/classroom-calm.png"
# THE SAME PICTURE, AS A WALLPAPER PACKAGE.
#
# The look and feel names this as the default for any desktop Plasma makes
# without the layout, and Plasma only accepts a package there, never a
# file. Its one image is a link to the file above rather than a copy, so
# there is still exactly one picture, and every desktop the layout wrote
# keeps pointing at the path it always has. The name says the image's
# size, because that is how Plasma chooses between a package's images.
WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/EkklesiaClassroom"
install -Dm644 \
"$ROOT/config/wallpapers/EkklesiaClassroom/metadata.json" \
"$WALLPAPER_PACKAGE/metadata.json"
wallpaper_size="$(python3 -c '
import struct, sys
with open(sys.argv[1], "rb") as png:
header = png.read(24)
assert header[:8] == b"\x89PNG\r\n\x1a\n" and header[12:16] == b"IHDR"
print("%dx%d" % struct.unpack(">II", header[16:24]))
' "$ROOT/config/wallpapers/classroom-calm.png")"
install -d -m755 "$WALLPAPER_PACKAGE/contents" "$WALLPAPER_PACKAGE/contents/images"
ln -s /usr/share/backgrounds/ekklesia/classroom-calm.png \
"$WALLPAPER_PACKAGE/contents/images/${wallpaper_size}.png"
@@ -193,6 +174,10 @@ install -Dm755 \
"$ROOT/scripts/ekklesia-classroom-update" \ "$ROOT/scripts/ekklesia-classroom-update" \
"$STAGE/usr/local/bin/ekklesia-classroom-update" "$STAGE/usr/local/bin/ekklesia-classroom-update"
install -Dm755 \
"$ROOT/scripts/classroom-reboot-when-idle" \
"$STAGE/usr/local/bin/classroom-reboot-when-idle"
# System configuration. # System configuration.
install -Dm644 \ install -Dm644 \
"$ROOT/config/sddm/ekklesia-classroom.conf" \ "$ROOT/config/sddm/ekklesia-classroom.conf" \
@@ -206,6 +191,11 @@ install -Dm644 \
"$ROOT/systemd/updater/ekklesia-classroom-update.timer" \ "$ROOT/systemd/updater/ekklesia-classroom-update.timer" \
"$STAGE/etc/systemd/system/ekklesia-classroom-update.timer" "$STAGE/etc/systemd/system/ekklesia-classroom-update.timer"
# After an update: the reboot, once nobody is using the computer.
install -Dm644 \
"$ROOT/systemd/updater/ekklesia-classroom-reboot-when-idle.service" \
"$STAGE/etc/systemd/system/ekklesia-classroom-reboot-when-idle.service"
install -Dm644 \ install -Dm644 \
"$ROOT/systemd/provision/ekklesia-classroom-provision.service" \ "$ROOT/systemd/provision/ekklesia-classroom-provision.service" \
"$STAGE/etc/systemd/system/ekklesia-classroom-provision.service" "$STAGE/etc/systemd/system/ekklesia-classroom-provision.service"
@@ -457,6 +447,10 @@ install -Dm755 \
"$ROOT/scripts/classroom-desktop-reset" \ "$ROOT/scripts/classroom-desktop-reset" \
"$STAGE/usr/local/bin/classroom-desktop-reset" "$STAGE/usr/local/bin/classroom-desktop-reset"
install -Dm755 \
"$ROOT/scripts/classroom-desktop-wallpaper" \
"$STAGE/usr/local/bin/classroom-desktop-wallpaper"
install -Dm755 \ install -Dm755 \
"$ROOT/scripts/classroom-theme" \ "$ROOT/scripts/classroom-theme" \
"$STAGE/usr/local/bin/classroom-theme" "$STAGE/usr/local/bin/classroom-theme"
Executable
+141
View File
@@ -0,0 +1,141 @@
#!/bin/bash
# EVERY CHECK THE REPOSITORY HAS THAT NEEDS NO CONTAINER AND NO SCREEN.
#
# tools/check.sh sources, the manual, every test
# tools/check.sh --base REF and the change since REF is clean
# CHECK_LOG_DIR=DIR tools/check.sh each test's output in DIR, and a
# summary.md for a CI run to show
#
# The one list. tools/build-release.sh runs it before it builds anything,
# and CI runs it on every push and pull request, so a test added to tests/
# is a test both of them run, with nothing else to edit.
#
# Every test runs even after one has failed, so a report names all of
# them; the exit status is 1 if any did.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
base=""
while [[ $# -gt 0 ]]; do
case "$1" in
--base) base="$2"; shift 2 ;;
*) echo "Usage: $0 [--base REF]" >&2; exit 2 ;;
esac
done
logs="${CHECK_LOG_DIR:-$(mktemp -d)}"
mkdir -p "$logs"
summary="$logs/summary.md"
failed=0
{
echo "| Check | Result | Seconds |"
echo "| --- | --- | --- |"
} > "$summary"
# check NAME COMMAND...: run it, keep its output, say how it went.
check() {
local name="$1" started status log
shift
started=$SECONDS
log="$logs/$name.log"
"$@" > "$log" 2>&1
status=$?
if [[ $status -eq 0 ]]; then
printf ' ok %s\n' "$name"
echo "| $name | pass | $(( SECONDS - started )) |" >> "$summary"
else
printf ' FAIL %s\n' "$name"
tail -n 25 "$log" | sed 's/^/ /'
echo "| $name | **FAIL** | $(( SECONDS - started )) |" >> "$summary"
failed=1
fi
}
# Syntax check everything the package ships, by shebang, so this cannot
# drift out of date as classroom commands are added.
validate_sources() {
local script module policy
for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/* "$ROOT"/tests/* \
"$ROOT"/tools/* "$ROOT"/tools/lab/* "$ROOT"/update_*.sh; do
[[ -f "$script" ]] || continue
case "$(head -n 1 "$script")" in
*python3*)
python3 -m py_compile "$script" || return 1
;;
*bash*|*bin/sh*)
bash -n "$script" || return 1
;;
*)
# Sourced, so no #! line: tools/release-lib.sh.
if [[ "$script" == *.sh ]]; then
bash -n "$script" || return 1
fi
;;
esac
done
for module in "$ROOT"/lib/*.py; do
[[ -f "$module" ]] || continue
python3 -m py_compile "$module" || return 1
done
for policy in "$ROOT"/config/chrome/policies/*/*.json "$ROOT"/config/wallpapers/*/metadata.json; do
[[ -f "$policy" ]] || continue
python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$policy" || return 1
done
find "$ROOT/scripts" "$ROOT/lib" "$ROOT/tests" "$ROOT/tools" \
-name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
}
# The change itself: no whitespace damage, and nothing built or published
# committed by mistake. Only with --base, because it is a question about a
# change, not about a tree.
hygiene() {
git diff --check "$base" HEAD || return 1
if git ls-files | grep -E '\.deb$|^(dist|repo|packaging/root)/'; then
echo "built or published files are committed (above)" >&2
return 1
fi
# A committed release version is one a release could be built from.
source "$ROOT/tools/release-lib.sh"
release_require_version "$(cat "$ROOT/VERSION")"
}
echo "Checking $(git rev-parse --short HEAD 2>/dev/null || echo 'the tree')"
check sources validate_sources
[[ -z "$base" ]] || check hygiene hygiene
# THE MANUAL IS PART OF THE RELEASE, NOT A JOB FOR AFTERWARDS.
#
# Built from docs/manual before the tests run, so that the test which
# checks the digital and printed versions against each other is checking
# what this package is about to ship rather than whatever was built last.
# A page that would make the printed manual wrong fails here.
check manual "$ROOT/tools/build-manual"
for test in "$ROOT"/tests/test-*.sh; do
[[ -x "$test" ]] || continue
check "$(basename "$test" .sh)" "$test"
done
echo
if [[ $failed -eq 0 ]]; then
echo "Every check passed. Logs: $logs"
else
echo "Checks FAILED. Each one's full output is in $logs"
fi
exit "$failed"
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""The Gitea release that is the permanent record of one version.
A Gitea release is where a version's package, its checksum, how it was
built and what happened to it afterwards are kept for good: which commit,
which gate, when it went to Preview (canary), when it was promoted to
Stable and on whose approval. The update server is where classrooms fetch
it; this is where a person months from now finds out why.
PUBLISHED BYTES ARE NEVER REPLACED. Gitea lets anyone with write access
delete an asset and upload another under the same name, and nothing in
Gitea stops a workflow doing it either, so this does: an asset is only
ever uploaded under a name the release does not have yet. An asset that
is already there with exactly the same bytes is left alone and reported;
one with different bytes fails, loudly, and nothing is changed. The same
rule the update server has had since 0.2.6 was republished over itself
(tools/release-lib.sh).
A tag, likewise, is never moved: a release is only created for a tag that
does not exist, or that already names the commit being released.
The record is append-only. `record` adds a dated line under "## Record";
nothing here rewrites what an earlier line says.
gitea-release show TAG the release, or exit 3 if none
gitea-release notes TAG its notes and record, as text
gitea-release create TAG COMMIT TITLE NOTES_FILE [--prerelease]
gitea-release upload TAG FILE... each only if not there yet
gitea-release download TAG NAME DEST an asset, by name
gitea-release record TAG LINE append to the record
gitea-release stable TAG no longer a pre-release
Environment:
GITEA_URL https://git.riverofglorymn.org (GITHUB_SERVER_URL in CI)
GITEA_REPO River_of_Glory_Church/Ekklesia_Classroom (GITHUB_REPOSITORY)
GITEA_TOKEN a token that may write releases; never printed
"""
import hashlib
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
RECORD = "## Record"
class Refused(Exception):
"""Something that must not happen, said in words."""
def say(message):
print(f"gitea-release: {message}", flush=True)
def config():
url = os.environ.get("GITEA_URL") or os.environ.get("GITHUB_SERVER_URL")
repo = os.environ.get("GITEA_REPO") or os.environ.get("GITHUB_REPOSITORY")
token = os.environ.get("GITEA_TOKEN", "")
if not url or not repo:
raise Refused("GITEA_URL and GITEA_REPO must be set")
return url.rstrip("/"), repo, token
def request(method, path, body=None, headers=None, raw=False):
"""(status, parsed body). 404 is an answer, not an error."""
url, repo, token = config()
target = path if path.startswith("http") else f"{url}/api/v1/repos/{repo}{path}"
headers = dict(headers or {})
if token:
headers["Authorization"] = f"token {token}"
if isinstance(body, (dict, list)):
body = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
call = urllib.request.Request(target, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(call, timeout=120) as response:
data = response.read()
status = response.status
except urllib.error.HTTPError as error:
data = error.read()
status = error.code
if status == 404:
return 404, None
if status >= 400:
detail = data[:300].decode("utf-8", "replace")
raise Refused(f"{method} {path} answered {status}: {detail}")
if raw:
return status, data
return status, json.loads(data) if data else None
def release(tag):
status, found = request("GET", f"/releases/tags/{urllib.parse.quote(tag)}")
return None if status == 404 else found
def need_release(tag):
found = release(tag)
if found is None:
raise Refused(f"there is no release {tag}")
return found
def tag_commit(tag):
status, found = request("GET", f"/tags/{urllib.parse.quote(tag)}")
if status == 404:
return None
return found["commit"]["sha"]
def sha256(data):
return hashlib.sha256(data).hexdigest()
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def assets(found):
status, listed = request("GET", f"/releases/{found['id']}/assets")
return {} if status == 404 else {asset["name"]: asset for asset in listed or []}
def fetch(asset):
_status, data = request("GET", asset["browser_download_url"], raw=True)
return data
# ---------------------------------------------------------------------------
def show(tag):
found = release(tag)
if found is None:
say(f"no release {tag}")
return 3
print(json.dumps({
"tag": found["tag_name"],
"commit": tag_commit(tag),
"prerelease": found.get("prerelease"),
"assets": sorted(assets(found)),
"url": found.get("html_url"),
}, indent=2))
return 0
def notes(tag):
print(need_release(tag).get("body") or "")
return 0
def create(tag, commit, title, notes_file, prerelease):
notes = open(notes_file, encoding="utf-8").read()
at = tag_commit(tag)
if at is not None and at != commit:
raise Refused(f"tag {tag} already names {at}, not {commit}. "
"A tag is never moved; release a new version.")
found = release(tag)
if found is not None:
say(f"release {tag} already exists at {commit}; nothing created")
return 0
request("POST", "/releases", {
"tag_name": tag,
"target_commitish": commit,
"name": title,
"body": notes.rstrip() + f"\n\n{RECORD}\n\n- {now()}: release created at `{commit}`\n",
"draft": False,
"prerelease": prerelease,
})
if tag_commit(tag) != commit:
raise Refused(f"release {tag} was created, but its tag does not name {commit}")
say(f"created release {tag} at {commit}")
return 0
def upload(tag, files):
found = need_release(tag)
present = assets(found)
for path in files:
name = os.path.basename(path)
data = open(path, "rb").read()
digest = sha256(data)
if name in present:
there = sha256(fetch(present[name]))
if there == digest:
say(f"{name} is already attached, byte for byte; left alone")
continue
raise Refused(f"{name} is already attached to {tag} with sha256 {there}; "
f"this file is {digest}. A published asset is never replaced.")
boundary = uuid.uuid4().hex
body = (f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n").encode() \
+ data + f"\r\n--{boundary}--\r\n".encode()
request("POST", f"/releases/{found['id']}/assets?name={urllib.parse.quote(name)}",
body, {"Content-Type": f"multipart/form-data; boundary={boundary}"})
# Read back: what Gitea serves is what was sent.
there = assets(need_release(tag)).get(name)
if there is None or sha256(fetch(there)) != digest:
raise Refused(f"{name} was uploaded but does not read back as sha256 {digest}")
say(f"attached {name}, sha256 {digest}")
return 0
def download(tag, name, dest):
present = assets(need_release(tag))
if name not in present:
raise Refused(f"{tag} has no asset {name}")
data = fetch(present[name])
with open(dest, "wb") as handle:
handle.write(data)
say(f"{name} -> {dest}, sha256 {sha256(data)}")
return 0
def record(tag, line):
found = need_release(tag)
body = found.get("body") or ""
if RECORD not in body:
body = body.rstrip() + f"\n\n{RECORD}\n"
body = body.rstrip() + f"\n- {now()}: {line}\n"
request("PATCH", f"/releases/{found['id']}", {"body": body})
say(f"recorded on {tag}: {line}")
return 0
def stable(tag):
found = need_release(tag)
request("PATCH", f"/releases/{found['id']}", {"prerelease": False})
say(f"{tag} is no longer marked as a pre-release")
return 0
def main(argv):
try:
match argv:
case ["show", tag]:
return show(tag)
case ["notes", tag]:
return notes(tag)
case ["create", tag, commit, title, notes_file, *rest] if rest in ([], ["--prerelease"]):
return create(tag, commit, title, notes_file, rest == ["--prerelease"])
case ["upload", tag, *files] if files:
return upload(tag, files)
case ["download", tag, name, dest]:
return download(tag, name, dest)
case ["record", tag, line]:
return record(tag, line)
case ["stable", tag]:
return stable(tag)
case _:
print(__doc__, file=sys.stderr)
return 2
except Refused as refusal:
say(f"REFUSED: {refusal}")
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+6
View File
@@ -182,6 +182,12 @@ check "no discover or system settings" 0 \
"$(in_lab "grep -cE 'discover.desktop|systemsettings.desktop' $APPLETS")" "$(in_lab "grep -cE 'discover.desktop|systemsettings.desktop' $APPLETS")"
check "the packaged wallpaper" 1 \ check "the packaged wallpaper" 1 \
"$(in_lab "grep -c 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png' $APPLETS")" "$(in_lab "grep -c 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png' $APPLETS")"
# And the one a desktop gets when nothing wrote it, which is what Tiny
# Pebbles showed: a package name, and the package there to be found.
check "a desktop nobody configured defaults to it" EkklesiaClassroom \
"$(in_lab 'sed -n "/^\[Wallpaper\]/,/^\[/s/^Image=//p" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults')"
check "and that package is the same picture" /usr/share/backgrounds/ekklesia/classroom-calm.png \
"$(in_lab 'readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png')"
section "it will not strand a teacher" section "it will not strand a teacher"
check "no automatic lock" false "$(session_config --file kscreenlockerrc --group Daemon --key Autolock)" check "no automatic lock" false "$(session_config --file kscreenlockerrc --group Daemon --key Autolock)"
+39 -9
View File
@@ -13,6 +13,8 @@
# tools/lab/dirty-upgrade [NEW.deb] [OLD.deb] # tools/lab/dirty-upgrade [NEW.deb] [OLD.deb]
# CHECK_ARGS=--restart tools/lab/dirty-upgrade ask twice, either side # CHECK_ARGS=--restart tools/lab/dirty-upgrade ask twice, either side
# of a logout # of a logout
# tools/lab/dirty-upgrade NEW.deb dist/ekklesia-classroom_0.2.2_amd64.deb
# the day the desktops move from 0.2.2
# #
# The container is restarted between the install and the session, because # The container is restarted between the install and the session, because
# a room takes its upgrade and reboots. # a room takes its upgrade and reboots.
@@ -38,13 +40,30 @@ docker exec "$NAME" sh -c '
# backports for Hyprland. The lab is not testing that. # backports for Hyprland. The lab is not testing that.
systemctl mask ekklesia-classroom-provision.service >/dev/null 2>&1' systemctl mask ekklesia-classroom-provision.service >/dev/null 2>&1'
# Which version is installed, fully configured, or the rehearsal stops.
# Every step below is a question about one particular release, and a
# step that quietly left the machine on another one answers for the
# wrong release — which is how the 0.2.2 rehearsal once passed on 0.2.2.
require_installed() {
local want got
want=$(dpkg-deb --field "$1" Version)
got=$(docker exec "$NAME" dpkg-query -W -f='${Version} ${db:Status-Abbrev}' ekklesia-classroom 2>/dev/null || true)
echo " installed: $got"
if [[ "$got" != "$want ii " ]]; then
echo " $(basename "$1") DID NOT INSTALL: [$got], wanted [$want ii]" >&2
echo " Nothing after this would be a question about $want." >&2
exit 1
fi
}
step "the old classroom: $(basename "$OLD")" step "the old classroom: $(basename "$OLD")"
# With apt, the way a room installs anything, so an old package's own
# dependencies are resolved rather than forced.
docker exec "$NAME" sh -c " docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${OLD#$ROOT/}' >/dev/null 2>&1 DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '/srv/classroom/${OLD#$ROOT/}' >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
true" || true true" || true
docker exec "$NAME" dpkg-query -W -f=' installed: ${Version}\n' ekklesia-classroom require_installed "$OLD"
step "and everything a used machine accumulates" step "and everything a used machine accumulates"
docker exec -i "$NAME" bash <<'DIRT' docker exec -i "$NAME" bash <<'DIRT'
@@ -164,14 +183,16 @@ DIRT
step "the new classroom: $(basename "$NEW")" step "the new classroom: $(basename "$NEW")"
docker exec "$NAME" sh -c ' docker exec "$NAME" sh -c '
systemctl unmask ekklesia-classroom-provision.service >/dev/null 2>&1 || true' systemctl unmask ekklesia-classroom-provision.service >/dev/null 2>&1 || true'
# The way ekklesia-classroom-update installs it: apt, not dpkg. The
# package conflicts with khelpcenter since 0.2.6.2, and dpkg -i refuses a
# conflict where apt resolves it — so from 0.2.2 this step used to leave
# the machine on 0.2.2 and every check below was asked of the old release.
docker exec "$NAME" sh -c " docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${NEW#$ROOT/}' 2>&1 \ DEBIAN_FRONTEND=noninteractive apt-get install -y '/srv/classroom/${NEW#$ROOT/}' 2>&1 \
| grep -E '^\[classroom-migrate\]' || true | grep -E '^\[classroom-migrate\]' || true
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
rm -f /usr/sbin/policy-rc.d rm -f /usr/sbin/policy-rc.d
true" || true true" || true
docker exec "$NAME" dpkg-query -W -f=' installed: ${Version} ${db:Status-Abbrev}\n' ekklesia-classroom require_installed "$NEW"
step "and the room reboots" step "and the room reboots"
"$LAB" reboot "$LAB" reboot
@@ -188,8 +209,9 @@ step "and again, installed a second time over itself"
# An update that changes nothing must change nothing. The migration runs # An update that changes nothing must change nothing. The migration runs
# again here, over a machine that is already converged. # again here, over a machine that is already converged.
docker exec "$NAME" sh -c " docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${NEW#$ROOT/}' >/dev/null 2>&1 DEBIAN_FRONTEND=noninteractive apt-get install --reinstall -y -qq '/srv/classroom/${NEW#$ROOT/}' >/dev/null 2>&1
true" || true true" || true
require_installed "$NEW"
before=$(docker exec "$NAME" sh -c 'cat /etc/ekklesia-classroom/device.conf | sha256sum') before=$(docker exec "$NAME" sh -c 'cat /etc/ekklesia-classroom/device.conf | sha256sum')
"$LAB" plasma-down >/dev/null "$LAB" plasma-down >/dev/null
"$LAB" plasma-up >/dev/null "$LAB" plasma-up >/dev/null
@@ -230,6 +252,14 @@ fi
theme dark >/dev/null theme dark >/dev/null
echo " back to $(theme show)" echo " back to $(theme show)"
step "and every desktop has the classroom's wallpaper, however it was made"
# Tiny Pebbles, 0.2.2 -> 0.2.6.4: a desktop Plasma made after the layout
# had run came up with Plasma's own wallpaper, and nothing here noticed,
# because every session this rehearsal started had its screen from the
# beginning. This makes Plasma build one by itself, and puts Tiny
# Pebbles' own file in front of a shell restart.
"$ROOT/tools/lab/wallpaper-check" || converged=1
step "and it still recovers" step "and it still recovers"
"$ROOT/tools/lab/recovery-check" || converged=1 "$ROOT/tools/lab/recovery-check" || converged=1
+162
View File
@@ -0,0 +1,162 @@
#!/bin/bash
# When a classroom checks for updates, measured on the lab's own systemd.
#
# switched on at the time the timer fires once at its time, and not
# again that day
# switched off at the time the lab machine is really stopped across the
# time, then started: the missed check runs
# once after it boots, and does not loop
#
# A pair of test units stands in for ekklesia-classroom-update: the same
# [Timer] settings with the time moved to a minute or two from now (the
# real one would update the lab for real). The real timer's own settings,
# after installing the package, are the release gate's to check.
#
# And classroom-reboot-when-idle against real kernel input: a mouse made
# with /dev/uinput, moved and then left alone, with a harmless command in
# place of the reboot.
#
# tools/lab/update-schedule-check
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
LAB="$ROOT/tools/lab/lab"
IDLE="${IDLE_COMMAND:-/usr/local/bin/classroom-reboot-when-idle}"
LOG=/var/tmp/ekk-schedule-test.log
lab() { docker exec -i "$NAME" bash -c "$1"; }
passed=0
failed=0
judge() {
if eval "$2"; then
printf ' \033[32mPASS\033[0m %s\n' "$1"
passed=$((passed + 1))
else
printf ' \033[31mFAIL\033[0m %s\n' "$1"
failed=$((failed + 1))
fi
}
# A timer like the real one, due at HH:MM:SS local time, with DELAY spread.
install_test_timer() { # WHEN DELAY
lab "cat > /etc/systemd/system/ekk-schedule-test.service <<'UNIT'
[Unit]
Description=Stand-in for the update check
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo \"fired \$(date +%%s) boot \$(cut -d. -f1 /proc/uptime)\" >> $LOG'
UNIT
cat > /etc/systemd/system/ekk-schedule-test.timer <<UNIT
[Unit]
Description=Stand-in for the update check
[Timer]
OnCalendar=*-*-* $1
RandomizedDelaySec=$2
Persistent=true
[Install]
WantedBy=timers.target
UNIT
rm -f $LOG /var/lib/systemd/timers/stamp-ekk-schedule-test.timer
systemctl daemon-reload
systemctl enable --now ekk-schedule-test.timer >/dev/null 2>&1"
}
fired() { lab "grep -c fired $LOG 2>/dev/null || echo 0" | tail -1; }
local_in() { lab "date -d '+$1 seconds' +%H:%M:%S"; } # local time SECONDS from now
echo "switched on at the time"
due=$(local_in 60)
install_test_timer "$due" 0
judge "the stand-in is waiting for $due, local time" \
"lab 'systemctl list-timers ekk-schedule-test.timer --no-legend | grep -q ekk-schedule-test'"
sleep 80
judge "at $due it checked, once" '[[ $(fired) -eq 1 ]]'
sleep 60
judge "and not again (a minute later, still once)" '[[ $(fired) -eq 1 ]]'
next=$(lab "systemctl show ekk-schedule-test.timer -p NextElapseUSecRealtime --value")
tomorrow=$(lab "date -d tomorrow +%Y-%m-%d")
judge "its next check is tomorrow at $due ($next)" '[[ "$next" == *"$tomorrow $due"* ]]'
echo "switched off at the time"
due=$(local_in 90)
install_test_timer "$due" 60
lab "systemctl start ekk-schedule-test.timer; sync"
# A stamp from a previous check, as every classroom has one: last checked
# yesterday. The catch-up is decided against it.
lab "touch -d yesterday /var/lib/systemd/timers/stamp-ekk-schedule-test.timer"
echo " powering the lab off before $due"
docker stop -t 20 "$NAME" >/dev/null
sleep 150
echo " and on again, after it"
"$LAB" reboot >/dev/null 2>&1
# When the lab machine really started (inside a container /proc/uptime is
# the host's, so this is asked of docker).
booted=$(date -d "$(docker inspect -f '{{.State.StartedAt}}' "$NAME")" +%s)
judge "while it was off, nothing ran" '[[ $(lab "grep -c fired $LOG 2>/dev/null || echo 0" | tail -1) -le 1 ]]'
for _ in $(seq 1 24); do [[ $(fired) -ge 1 ]] && break; sleep 5; done
judge "switched on after $due: the missed check ran after it booted ($(lab "tail -1 $LOG"))" '[[ $(fired) -eq 1 ]]'
after=$(( $(lab "awk '{print \$2}' $LOG | head -1") - booted ))
judge "$after s after it started: once it was up, within the minute of spread" '(( after >= 0 && after <= 150 ))'
sleep 90
judge "and only once: no loop after booting (90 s later, still once)" '[[ $(fired) -eq 1 ]]'
lab "systemctl disable --now ekk-schedule-test.timer >/dev/null 2>&1; rm -f /etc/systemd/system/ekk-schedule-test.* $LOG /var/lib/systemd/timers/stamp-ekk-schedule-test.timer; systemctl daemon-reload"
echo "reboot only when nobody is using it"
docker cp "$ROOT/scripts/classroom-reboot-when-idle" "$NAME:/opt/classroom-reboot-when-idle" >/dev/null
lab "chmod 755 /opt/classroom-reboot-when-idle; rm -f /tmp/rebooted"
[[ "$IDLE" == /usr/local/bin/* ]] && lab "test -x $IDLE" || IDLE=/opt/classroom-reboot-when-idle
cat > /tmp/update-schedule-mouse.py <<'PY'
"""A mouse made with /dev/uinput: real kernel input events. Moves for N s, then stays."""
import fcntl, os, struct, sys, time
UI_SET_EVBIT, UI_SET_RELBIT, UI_SET_KEYBIT = 0x40045564, 0x40045566, 0x40045565
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
EV_SYN, EV_KEY, EV_REL, REL_X, BTN_LEFT = 0, 1, 2, 0, 0x110
fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
for ioctl, value in ((UI_SET_EVBIT, EV_KEY), (UI_SET_KEYBIT, BTN_LEFT), (UI_SET_EVBIT, EV_REL), (UI_SET_RELBIT, REL_X)):
fcntl.ioctl(fd, ioctl, value)
os.write(fd, struct.pack("80sHHHHi", b"lab-classroom-mouse", 3, 1, 1, 1, 0) + b"\0" * (4 * 64 * 4))
fcntl.ioctl(fd, UI_DEV_CREATE)
time.sleep(1)
def event(kind, code, value):
now = time.time()
os.write(fd, struct.pack("llHHi", int(now), int(now % 1 * 1e6), kind, code, value))
end = time.monotonic() + float(sys.argv[1])
while time.monotonic() < end:
event(EV_REL, REL_X, 1); event(EV_SYN, 0, 0)
time.sleep(2)
print("mouse left alone", flush=True)
time.sleep(float(sys.argv[2]))
fcntl.ioctl(fd, UI_DEV_DESTROY)
PY
docker cp /tmp/update-schedule-mouse.py "$NAME:/opt/update-schedule-mouse.py" >/dev/null
rm -f /tmp/update-schedule-mouse.py
docker exec -d "$NAME" python3 /opt/update-schedule-mouse.py 40 90
sleep 2
# The kernel makes the device; on a classroom machine devtmpfs gives it a
# node in /dev/input. The lab's /dev is a plain tmpfs, so the node is made
# here, from what sysfs says, as devtmpfs would.
lab 'for e in /sys/class/input/event*; do
[[ "$(cat $e/device/name)" == lab-classroom-mouse ]] || continue
IFS=: read -r major minor < $e/dev
mknod /dev/input/$(basename $e) c $major $minor 2>/dev/null
echo $(basename $e) > /tmp/lab-mouse-node
done'
sleep 1
docker exec -d "$NAME" sh -c "$IDLE --idle-seconds 25 --check-seconds 2 --reboot-command touch /tmp/rebooted > /tmp/idle.log 2>&1"
sleep 35
judge "the mouse moved every 2 s for 40 s: no reboot, against a 25 s limit" "! lab 'test -e /tmp/rebooted'"
judge "it was watching real devices ($(lab 'grep -o "watching [0-9]* input devices" /tmp/idle.log'))" \
"lab 'grep -qE \"watching [1-9][0-9]* input devices\" /tmp/idle.log'"
sleep 40
judge "left alone for longer than the limit: it rebooted" "lab 'test -e /tmp/rebooted'"
lab "pkill -f '[u]pdate-schedule-mouse'; rm -f /dev/input/\$(cat /tmp/lab-mouse-node 2>/dev/null) /tmp/lab-mouse-node /tmp/rebooted /opt/update-schedule-mouse.py /opt/classroom-reboot-when-idle"
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]
+238
View File
@@ -0,0 +1,238 @@
#!/bin/bash
# Does every desktop show the classroom's wallpaper — including one Plasma
# makes by itself after the layout has run?
#
# Tiny Pebbles came through the 0.2.2 upgrade with its desktop numbered 10
# and nothing configured on it, and showed Plasma's own purple wallpaper.
# The layout only reaches the desktops that exist while it runs, and a
# room whose updater reboots it at three in the morning with the
# television off has none then. The lab cannot switch a television on, so
# it makes Plasma create a desktop the other way it does, through a new
# activity: the same code in the shell, and measured to give the same
# bare containment.
#
# tools/lab/wallpaper-check against the running lab session
#
# Each answer is read off the screen, not out of a file: a configuration
# can say anything, and the question is what the room sees. The sky at the
# top of the classroom's picture is pale blue; Plasma's is purple.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
LAB="$ROOT/tools/lab/lab"
NAME="${LAB_NAME:-classroom-v2-lab}"
APPLETS=/home/classroom/.config/plasma-org.kde.plasma.desktop-appletsrc
PASS=0
FAIL=0
check() {
if [[ "$2" == "$3" ]]; then
printf ' \033[32mPASS\033[0m %-58s %s\n' "$1" "$3"
PASS=$((PASS + 1))
else
printf ' \033[31mFAIL\033[0m %-58s got [%s] want [%s]\n' "$1" "$3" "$2"
FAIL=$((FAIL + 1))
fi
}
section() { printf '\n%s\n' "$1"; }
in_lab() { docker exec "$NAME" bash -c "$1" 2>/dev/null; }
# The same, with this script's input handed through — for a program on a
# here-document. Without -i docker gives it nothing to read, and it runs
# on nothing and says nothing.
in_lab_fed() { docker exec -i "$NAME" bash -c "$1"; }
user() { "$LAB" run "$@" 2>/dev/null; }
activities() {
user busctl --user call org.kde.ActivityManager /ActivityManager/Activities \
org.kde.ActivityManager.Activities "$@"
}
# What is on the screen at the top middle, where the classroom's picture
# is sky and Plasma's is purple: "classroom", "plasma", or the colour.
screen() {
"$LAB" outer grim -t ppm - 2>/dev/null | python3 -c '
import sys
data = sys.stdin.buffer.read()
fields, at = [], 0
while len(fields) < 4:
while data[at:at + 1].isspace():
at += 1
end = at
while not data[end:end + 1].isspace():
end += 1
fields.append(data[at:end])
at = end
width, height = int(fields[1]), int(fields[2])
pixels = data[at + 1:]
total = [0, 0, 0]
count = 0
for y in range(20, 140, 4):
for x in range(width * 2 // 5, width * 3 // 5, 8):
offset = (y * width + x) * 3
for channel in range(3):
total[channel] += pixels[offset + channel]
count += 1
r, g, b = (value / count for value in total)
if b > r + 25 and g > r + 10:
print("classroom")
elif r > g + 25 and b > g + 25:
print("plasma")
else:
print(f"rgb({r:.0f},{g:.0f},{b:.0f})")
'
}
# The shell writes its file a few seconds after a change, not at once.
settle() { sleep "${1:-35}"; }
# The shell's file, read as groups rather than grepped, so nothing here
# can pick a line out of the wrong containment.
#
# applets panels each panel's number, its applets' numbers and plugins
# and their order. Not the panel's text: the shell adds
# keys of its own to applets as soon as it runs (popup
# sizes, the volume's migration), and that is Plasma,
# not the repair. tests/ checks the repair's own
# output byte for byte.
# applets desktops the desktop containments as "id:image" or "id:none",
# found by plugin and never by number
applets() {
in_lab_fed "python3 - $1 $APPLETS" <<'PY'
import re, sys
mode, path = sys.argv[1], sys.argv[2]
groups, name = {}, None
for line in open(path).read().splitlines():
if line.startswith("[") and line.endswith("]"):
name = line
groups[name] = []
elif name and line:
groups[name].append(line)
def top(cid):
return groups.get(f"[Containments][{cid}]", [])
ids = [m.group(1) for g in groups for m in [re.match(r"^\[Containments\]\[(\d+)\]$", g)] if m]
if mode == "panels":
for c in ids:
if "plugin=org.kde.panel" not in top(c):
continue
applets = sorted(
(int(m.group(1)), line[len("plugin=org.kde.plasma."):])
for g in groups
for m in [re.match(r"^\[Containments\]\[%s\]\[Applets\]\[(\d+)\]$" % c, g)] if m
for line in groups[g] if line.startswith("plugin=")
)
order = [l[len("AppletOrder="):] for l in groups.get(f"[Containments][{c}][General]", [])
if l.startswith("AppletOrder=")]
print(f"panel {c}:", " ".join(f"{n}={name}" for n, name in applets), "order", *order)
else:
image = "Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png"
out = []
for c in ids:
if {"plugin=org.kde.plasma.folder", "plugin=org.kde.desktopcontainment"} & set(top(c)):
wallpaper = groups.get(f"[Containments][{c}][Wallpaper][org.kde.image][General]", [])
out.append(f"{c}:{'image' if image in wallpaper else 'none'}")
print(" ".join(out))
PY
}
panel_groups() { applets panels; }
desktops() { applets desktops; }
last_repair() {
user journalctl --user -u plasma-plasmashell.service --no-pager -o cat \
| grep '^classroom-desktop-wallpaper:' | tail -1
}
restart_shell() {
user systemctl --user restart plasma-plasmashell.service
for _ in $(seq 1 30); do
in_lab 'pgrep -x plasmashell >/dev/null' && break
sleep 1
done
sleep 10
}
section "the package"
check "the look and feel's default names a package" EkklesiaClassroom \
"$(in_lab 'sed -n "/^\[Wallpaper\]/,/^\[/s/^Image=//p" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults')"
check "which is installed, and is the classroom's picture" /usr/share/backgrounds/ekklesia/classroom-calm.png \
"$(in_lab 'readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png')"
check "the repair runs before every shell start" 1 \
"$(in_lab 'grep -c "^ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper" /etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf')"
section "the session as it came up"
check "the room shows the classroom's picture" classroom "$(screen)"
original=$(activities CurrentActivity | cut -d'"' -f2)
section "a desktop Plasma makes by itself (Tiny Pebbles' television)"
panels_before=$(panel_groups)
late=$(activities AddActivity s "Late screen" | cut -d'"' -f2)
activities SetCurrentActivity s "$late" >/dev/null
settle
now=$(desktops)
check "Plasma made a second desktop" 2 "$(wc -w <<< "$now")"
check "and wrote no wallpaper into it, as on Tiny Pebbles" 1 "$(grep -o ':none' <<< "$now" | wc -l)"
check "the room still shows the classroom's picture" classroom "$(screen)"
section "the shell restarted, without a login"
restart_shell
check "the repair wrote it in" "classroom-desktop-wallpaper: the wallpaper is now configured on desktop $(tr ' ' '\n' <<< "$now" | cut -d: -f1 | paste -sd, | sed 's/,/, /g')" \
"$(last_repair)"
check "every desktop now names the picture" 0 "$(desktops | grep -o ':none' | wc -l)"
check "the panel keeps its numbers, applets and order" "$panels_before" "$(panel_groups)"
check "the room shows the classroom's picture" classroom "$(screen)"
restart_shell
check "a second restart finds nothing to do" \
"classroom-desktop-wallpaper: desktop $(desktops | tr ' ' '\n' | cut -d: -f1 | paste -sd, | sed 's/,/, /g'): the classroom wallpaper already" \
"$(last_repair)"
check "and still does" "$panels_before" "$(panel_groups)"
activities SetCurrentActivity s "$original" >/dev/null
activities RemoveActivity s "$late" >/dev/null
sleep 3
section "Tiny Pebbles' own file: desktop 10, panel 1, no wallpaper"
user systemctl --user stop plasma-plasmashell.service
in_lab_fed "python3 - '$original' $APPLETS" <<'PY'
import sys
activity, path = sys.argv[1], sys.argv[2]
applets = ["quicklaunch", "panelspacer", "icontasks", "panelspacer",
"networkmanagement", "volume", "notifications", "digitalclock"]
out = ["[Containments][1]", "activityId=", "formfactor=2", "immutability=1",
"lastScreen=0", "location=4", "plugin=org.kde.panel",
"wallpaperplugin=org.kde.image", ""]
for number, applet in enumerate(applets, start=2):
out += [f"[Containments][1][Applets][{number}]", "immutability=1",
f"plugin=org.kde.plasma.{applet}", ""]
out += ["[Containments][1][Applets][4][Configuration][General]",
"launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop,applications:ekklesia-dvd.desktop",
"sortingStrategy=1", "",
"[Containments][1][General]", "AppletOrder=2;3;4;5;6;7;8;9", "",
"[Containments][10]", f"activityId={activity}", "formfactor=0",
"immutability=1", "lastScreen=0", "location=0",
"plugin=org.kde.plasma.folder", "wallpaperplugin=org.kde.image", "",
"[Containments][10][General]", 'positions={"1920x1080":[]}', ""]
open(path, "w").write("\n".join(out))
PY
in_lab "chown classroom:classroom $APPLETS"
panels_before=$(panel_groups)
user systemctl --user start plasma-plasmashell.service
sleep 12
check "the repair found desktop 10 and wrote it" \
"classroom-desktop-wallpaper: the wallpaper is now configured on desktop 10" "$(last_repair)"
check "desktop 10 names the picture" "10:image" "$(desktops)"
check "the panel is still containment 1, applets 2 to 9, in order" "$panels_before" "$(panel_groups)"
check "the room shows the classroom's picture" classroom "$(screen)"
section "and at the next login"
"$LAB" plasma-down >/dev/null
"$LAB" plasma-up >/dev/null
settle 10
check "the reset left the repair nothing to do" \
"classroom-desktop-wallpaper: no desktop configuration yet; the layout writes the wallpaper" \
"$(last_repair)"
check "the layout rebuilt one desktop with the picture" 1 "$(desktops | grep -o ':image' | wc -l)"
check "the room shows the classroom's picture" classroom "$(screen)"
printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]
+401
View File
@@ -0,0 +1,401 @@
#!/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, and without which a job refuses to publish. Run by hand
# (no GITHUB_ACTIONS), ~/.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 it was given, in a
# configuration of its own (RELEASE_SSH_CONFIG, used with ssh -F), so
# nothing on the runner's own disk is read or overwritten, and the key is
# deleted when this ends. Under Actions there is no other way: a job
# without the key stops, rather than reach for whatever credentials the
# machine it runs on happens to hold. Measured: on the development VM,
# which holds the production publish key, the fallback would have
# published a throwaway build to the real server.
ssh_setup() {
local dir="$WORK/ssh"
if [[ -z "${RELEASE_SSH_KEY:-}" ]]; then
if [[ -n "${GITHUB_ACTIONS:-}${GITEA_ACTIONS:-}" ]]; then
fail "no RELEASE_SSH_KEY: set the CLASSROOM_UPDATES_SSH_KEY secret. A job never uses the runner's own ssh keys"
fi
echo "release-ci: no RELEASE_SSH_KEY; by hand, so 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 "$dir"
chmod 700 "$dir"
(umask 077; printf '%s\n' "$RELEASE_SSH_KEY" > "$dir/key")
trap 'rm -f "$WORK/ssh/key"' EXIT
printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$dir/known_hosts"
cat > "$dir/config" <<EOF
Host $RELEASE_HOST
HostName ${RELEASE_SSH_ADDRESS:?RELEASE_SSH_ADDRESS is required}
User ${RELEASE_SSH_USER:?RELEASE_SSH_USER is required}
IdentityFile $dir/key
IdentitiesOnly yes
UserKnownHostsFile $dir/known_hosts
GlobalKnownHostsFile /dev/null
StrictHostKeyChecking yes
BatchMode yes
EOF
chmod 600 "$dir/config"
export RELEASE_SSH_CONFIG="$dir/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
+219
View File
@@ -0,0 +1,219 @@
#!/bin/bash
# THE RELEASE GATE: may this exact file go to a classroom?
#
# tools/release-gate.sh PACKAGE.deb [FROM_VERSION...]
# tools/release-gate.sh --ci PACKAGE.deb for CI; see below
# tools/release-gate.sh --still-current REPORT PACKAGE.deb
#
# Runs tools/validate-trixie-release.sh against PACKAGE as
#
# * a fresh install;
# * an upgrade from what canary serves today, on a canary room;
# * an upgrade from what stable serves today, on a stable room;
# * an upgrade from each FROM_VERSION, on a stable room — the release a
# room is really still on, which can be several behind stable.
#
# The releases it upgrades from are downloaded over HTTPS the way a
# classroom downloads them, and each is checked against the SHA-256 its
# manifest publishes, or, for a FROM_VERSION, against the version dpkg
# reads out of it. Nothing here needs a credential or writes to the
# update server, so a CI runner can run it on code nobody has reviewed.
#
# Every run is one line in RELEASE_GATE_REPORT (default: printed only),
# so the result can be attached to the release it describes.
#
# It stops at the first failure: a gate that carries on reports a pass
# for a machine it never finished building.
#
# --ci is for a commit, not a release. Between releases master's VERSION
# is what canary already serves, and no room can upgrade to a version it
# has; such an upgrade is recorded as SKIP, with why, instead of failing
# every commit. Its report says it is a CI report and can never stand in
# for a release's.
#
# --still-current REPORT PACKAGE asks whether a gate report may still be
# relied on to publish PACKAGE: it has to be a release gate's report, for
# exactly these bytes, that passed, and whose upgrades were from exactly
# what canary and stable serve now. A report made before either channel
# moved is not current, and the gate has to be run again. Exit 0 or 1.
set -euo pipefail
# The fetches below run in $(...), which otherwise ignores set -e: a
# manifest that failed to download would go on to compare an empty hash
# with an empty hash, and match.
shopt -s inherit_errexit
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT/tools/release-lib.sh"
usage() {
echo "Usage: $0 [--ci] PACKAGE.deb [FROM_VERSION...]" >&2
echo " $0 --still-current REPORT PACKAGE.deb" >&2
exit 2
}
mode=release
case "${1:-}" in
--ci) mode=ci; shift ;;
--still-current) mode=check; shift ;;
esac
if [[ "$mode" == check ]]; then
[[ $# -eq 2 ]] || usage
gate_report="$1"
shift
fi
[[ $# -ge 1 ]] || usage
candidate="$(realpath "$1")"
shift
extra_from=("$@")
[[ -f "$candidate" ]] || { echo "Package not found: $candidate" >&2; exit 1; }
version="$(release_package_version "$candidate")"
release_require_version "$version"
# The name a release is published under is the version dpkg reads out of
# it. A file called 0.2.6.6 that says it is 0.2.6.5 is the wrong file.
if [[ "$(basename "$candidate")" != "$(release_package_name "$version")" ]]; then
echo "$(basename "$candidate") says it is $version." >&2
exit 1
fi
hash="$(sha256sum "$candidate" | awk '{print $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"])'
}
if [[ "$mode" == check ]]; then
stale() { echo "The gate report $gate_report cannot be relied on: $1" >&2; exit 1; }
name="$(basename "$candidate")"
[[ -f "$gate_report" ]] || stale "there is no such file"
grep -qx "release gate for $name" "$gate_report" || stale "it is not about $name"
grep -qx "mode release" "$gate_report" || stale "it is not a release gate's report"
grep -qx "sha256 $hash" "$gate_report" || stale "it is about other bytes than $hash"
[[ "$(tail -n 1 "$gate_report")" == *": $name passed the release gate" ]] \
|| stale "it does not end in a pass"
! grep -q '^SKIP' "$gate_report" || stale "it skipped something"
grep -q "^PASS fresh install of $version " "$gate_report" || stale "it has no fresh install"
for channel in canary stable; do
now="$(served "$channel")"
grep -qF "PASS upgrade $now -> $version on a $channel room ($channel serves $now)" "$gate_report" \
|| stale "$channel serves $now now, and the report did not upgrade from that"
done
echo "The gate report for $name, sha256 $hash, is current: canary and stable are where it left them."
exit 0
fi
report="${RELEASE_GATE_REPORT:-}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
record() {
printf '%s\n' "$1"
[[ -z "$report" ]] || printf '%s\n' "$1" >> "$report"
}
[[ -z "$report" ]] || : > "$report"
record "release gate for $(basename "$candidate")"
record "sha256 $hash"
record "mode $mode"
record "started $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# A channel's current package, fetched as a classroom fetches it and
# proven to be what the manifest promises.
fetch_channel() {
local channel="$1" manifest from from_hash name got
manifest="$(curl -fsS -H 'Cache-Control: no-cache' "$RELEASE_URL/$channel/manifest.json")"
read -r from from_hash < <(python3 -c 'import json, sys
m = json.loads(sys.argv[1])
print(m["version"], m["sha256"])' "$manifest")
release_require_version "$from"
[[ "$from_hash" =~ ^[0-9a-f]{64}$ ]]
name="$(release_package_name "$from")"
curl -fsS -o "$work/$name" "$RELEASE_URL/packages/$name"
got="$(sha256sum "$work/$name" | awk '{print $1}')"
if [[ "$got" != "$from_hash" ]]; then
echo "$channel serves $name with sha256 $got; its manifest says $from_hash" >&2
return 1
fi
printf '%s\n' "$work/$name"
}
# A published release by version, for the rooms still on an older one.
fetch_version() {
local from="$1" name
release_require_version "$from"
name="$(release_package_name "$from")"
curl -fsS -o "$work/$name" "$RELEASE_URL/packages/$name"
if [[ "$(release_package_version "$work/$name")" != "$from" ]]; then
echo "$name does not say it is $from" >&2
return 1
fi
printf '%s\n' "$work/$name"
}
run() {
local label="$1" started
shift
started=$SECONDS
echo
echo "== $label"
"$@"
record "PASS $label ($(( (SECONDS - started) / 60 )) min)"
}
# A new machine is a stable room: that is what a fresh device.conf says,
# so that is the manifest its updater is asked about.
run "fresh install of $version (a new machine, on stable)" \
env VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$candidate"
for channel in canary stable; do
previous="$(fetch_channel "$channel")"
from="$(release_package_version "$previous")"
if ! release_version_gt "$version" "$from"; then
if [[ "$mode" == ci ]]; then
record "SKIP upgrade $from -> $version on a $channel room: $channel serves $from, not older than this commit's VERSION (a CI build, not a release)"
continue
fi
echo "$channel is on $from; $version is not newer, so no room could take it." >&2
exit 1
fi
run "upgrade $from -> $version on a $channel room ($channel serves $from)" \
env VALIDATION_CHANNEL="$channel" \
"$ROOT/tools/validate-trixie-release.sh" "$candidate" "$previous"
done
for from in "${extra_from[@]}"; do
previous="$(fetch_version "$from")"
run "upgrade $from -> $version on a stable room" \
env VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$candidate" "$previous"
done
if [[ "$mode" == ci ]]; then
record "finished $(date -u +%Y-%m-%dT%H:%M:%SZ): $(basename "$candidate") passed the CI gate, which is not a release gate"
else
record "finished $(date -u +%Y-%m-%dT%H:%M:%SZ): $(basename "$candidate") passed the release gate"
fi
+21 -3
View File
@@ -66,8 +66,26 @@ release_package_version() {
dpkg-deb --field "$1" Version dpkg-deb --field "$1" Version
} }
# ssh to the update server. With RELEASE_SSH_CONFIG, that file and nothing
# else configures it — `ssh -F` ignores ~/.ssh/config and the system's — so
# a release job uses only the key it was given, and never the credentials
# of whatever machine it happens to run on. Without it, this machine's own
# configuration, as a person publishing by hand always has.
release_ssh() {
if [[ -n "${RELEASE_SSH_CONFIG:-}" ]]; then
ssh -F "$RELEASE_SSH_CONFIG" "$@"
else
ssh "$@"
fi
}
# The same, as rsync's -e.
release_rsync_ssh() {
printf 'ssh%s\n' "${RELEASE_SSH_CONFIG:+ -F $RELEASE_SSH_CONFIG}"
}
release_remote() { release_remote() {
ssh "$RELEASE_HOST" "$@" release_ssh "$RELEASE_HOST" "$@"
} }
# Every version that has ever been published, one per line, read from the # Every version that has ever been published, one per line, read from the
@@ -151,7 +169,7 @@ release_upload_package() {
name="$(basename "$package")" name="$(basename "$package")"
local_hash="$(sha256sum "$package" | awk '{print $1}')" local_hash="$(sha256sum "$package" | awk '{print $1}')"
rsync -av --ignore-existing -e ssh \ rsync -av --ignore-existing -e "$(release_rsync_ssh)" \
"$package" "$RELEASE_HOST:$RELEASE_ROOT/packages/" "$package" "$RELEASE_HOST:$RELEASE_ROOT/packages/"
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')" remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
@@ -192,7 +210,7 @@ release_publish_manifest() {
EOF EOF
# Manifest deliberately goes LAST, after the package it names. # Manifest deliberately goes LAST, after the package it names.
rsync -av -e ssh \ rsync -av -e "$(release_rsync_ssh)" \
"$RELEASE_LOCAL/$channel/manifest.json" \ "$RELEASE_LOCAL/$channel/manifest.json" \
"$RELEASE_HOST:$RELEASE_ROOT/$channel/manifest.json" "$RELEASE_HOST:$RELEASE_ROOT/$channel/manifest.json"
+89
View File
@@ -117,6 +117,13 @@ CUSTOM_SETTING=preserve-me-too
CONFIG CONFIG
sed -i "s/@CHANNEL@/${channel}/" /etc/ekklesia-classroom/device.conf sed -i "s/@CHANNEL@/${channel}/" /etc/ekklesia-classroom/device.conf
previous_version="$(dpkg-query -W -f='${Version}' ekklesia-classroom)" previous_version="$(dpkg-query -W -f='${Version}' ekklesia-classroom)"
# The release this is an upgrade FROM, proven rather than assumed: an
# install that silently did not happen makes every check below a
# check of a fresh install instead.
[[ "$previous_version" == "$(dpkg-deb --field "$previous" Version)" ]]
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)" == "ii " ]]
echo "installed the previous release, ekklesia-classroom $previous_version"
fi fi
run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb
@@ -130,6 +137,7 @@ run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb
# for ever. # for ever.
candidate_version="$(dpkg-deb --field /packages/candidate.deb Version)" candidate_version="$(dpkg-deb --field /packages/candidate.deb Version)"
[[ "$(dpkg-query -W -f='${Version}' ekklesia-classroom)" == "$candidate_version" ]] [[ "$(dpkg-query -W -f='${Version}' ekklesia-classroom)" == "$candidate_version" ]]
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)" == "ii " ]]
[[ "$(cat /usr/local/lib/ekklesia-classroom/VERSION)" == "$candidate_version" ]] [[ "$(cat /usr/local/lib/ekklesia-classroom/VERSION)" == "$candidate_version" ]]
echo "installed ekklesia-classroom $candidate_version${previous_version:+ over $previous_version}" echo "installed ekklesia-classroom $candidate_version${previous_version:+ over $previous_version}"
@@ -145,6 +153,20 @@ systemctl is-enabled NetworkManager.service >/dev/null
systemctl is-enabled cups.service >/dev/null systemctl is-enabled cups.service >/dev/null
! systemctl is-enabled NetworkManager-wait-online.service >/dev/null 2>&1 ! systemctl is-enabled NetworkManager-wait-online.service >/dev/null 2>&1
# The update check: daily at 03:00, caught up after a night switched off,
# and still so after this install or upgrade. The reboot after an update
# waits until nobody is using the computer, in a unit nothing enables.
systemctl is-enabled ekklesia-classroom-update.timer >/dev/null
timer=/etc/systemd/system/ekklesia-classroom-update.timer
grep -qx 'OnCalendar=\*-\*-\* 03:00:00' "$timer"
grep -qx 'Persistent=true' "$timer"
! grep -qE '^(OnBootSec|OnStartupSec|OnUnitActiveSec|OnUnitInactiveSec|OnActiveSec)=' "$timer"
! ls /etc/systemd/system/ekklesia-classroom-update.timer.d/*.conf >/dev/null 2>&1
test -x /usr/local/bin/classroom-reboot-when-idle
[[ "$(systemctl is-enabled ekklesia-classroom-reboot-when-idle.service 2>/dev/null || true)" == "static" ]]
! grep -q '"systemctl", "reboot"' /usr/local/bin/ekklesia-classroom-update
grep -q 'ekklesia-classroom-reboot-when-idle.service' /usr/local/bin/ekklesia-classroom-update
# The audio policy reaches a session, and is ordered after the server it # The audio policy reaches a session, and is ordered after the server it
# actually talks to. Ordering it only after pipewire and wireplumber is # actually talks to. Ordering it only after pipewire and wireplumber is
# what let it race PipeWire's PulseAudio server at boot, fail its one # what let it race PipeWire's PulseAudio server at boot, fail its one
@@ -485,6 +507,71 @@ if [[ -n "$previous" ]]; then
grep -q 'var PRESENTATIONS = false;' \ grep -q 'var PRESENTATIONS = false;' \
/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
# TINY PEBBLES, 0.2.2 -> 0.2.6.4: THE DESKTOP WITH NO WALLPAPER.
#
# The room's desktop was containment 10, made by Plasma after the
# layout had run, with nothing configured on it; it showed Plasma's
# own wallpaper. What a desktop gets when nothing wrote it is the look
# and feel's default, and that has to be a package Plasma can find.
lnf=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults
[[ "$(sed -n '/^\[Wallpaper\]/,/^\[/s/^Image=//p' "$lnf")" == EkklesiaClassroom ]]
grep -q '"Id": "EkklesiaClassroom"' /usr/share/wallpapers/EkklesiaClassroom/metadata.json
[[ "$(readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png)" \
== /usr/share/backgrounds/ekklesia/classroom-calm.png ]]
grep -qx 'ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper' \
/etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf
# And the repair the package installed, on that room's own file: the
# panel built with no screen took 1 and its applets 2 to 9.
wallpaper_home=$(mktemp -d)
cat > "$wallpaper_home/plasma-org.kde.plasma.desktop-appletsrc" <<'APPLETS'
[Containments][1]
formfactor=2
location=4
plugin=org.kde.panel
wallpaperplugin=org.kde.image
[Containments][1][Applets][2]
plugin=org.kde.plasma.quicklaunch
[Containments][1][Applets][9]
plugin=org.kde.plasma.digitalclock
[Containments][1][General]
AppletOrder=2;3;4;5;6;7;8;9
[Containments][10]
activityId=62d3d55d-bd31-4fc8-9a7b-a5b9b2b99b60
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][10][General]
positions={"1920x1080":[]}
APPLETS
applets="$wallpaper_home/plasma-org.kde.plasma.desktop-appletsrc"
panel_before=$(sed -n '/^\[Containments\]\[1\]/,/^$/p' "$applets")
! XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper --check >/dev/null
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper \
| grep -q 'configured on desktop 10'
sed -n '/^\[Containments\]\[10\]\[Wallpaper\]\[org.kde.image\]\[General\]$/,/^$/p' "$applets" \
| grep -qx 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png'
[[ "$(sed -n '/^\[Containments\]\[1\]/,/^$/p' "$applets")" == "$panel_before" ]]
[[ "$(grep -c '^\[Containments\]\[[0-9]*\]$' "$applets")" -eq 2 ]]
# Twice changes nothing, and a room that was already right is not
# written at all.
repaired=$(sha256sum < "$applets")
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper \
| grep -q 'the classroom wallpaper already'
[[ "$(sha256sum < "$applets")" == "$repaired" ]]
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper --check >/dev/null
rm -rf "$wallpaper_home"
# And a room that was carried through the experiment on its own # And a room that was carried through the experiment on its own
# channel comes home, because that channel is the obsolete one now. # channel comes home, because that channel is the obsolete one now.
sed -i 's/^CHANNEL=.*/CHANNEL=classroom-v2/' /etc/ekklesia-classroom/device.conf sed -i 's/^CHANNEL=.*/CHANNEL=classroom-v2/' /etc/ekklesia-classroom/device.conf
@@ -505,6 +592,8 @@ if [[ -n "$previous" ]]; then
run_logged candidate-reinstall 900 \ run_logged candidate-reinstall 900 \
apt-get install --reinstall -y -qq /packages/candidate.deb apt-get install --reinstall -y -qq /packages/candidate.deb
[[ "$(dpkg-query -W -f='${Version} ${db:Status-Abbrev}' ekklesia-classroom)" \
== "$candidate_version ii " ]]
# An install that changes nothing must change nothing. # An install that changes nothing must change nothing.
for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do
+55 -36
View File
@@ -12,9 +12,12 @@ source "$ROOT/tools/release-lib.sh"
# there are two — 0.2.6 is followed by 0.2.6.1 if it is a fix and by # there are two — 0.2.6 is followed by 0.2.6.1 if it is a fix and by
# 0.2.7 if it is not — and which one a release is, is a decision about # 0.2.7 if it is not — and which one a release is, is a decision about
# what is in it, not arithmetic. # what is in it, not arithmetic.
if [[ $# -ne 1 ]]; then if [[ $# -lt 1 || $# -gt 2 ]]; then
current="$(release_channel_version canary || true)" current="$(release_channel_version canary || true)"
echo "Usage: $0 VERSION" >&2 echo "Usage: $0 VERSION [PACKAGE.deb]" >&2
echo >&2
echo "With PACKAGE.deb, that file is gated and published and nothing is" >&2
echo "built, so the file that was tested is the file that is published." >&2
echo >&2 echo >&2
echo "Canary is on ${current:-an unknown version}. Name the release, for example:" >&2 echo "Canary is on ${current:-an unknown version}. Name the release, for example:" >&2
if [[ -n "$current" ]] && release_version_valid "$current"; then if [[ -n "$current" ]] && release_version_valid "$current"; then
@@ -28,62 +31,78 @@ fi
version="$1" version="$1"
package_name="$(release_package_name "$version")" package_name="$(release_package_name "$version")"
package_path="$ROOT/dist/$package_name" package_path="$ROOT/dist/$package_name"
prebuilt="${2:-}"
echo "Current canary: $(release_channel_version canary || echo unknown)" echo "Current canary: $(release_channel_version canary || echo unknown)"
echo "New canary: $version" echo "New canary: $version"
echo echo
# RELEASE_GATE_FROM="0.2.2" adds the upgrade from an older release that
# rooms are still on to the gate, beside canary's and stable's own.
#
# Before anything is built: a version that has been published, or that # Before anything is built: a version that has been published, or that
# would not be newer than everything that has been, stops here. # would not be newer than everything that has been, stops here.
release_check_new_version "$version" release_check_new_version "$version"
# ONE FILE, FROM THE GATE TO THE ROOM.
#
# Given a package, that package is what is gated and published, and
# nothing is rebuilt: a build is not byte-for-byte repeatable, so a second
# build is a second, untested file. Without one, it is built here.
if [[ -n "$prebuilt" ]]; then
package_path="$(realpath "$prebuilt")"
if [[ "$(basename "$package_path")" != "$package_name" ]]; then
echo "$prebuilt is not named $package_name." >&2
exit 1
fi
if [[ "$(release_package_version "$package_path")" != "$version" ]]; then
echo "$prebuilt says it is $(release_package_version "$package_path"), not $version." >&2
exit 1
fi
echo "Publishing the package already built: $package_path"
else
"$ROOT/tools/build-release.sh" "$version" "$ROOT/tools/build-release.sh" "$version"
# Validate the exact artifact that will be published. If a previous canary is
# available locally, exercise the real upgrade path as well as a fresh install.
validation_args=("$package_path")
if [[ -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
previous_package_name="$(python3 -c \
'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \
"$RELEASE_LOCAL/canary/manifest.json")"
previous_package="$RELEASE_LOCAL/packages/$previous_package_name"
if [[ -f "$previous_package" && "$previous_package" != "$package_path" ]]; then
validation_args+=("$previous_package")
fi
fi fi
"$ROOT/tools/validate-trixie-release.sh" "${validation_args[@]}" # A fresh install, and the upgrade from whatever canary and stable serve
# today, each on a room of that channel. Every canary is a stable release
# in waiting, and the desktops are not on the previous canary: they are on
# whatever stable names, which can be several releases back (0.2.2 under
# 0.2.6.1). A package that upgrades the laptop and breaks the desktops has
# to fail here, not on a Sunday. See tools/release-gate.sh.
hash="$(sha256sum "$package_path" | awk '{print $1}')"
# And the stable rooms' own path, on the same file. Every canary is a # RELEASE_GATE_REPORT_PASSED=REPORT relies on a gate already run on this
# stable release in waiting, and the desktops are not on the previous # file instead — Preview's gate job, which has Docker, where the publish
# canary: they are on whatever stable names, which can be several # job has the key and no Docker. The report is only accepted if it is a
# releases back (0.2.2 under 0.2.6.1). A package that upgrades the # pass for exactly these bytes, from exactly what canary and stable serve
# laptop and breaks the desktops has to fail here, not on a Sunday. # now; anything else and nothing is published.
if [[ -f "$RELEASE_LOCAL/stable/manifest.json" ]]; then if [[ -n "${RELEASE_GATE_REPORT_PASSED:-}" ]]; then
stable_package_name="$(python3 -c \ "$ROOT/tools/release-gate.sh" --still-current "$RELEASE_GATE_REPORT_PASSED" "$package_path"
'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \ else
"$RELEASE_LOCAL/stable/manifest.json")" "$ROOT/tools/release-gate.sh" "$package_path" ${RELEASE_GATE_FROM:-}
stable_package="$RELEASE_LOCAL/packages/$stable_package_name"
if [[ -f "$stable_package" && "$stable_package" != "${validation_args[1]:-}" ]]; then
echo
echo "Rehearsing the stable rooms' upgrade from $stable_package_name"
VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$package_path" "$stable_package"
fi
fi fi
# Checked again after the gate, which takes long enough for somebody else # Checked again after the gate, which takes long enough for somebody else
# to have published in the meantime. # to have published in the meantime — or for the file to have changed.
release_check_new_version "$version" release_check_new_version "$version"
hash="$(sha256sum "$package_path" | awk '{print $1}')" if [[ "$(sha256sum "$package_path" | awk '{print $1}')" != "$hash" ]]; then
echo "$package_path changed while it was being gated. Nothing was published." >&2
exit 1
fi
mkdir -p "$RELEASE_LOCAL/packages" mkdir -p "$RELEASE_LOCAL/packages"
cp -n "$package_path" "$RELEASE_LOCAL/packages/$package_name" cp -n "$package_path" "$RELEASE_LOCAL/packages/$package_name"
if [[ "$(sha256sum "$RELEASE_LOCAL/packages/$package_name" | awk '{print $1}')" != "$hash" ]]; then
echo "$RELEASE_LOCAL/packages/$package_name is not the file that passed the gate." >&2
exit 1
fi
echo echo
echo "Publishing package..." echo "Publishing package..."
release_upload_package "$RELEASE_LOCAL/packages/$package_name" release_upload_package "$RELEASE_LOCAL/packages/$package_name"
+24 -1
View File
@@ -9,7 +9,23 @@ source "$ROOT/tools/release-lib.sh"
# Stable never gets a package of its own. It is pointed at one canary # Stable never gets a package of its own. It is pointed at one canary
# already published, so the file every classroom downloads is byte for # already published, so the file every classroom downloads is byte for
# byte the one the canary machine ran. # byte the one the canary machine ran.
if [[ $# -eq 1 ]]; then #
# ./update_stable.sh VERSION SHA256 the release that was approved, by
# the checksum it was approved as
# ./update_stable.sh VERSION the release, whatever file it is
# ./update_stable.sh whatever canary is on
#
# With SHA256, anything but that exact file is refused. The checksum is
# the one written down when the release went to canary and passed its
# physical checks, so the approval and the promotion name the same bytes.
expected_hash="${2:-}"
if [[ -n "$expected_hash" && ! "$expected_hash" =~ ^[0-9a-f]{64}$ ]]; then
echo "Not a SHA-256 checksum: $expected_hash" >&2
exit 1
fi
if [[ $# -ge 1 ]]; then
version="$1" version="$1"
else else
if [[ ! -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then if [[ ! -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
@@ -50,6 +66,13 @@ fi
hash="$(sha256sum "$package_path" | awk '{print $1}')" hash="$(sha256sum "$package_path" | awk '{print $1}')"
if [[ -n "$expected_hash" && "$hash" != "$expected_hash" ]]; then
echo "$package_name is not the file that was approved:" >&2
echo " approved: $expected_hash" >&2
echo " here: $hash" >&2
exit 1
fi
echo "Promoting $version to STABLE (from ${current:-nothing})" echo "Promoting $version to STABLE (from ${current:-nothing})"
# Refuses unless the server already holds exactly this package. # Refuses unless the server already holds exactly this package.