Skip to content

Self-hosted APT repo on apt.learningequality.org#15054

Open
rtibblesbot wants to merge 8 commits into
learningequality:developfrom
rtibblesbot:issue-14965-65e919
Open

Self-hosted APT repo on apt.learningequality.org#15054
rtibblesbot wants to merge 8 commits into
learningequality:developfrom
rtibblesbot:issue-14965-65e919

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Move the self-hosted Debian/Ubuntu APT repo off non-redirectable github.io Pages so the installed base self-migrates with no user action.

  • One unified reprepro repo at apt.learningequality.org, backed by the release GCS bucket's apt/ path.
  • kolibri-archive-keyring package for fresh installs.
  • kolibri-server postinst snippet rewrites any existing github.io source to the new host on upgrade.
  • The kolibri-server release flow only publishes: it takes an already-built .deb (release-run artifact name, or a URL on manual dispatch) and hands it to the publish workflow.
  • The Raspberry Pi image ships one .sources file pointing at the new host — the unified repo serves both packages, so the separate kolibri-server source file goes away.

References

Implements #14965.

Ops prerequisites deferred to #13720:

  • subdomain + Cloudflare
  • CI secrets
  • running the one-shot seed for the cutover release
  • readthedocs user-manual update
  • archiving the old Pages repos

Acceptance criteria

All met and verified by the apt-repo test suite:

  • Publish read-modify-writes the reprepro repo on the bucket apt/ path — test_publish_roundtrip.sh.
  • Publish reuses release_kolibri.yml GCS auth — platform-apt-repo-publish.yml.
  • Published repo is signed — test_publish_roundtrip.sh.
  • Published repo serves pubkey.asctest_publish_roundtrip.sh.
  • A second publish preserves prior packages — test_publish_roundtrip.sh.
  • kolibri-archive-keyring .deb installs its source file and signing key — test_keyring_install.sh.
  • apt update works after install with no manual source line — test_keyring_install.sh.
  • postinst rewrites an existing github.io source on Debian 13 — test_migrate_apt_source.sh Case A, e2e_cutover.sh.
  • postinst is a no-op when no github.io source is present — test_migrate_apt_source.sh Cases B and D.
  • Seed script pushes a .deb to the old gh-pages branch(es) — test_seed_old_pages.sh.
  • Seed script is safe to run twice — test_seed_old_pages.sh.
  • Seed script documented as run-once — README.rst and the script's banner.
  • End-to-end: an old-source Debian 13 client is auto-rewritten to the new host — e2e_cutover.sh.
  • End-to-end: it then fetches later updates from the new host — e2e_cutover.sh.
  • Publish aborts rather than wiping the live repo when it can't confirm the prefix is empty — test_publish_empty_prefix_guard.sh.

Reviewer guidance

  • platforms/apt-repo/publish.sh:40 — the sync-down guard aborts instead of treating an ambiguous gcloud storage ls failure as "empty prefix" (which would let the rsync -c -d up-leg wipe the live repo); driven by a fake gcloud in test_publish_empty_prefix_guard.sh. Confirm the matched no objects string match is the right signal for a genuinely-empty prefix.
  • platforms/apt-repo/conf/distributions.in:5Architectures omits armhf, so Architecture: all debs are invisible to 32-bit Pi clients; confirm the old repos don't serve them or add armhf before cutover.
  • .github/workflows/platform-apt-repo-publish.yml — confirm the reused GPG_SIGNING_KEY private half matches the committed platforms/raspberry-pi/files/learningequality.asc trust key.
  • .github/workflows/platform-apt-repo-test.yml — new workflow that runs the apt-repo shell tests on each relevant PR. The tests SKIP when their tooling is missing so they stay usable on a bare dev box; APT_REPO_TESTS_STRICT=1 turns that SKIP into a failure in CI.
  • platforms/raspberry-pi/base.Pifile — dropping learningequality-kolibri-server.sources reverses the Migrate kolibri-server into the Python monorepo #14964 review decision to keep it, on the grounds that the single new source now covers kolibri-server too. Confirm that reading before merge.

AI usage

Used Claude Code to implement the reviewed plan. Verified with the repo's shell test suite, the containerized keyring and end-to-end cutover tests, and prek.


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks

Deviations from the issue spec

  • Repoints the Raspberry Pi image's APT source at the new host and deletes its kolibri-server source file — not in the issue, but the Pi carries the old github.io URLs and would otherwise keep provisioning fresh images onto a repo we are cutting over.
  • Adds an abort-on-ambiguous-listing guard to the publish sync-down leg (and its test) beyond the issue's acceptance criteria: without it a transient gcloud storage ls failure reads as "empty prefix" and the rsync -c -d up-leg wipes the live repo.
@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-07-24 22:18 UTC

@github-actions github-actions Bot added DEV: dev-ops Continuous integration & deployment SIZE: very large labels Jul 23, 2026
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@rtibblesbot
rtibblesbot marked this pull request as ready for review July 23, 2026 05:19

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI passing. One blocking issue on the publish read-modify-write; everything else checks out (ACs each have a test, the committed keyring key matches the Pi trust key so URL-only source rewrites still verify).

  • blockingplatforms/apt-repo/publish.sh:38: transient gcloud storage ls failure is indistinguishable from an empty prefix and can trigger the -c -d up-leg to wipe the live repo.

Comment thread platforms/apt-repo/publish.sh Outdated
# repo, so only a genuinely empty prefix may skip the down-leg.
case "$KOLIBRI_APT_BUCKET" in
gs://*)
if gcloud storage ls "$KOLIBRI_APT_BUCKET/**" >/dev/null 2>&1; then

@rtibbles rtibbles Jul 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: This is the reviewer-guidance 1 scenario, and the answer is yes — it can wipe the repo. gcloud storage ls "$KOLIBRI_APT_BUCKET/**" >/dev/null 2>&1 exits non-zero for a genuinely empty prefix and for any transient network/throttle/auth failure, and both fall through to "empty prefix — first publish, starting fresh". The local tree then holds only the new deb, and the -c -d up-leg at line 86 deletes every prior package/pool/dists/db object. The comment at 32-35 states the required invariant ("only a genuinely empty prefix may skip the down-leg") but the code doesn't enforce it — exit status alone can't distinguish the two.

Fix: only skip the down-leg on a positively-confirmed-empty listing. Inspect the ls result rather than trusting exit status (e.g. count listed objects on success; treat any error that isn't an unambiguous "no objects" as fatal and abort before the destructive up-leg), or gate the -d flag on a sentinel-object probe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1920f5c. The gs:// branch now inspects the listing instead of trusting exit status:

  • clean exit → objects exist → sync down
  • non-zero exit whose stderr unambiguously says matched no objects → genuinely empty → skip the down-leg
  • any other non-zero exit (network/throttle/auth) → print the error and exit 1 before reaching the -c -d up-leg

So a transient gcloud storage ls failure now aborts the publish rather than falling through to "starting fresh" and wiping the live repo. Added tests/test_publish_empty_prefix_guard.sh, which drives all three outcomes through a fake gcloud on PATH and asserts the transient case aborts with no rsync leg run (repo untouched) while a confirmed-empty prefix still publishes and reaches the -d up-leg.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still fixed — the empty-prefix guard now inspects the listing and aborts on any non-empty-but-failed ls rather than trusting exit status, with tests/test_publish_empty_prefix_guard.sh driving both cases through a fake gcloud. The history rewrite folded that fix into the original publish.sh commit, so it now reads as one commit rather than a follow-up.

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple more tweaks to make this most useful.

Once updated, please also tidy up the commit history.

VERSION=$(dpkg-parsechangelog -S Version)
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Debian version: ${VERSION}"
build_deb_binary:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already built in the main release flow, right? I feel like just adding an input to pass in the already built debian file to this workflow and invoking it from the release flow like that is preferable to rebuilding the deb file again.

As a general rule of thumb, the platform release actions should be about releasing things that have already been built - not about building the artifacts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — build_deb_binary is gone. platform-debian-server-release.yml now takes a deb-artifact-name input, and release_kolibri.yml's server_release job passes ${{ needs.server_deb.outputs.deb-file-name }} (it already needs: server_deb, so the artifact is there). The release flow only releases now; nothing in it builds a deb.

One knock-on: the GCS auth/bucket secrets had to be declared and passed explicitly down the chain rather than relying on secrets: inherit in the nested call — a called workflow's secrets context only holds what it was itself given, so inherit from platform-debian-server-release.yml would only have forwarded the three Launchpad/GPG secrets it receives.

@@ -0,0 +1,46 @@
name: Test the self-hosted APT repo tooling

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am unsure about proliferating test yml files like this. I know this isn't the first, but I wonder if it would be better to have a single workflow for all tests. This can stay like this for now, until we do that follow up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — leaving it as its own file for now, per your call, and it can fold into a single test workflow whenever that consolidation happens. No change made here.

@@ -0,0 +1,72 @@
name: Publish to the self-hosted APT repo
on:
workflow_call:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add workflow_dispatch here as well, firstly for manual testing of the workflow, and also to populate from an existing release. We should use our usual pattern of accepting a URL to the asset in the workflow_dispatch and then downloading from that URL.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, following the platform-pi-build_img.yml pattern: workflow_dispatch takes a required deb-url, workflow_call takes an optional deb-artifact-name or deb-url, and a validation step rejects both-or-neither. The URL path is a plain curl into dist/, so publish.sh dist/*.deb is unchanged.

platform-debian-server-release.yml gained a matching optional deb-url dispatch input and passes whichever it got through, so a manual server release can populate the repo from an existing release asset too (the apt-publish job is skipped when neither is given).

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Rebased onto develop and rewrote the history into 8 logical commits — the seven review-response fixups are folded into the commits they belong to:

  1. Add reprepro read-modify-write publish for the self-hosted APT repo
  2. Add kolibri-archive-keyring package for the new APT source
  3. Add APT-repo publish workflow and wire it into the server release
  4. Migrate existing github.io APT sources to the new host on upgrade
  5. Add one-shot seed script to push the cutover release into old repos
  6. Add end-to-end cutover test and publishing README
  7. Run the apt-repo shell tests in CI
  8. Point the Raspberry Pi APT source at apt.learningequality.org

Verified the tree is byte-identical to the pre-rewrite branch apart from the two intentional review changes (the release/publish workflow rewiring and the README paragraph describing it).

The self-hosted APT repo replaces the per-installer github.io Pages repos
with a single reprepro repository living on the release GCS bucket's apt/
path. publish.sh does the read-modify-write: sync the whole tree down,
includedeb the new package(s), re-export the signed indices and pubkey.asc,
then sync back up with -c -d so prior packages from earlier releases persist.

The empty-prefix probe only skips the down-leg on a positively confirmed
empty listing — a transient gcloud failure aborts rather than falling
through to the destructive up-leg.
Ships the deb822 source file and the repo signing key so a fresh install
needs no hand-written sources.list line. The key is a byte-identical copy of
the trust key the Raspberry Pi image already installs, asserted by the test.
The reusable publish workflow only ever releases an already-built .deb: the
release flow hands it the artifact built by release_kolibri.yml's server_deb
job, and a workflow_dispatch takes a URL to an existing release asset so the
repo can be populated or the workflow exercised by hand.

The GCS auth and bucket secrets are declared and passed explicitly down the
call chain, since a nested `secrets: inherit` only forwards what the calling
reusable workflow was itself given.
kolibri-server's postinst sources the shared snippet and rewrites any
learningequality.github.io source file to apt.learningequality.org, so the
installed base self-migrates with no user action. It is a no-op — and safe
under the postinst's set -e — when no such source is present.
Pushes the cutover .deb into the soon-to-be-archived gh-pages repos so
clients still pointed at them pick up the migrating package. Run once, for
the cutover release only; a repeat run is a logged no-op rather than a
failure.
The E2E test stands up an old and a new reprepro repo behind local HTTP
servers and drives a container through the whole cutover: install from the
old source, postinst rewrites it, then upgrade served from the new host.
The tests SKIP when their tooling is missing so they stay usable on a bare
dev box; APT_REPO_TESTS_STRICT=1 turns that SKIP into a failure in CI, so a
missing runner package can't make the suite pass vacuously.
The new host serves one unified repo at its root, so the two per-installer
github.io source files collapse into a single .sources entry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: dev-ops Continuous integration & deployment SIZE: very large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants