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