Compare commits
17
Commits
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -24,8 +24,18 @@ cursorTheme=breeze_cursors
|
||||
library=org.kde.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]
|
||||
Image=/usr/share/backgrounds/ekklesia/classroom-calm.png
|
||||
Image=EkklesiaClassroom
|
||||
|
||||
[KSplash]
|
||||
Theme=None
|
||||
|
||||
@@ -37,6 +37,13 @@ var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
|
||||
var PANEL_HEIGHT = 76;
|
||||
|
||||
// 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();
|
||||
for (var i = 0; i < screens.length; i++) {
|
||||
var desktop = screens[i];
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"KPlugin": {
|
||||
"Authors": [
|
||||
{
|
||||
"Name": "Ekklesia"
|
||||
}
|
||||
],
|
||||
"Id": "EkklesiaClassroom",
|
||||
"License": "GPLv2+",
|
||||
"Name": "Ekklesia Classroom"
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
|
||||
<body>
|
||||
<div class="sheet">
|
||||
<h1>Classroom manual</h1>
|
||||
<p class="revision">River of Glory · Revision 26 September 2026</p>
|
||||
<p class="revision">River of Glory · Revision 29 September 2026</p>
|
||||
<nav id='contents'></nav>
|
||||
<section id="00-welcome">
|
||||
<h2>Your classroom computer</h2>
|
||||
|
||||
@@ -46,7 +46,7 @@ endobj
|
||||
stream
|
||||
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 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 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
|
||||
|
||||
+60
-5
@@ -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
|
||||
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
|
||||
|
||||
```
|
||||
@@ -155,11 +199,15 @@ version newer than its own. So now:
|
||||
A publish that fails after the local copy into `repo/packages` has spent
|
||||
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.7 # the next feature 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 # build, gate and publish
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
```
|
||||
./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
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -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
@@ -3,6 +3,36 @@
|
||||
Run against the exact artifact published to canary, on the real endpoint,
|
||||
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
|
||||
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
|
||||
- [ ] `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
|
||||
- [ ] The endpoint on the canary channel picks the update up
|
||||
- [ ] 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
|
||||
- [ ] Upgrade from 0.1.36 preserves every existing `device.conf` value
|
||||
- [ ] `LIVE_ENABLED=false` is appended exactly once on upgrade and remains
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
`classroom-ui-scale` prints it, clamped, and everything that cannot read
|
||||
|
||||
Executable
+308
@@ -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:]))
|
||||
Executable
+157
@@ -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())
|
||||
@@ -14,6 +14,7 @@ from urllib.parse import urljoin
|
||||
|
||||
CONFIG = "/etc/ekklesia-classroom/device.conf"
|
||||
LOCKFILE = "/run/ekklesia-classroom-update.lock"
|
||||
REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"
|
||||
CACHE_DIR = "/var/cache/ekklesia-classroom"
|
||||
PACKAGE = "ekklesia-classroom"
|
||||
|
||||
@@ -291,8 +292,12 @@ def main():
|
||||
if args.no_reboot:
|
||||
log("Update requires reboot, but --no-reboot was specified.")
|
||||
else:
|
||||
log("Update requires reboot. Rebooting now.")
|
||||
subprocess.run(["systemctl", "reboot"])
|
||||
# Not now. At 03:00 nobody is there; after a missed 03:00,
|
||||
# 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
|
||||
|
||||
|
||||
@@ -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]
|
||||
Restart=always
|
||||
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
|
||||
|
||||
Executable
+115
@@ -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()
|
||||
Executable
+408
@@ -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
|
||||
Executable
+182
@@ -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
|
||||
@@ -244,20 +244,51 @@ check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in vali
|
||||
|
||||
print()
|
||||
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"')
|
||||
first_upload = canary.index("release_upload_package")
|
||||
check("Trixie validation runs before the repository copy", validation < package_copy)
|
||||
check("Trixie validation runs before any upload", validation < first_upload)
|
||||
check("the release gate runs before the repository copy", validation < package_copy)
|
||||
check("the release gate runs before any upload", validation < first_upload)
|
||||
# A version is checked before anything is built for it, and again after
|
||||
# the long gate, immediately before the first upload.
|
||||
check("a spent version is refused before the build",
|
||||
canary.index("release_check_new_version") < canary.index("build-release.sh"))
|
||||
check("and again after the gate",
|
||||
validation < canary.rindex("release_check_new_version") < first_upload)
|
||||
check("the stable rooms' upgrade is rehearsed on the same file before publishing",
|
||||
"VALIDATION_CHANNEL=stable" in canary
|
||||
and canary.index("VALIDATION_CHANNEL=stable") < first_upload)
|
||||
# A package handed in is the one published: a second build is a second,
|
||||
# untested file.
|
||||
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",
|
||||
canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary"))
|
||||
# The gate checks convergence, not just that the package installs: an
|
||||
|
||||
Executable
+128
@@ -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 ]]
|
||||
Executable
+307
@@ -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 ]]
|
||||
@@ -86,6 +86,11 @@ cp "$SERVER/canary/manifest.json" "$LOCAL/canary/"
|
||||
# to one host, and it is this directory.
|
||||
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
|
||||
@@ -98,7 +103,7 @@ args=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ignore-existing) keep=1 ;;
|
||||
-e) shift ;;
|
||||
-e) echo "rsync $2" >> "${SSH_LOG:-/dev/null}"; shift ;;
|
||||
-*) ;;
|
||||
*) args+=("$1") ;;
|
||||
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" \
|
||||
refuses "$ROOT/update_stable.sh" 0.2.9
|
||||
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" \
|
||||
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 "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" \
|
||||
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
|
||||
# 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
|
||||
@@ -441,8 +470,11 @@ publish("canary", "0.2.7")
|
||||
status, out = run()
|
||||
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)
|
||||
check("and reboots, because the manifest asks it to",
|
||||
(work / "systemctl.log").read_text().strip() == "reboot")
|
||||
# Since 0.2.6.5 not there and then: the reboot is handed to a service that
|
||||
# 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")
|
||||
|
||||
device("canary", "0.2.7")
|
||||
|
||||
Executable
+145
@@ -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
@@ -37,9 +37,10 @@ import argparse
|
||||
import base64
|
||||
import html
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -704,7 +705,13 @@ def main():
|
||||
if problems:
|
||||
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:
|
||||
for name in ("classroom-manual.html", "classroom-manual.pdf"):
|
||||
|
||||
+45
-51
@@ -19,57 +19,12 @@ release_require_version "$VERSION" || exit 1
|
||||
STAGE="$ROOT/packaging/root"
|
||||
OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
|
||||
|
||||
# Syntax check everything the package ships, by shebang, so this cannot
|
||||
# drift out of date as classroom commands are added.
|
||||
echo "Validating sources"
|
||||
|
||||
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
|
||||
}
|
||||
done
|
||||
# Sources, the teacher's manual and every test, before anything is built.
|
||||
# The same list CI runs on every change; see tools/check.sh.
|
||||
"$ROOT/tools/check.sh" || {
|
||||
echo "Checks failed; nothing was built."
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Building Ekklesia Classroom ${VERSION}"
|
||||
|
||||
@@ -176,6 +131,32 @@ install -Dm644 \
|
||||
"$ROOT/config/wallpapers/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" \
|
||||
"$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.
|
||||
install -Dm644 \
|
||||
"$ROOT/config/sddm/ekklesia-classroom.conf" \
|
||||
@@ -206,6 +191,11 @@ install -Dm644 \
|
||||
"$ROOT/systemd/updater/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 \
|
||||
"$ROOT/systemd/provision/ekklesia-classroom-provision.service" \
|
||||
"$STAGE/etc/systemd/system/ekklesia-classroom-provision.service"
|
||||
@@ -457,6 +447,10 @@ install -Dm755 \
|
||||
"$ROOT/scripts/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 \
|
||||
"$ROOT/scripts/classroom-theme" \
|
||||
"$STAGE/usr/local/bin/classroom-theme"
|
||||
|
||||
Executable
+141
@@ -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"
|
||||
Executable
+304
@@ -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:]))
|
||||
@@ -182,6 +182,12 @@ check "no discover or system settings" 0 \
|
||||
"$(in_lab "grep -cE 'discover.desktop|systemsettings.desktop' $APPLETS")"
|
||||
check "the packaged wallpaper" 1 \
|
||||
"$(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"
|
||||
check "no automatic lock" false "$(session_config --file kscreenlockerrc --group Daemon --key Autolock)"
|
||||
|
||||
+39
-9
@@ -13,6 +13,8 @@
|
||||
# tools/lab/dirty-upgrade [NEW.deb] [OLD.deb]
|
||||
# CHECK_ARGS=--restart tools/lab/dirty-upgrade ask twice, either side
|
||||
# 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
|
||||
# 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.
|
||||
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")"
|
||||
# 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 "
|
||||
DEBIAN_FRONTEND=noninteractive dpkg -i '/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
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '/srv/classroom/${OLD#$ROOT/}' >/dev/null 2>&1
|
||||
true" || true
|
||||
docker exec "$NAME" dpkg-query -W -f=' installed: ${Version}\n' ekklesia-classroom
|
||||
require_installed "$OLD"
|
||||
|
||||
step "and everything a used machine accumulates"
|
||||
docker exec -i "$NAME" bash <<'DIRT'
|
||||
@@ -164,14 +183,16 @@ DIRT
|
||||
step "the new classroom: $(basename "$NEW")"
|
||||
docker exec "$NAME" sh -c '
|
||||
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 "
|
||||
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
|
||||
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
|
||||
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"
|
||||
"$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
|
||||
# again here, over a machine that is already converged.
|
||||
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
|
||||
require_installed "$NEW"
|
||||
before=$(docker exec "$NAME" sh -c 'cat /etc/ekklesia-classroom/device.conf | sha256sum')
|
||||
"$LAB" plasma-down >/dev/null
|
||||
"$LAB" plasma-up >/dev/null
|
||||
@@ -230,6 +252,14 @@ fi
|
||||
theme dark >/dev/null
|
||||
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"
|
||||
"$ROOT/tools/lab/recovery-check" || converged=1
|
||||
|
||||
|
||||
Executable
+162
@@ -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 ]]
|
||||
Executable
+238
@@ -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 ]]
|
||||
Executable
+401
@@ -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
|
||||
Executable
+219
@@ -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
@@ -66,8 +66,26 @@ release_package_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() {
|
||||
ssh "$RELEASE_HOST" "$@"
|
||||
release_ssh "$RELEASE_HOST" "$@"
|
||||
}
|
||||
|
||||
# Every version that has ever been published, one per line, read from the
|
||||
@@ -151,7 +169,7 @@ release_upload_package() {
|
||||
name="$(basename "$package")"
|
||||
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/"
|
||||
|
||||
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
|
||||
@@ -192,7 +210,7 @@ release_publish_manifest() {
|
||||
EOF
|
||||
|
||||
# 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_HOST:$RELEASE_ROOT/$channel/manifest.json"
|
||||
|
||||
|
||||
@@ -117,6 +117,13 @@ CUSTOM_SETTING=preserve-me-too
|
||||
CONFIG
|
||||
sed -i "s/@CHANNEL@/${channel}/" /etc/ekklesia-classroom/device.conf
|
||||
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
|
||||
|
||||
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.
|
||||
candidate_version="$(dpkg-deb --field /packages/candidate.deb 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" ]]
|
||||
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 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
|
||||
# actually talks to. Ordering it only after pipewire and wireplumber is
|
||||
# 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;' \
|
||||
/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
|
||||
# channel comes home, because that channel is the obsolete one now.
|
||||
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 \
|
||||
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.
|
||||
for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do
|
||||
|
||||
+54
-35
@@ -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
|
||||
# 0.2.7 if it is not — and which one a release is, is a decision about
|
||||
# what is in it, not arithmetic.
|
||||
if [[ $# -ne 1 ]]; then
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
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 "Canary is on ${current:-an unknown version}. Name the release, for example:" >&2
|
||||
if [[ -n "$current" ]] && release_version_valid "$current"; then
|
||||
@@ -28,62 +31,78 @@ fi
|
||||
version="$1"
|
||||
package_name="$(release_package_name "$version")"
|
||||
package_path="$ROOT/dist/$package_name"
|
||||
prebuilt="${2:-}"
|
||||
|
||||
echo "Current canary: $(release_channel_version canary || echo unknown)"
|
||||
echo "New canary: $version"
|
||||
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
|
||||
# would not be newer than everything that has been, stops here.
|
||||
release_check_new_version "$version"
|
||||
|
||||
"$ROOT/tools/build-release.sh" "$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")"
|
||||
|
||||
# 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")
|
||||
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"
|
||||
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
|
||||
# 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.
|
||||
if [[ -f "$RELEASE_LOCAL/stable/manifest.json" ]]; then
|
||||
stable_package_name="$(python3 -c \
|
||||
'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \
|
||||
"$RELEASE_LOCAL/stable/manifest.json")"
|
||||
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
|
||||
# RELEASE_GATE_REPORT_PASSED=REPORT relies on a gate already run on this
|
||||
# file instead — Preview's gate job, which has Docker, where the publish
|
||||
# job has the key and no Docker. The report is only accepted if it is a
|
||||
# pass for exactly these bytes, from exactly what canary and stable serve
|
||||
# now; anything else and nothing is published.
|
||||
if [[ -n "${RELEASE_GATE_REPORT_PASSED:-}" ]]; then
|
||||
"$ROOT/tools/release-gate.sh" --still-current "$RELEASE_GATE_REPORT_PASSED" "$package_path"
|
||||
else
|
||||
"$ROOT/tools/release-gate.sh" "$package_path" ${RELEASE_GATE_FROM:-}
|
||||
fi
|
||||
|
||||
# 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"
|
||||
|
||||
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"
|
||||
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 "Publishing package..."
|
||||
release_upload_package "$RELEASE_LOCAL/packages/$package_name"
|
||||
|
||||
+24
-1
@@ -9,7 +9,23 @@ source "$ROOT/tools/release-lib.sh"
|
||||
# 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
|
||||
# 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"
|
||||
else
|
||||
if [[ ! -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
|
||||
@@ -50,6 +66,13 @@ fi
|
||||
|
||||
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})"
|
||||
|
||||
# Refuses unless the server already holds exactly this package.
|
||||
|
||||
Reference in New Issue
Block a user