diff --git a/.github/scripts/render-downstream-tracker.py b/.github/scripts/render-downstream-tracker.py new file mode 100755 index 00000000000..c4ee90501b1 --- /dev/null +++ b/.github/scripts/render-downstream-tracker.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Render the downstream-bump tracking issue body from docs/downstreams.yml. + +Called from .github/workflows/release-downstreams.yml. Kept as a +standalone script (rather than inline yaml-munging in the workflow) so +the format is easy to tweak without re-running CI to eyeball it โ€” run +locally with: + + python3 .github/scripts/render-downstream-tracker.py \\ + docs/downstreams.yml 2.6.1 ether/etherpad-lite + +Usage: render-downstream-tracker.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import yaml + +GROUPS: list[tuple[str, str]] = [ + ("automatic", "๐Ÿš€ Automatic (this repo handles it)"), + ("manual_ci", "๐Ÿงฉ Manual bump in this repo"), + ("external_auto", "๐Ÿค– Externally automated"), + ("external_pr", "โœ‰๏ธ Needs a PR we send"), + ("external_issue", "๐Ÿ“จ Needs an issue we file"), + ("external_maintainer", "๐Ÿค Maintained externally โ€” poke if stale"), + ("stale", "โš ๏ธ Known stale โ€” informational only"), +] + + +def render(catalog_path: Path, version: str, repo: str) -> str: + with catalog_path.open() as f: + catalog = yaml.safe_load(f) + if not isinstance(catalog, dict): + raise ValueError( + f"{catalog_path}: top-level must be a mapping, " + f"got {type(catalog).__name__}" + ) + items = catalog.get("downstreams", []) + if not isinstance(items, list): + raise ValueError( + f"{catalog_path}: `downstreams` must be a list, " + f"got {type(items).__name__}" + ) + for idx, item in enumerate(items): + if not isinstance(item, dict): + raise ValueError( + f"{catalog_path}: downstreams[{idx}] must be a mapping, " + f"got {type(item).__name__}" + ) + if "name" not in item or "update_type" not in item: + raise ValueError( + f"{catalog_path}: downstreams[{idx}] missing required " + f"`name` and/or `update_type`" + ) + # Reject typo'd update_type values up front. Without this, an entry + # with `update_type: external-pr` (dash instead of underscore) is + # silently dropped from the rendered checklist because render() only + # iterates the GROUPS allowlist below. + allowed = {g[0] for g in GROUPS} + if item["update_type"] not in allowed: + raise ValueError( + f"{catalog_path}: downstreams[{idx}] ({item['name']}) " + f"has unknown update_type {item['update_type']!r}; " + f"expected one of {sorted(allowed)}" + ) + if "path" in item and "file" in item: + raise ValueError( + f"{catalog_path}: downstreams[{idx}] ({item['name']}) " + f"sets both `path` and `file`; use `file` for files and " + f"`path` for directories, not both" + ) + + out: list[str] = [] + out.append(f"## Downstream distribution checklist for `{version}`\n") + out.append( + "Auto-opened by `.github/workflows/release-downstreams.yml` on " + "release publish.\n" + ) + out.append( + f"Source of truth: [`docs/downstreams.yml`](https://github.com/" + f"{repo}/blob/develop/docs/downstreams.yml).\n" + ) + out.append( + "Tick items as you verify them. Anything still unchecked a week " + "after release is a candidate for follow-up.\n" + ) + + for update_type, heading in GROUPS: + matches = [i for i in items if i.get("update_type") == update_type] + if not matches: + continue + out.append(f"\n### {heading}\n") + for item in matches: + out.append(_render_item(item, repo)) + + return "\n".join(out) + + +def _render_item(item: dict, repo: str) -> str: + name = item["name"] + target_repo = item.get("repo") + # `file:` deep-links to a single file (GitHub /blob/...). + # `path:` deep-links to a directory (GitHub /tree/...). + # `/blob/` and `/tree/` both 404 on GitHub, so the two + # must be distinguished. The renderer trusts the YAML key โ€” see + # render() for the both-set guard. + file_path = item.get("file") + dir_path = item.get("path") + workflow = item.get("workflow") + notes = item.get("notes", "").strip() + + # Primary link: deep-link to the file/dir if we know one, otherwise + # to the repo root. `HEAD` avoids pinning to a stale default-branch + # name (`main` vs `master` vs `develop`). + link = "" + if target_repo: + base = f"https://github.com/{target_repo}" + if file_path: + link = f" โ€” [`{target_repo}/{file_path}`]({base}/blob/HEAD/{file_path})" + elif dir_path: + link = f" โ€” [`{target_repo}/{dir_path}`]({base}/tree/HEAD/{dir_path})" + else: + link = f" โ€” [`{target_repo}`]({base})" + if workflow: + workflow_url = f"https://github.com/{repo}/blob/develop/{workflow}" + link += f" ยท [workflow]({workflow_url})" + + lines = [f"- [ ] **{name}**{link}"] + if notes: + # Indent notes under the checkbox so GitHub renders them as part + # of the list item rather than a sibling paragraph. + for note_line in notes.splitlines(): + lines.append(f" {note_line}") + lines.append("") + return "\n".join(lines) + + +def main() -> int: + if len(sys.argv) != 4: + print(__doc__, file=sys.stderr) + return 2 + catalog_path = Path(sys.argv[1]) + version = sys.argv[2] + repo = sys.argv[3] + try: + body = render(catalog_path, version, repo) + except (ValueError, OSError, yaml.YAMLError) as e: + # Surface validation, IO, and YAML parse errors as a clean CI + # failure with a single actionable line, instead of a Python + # traceback. A missing/unreadable catalog or syntactically broken + # YAML is a bad-input case, not a bug in this script. + print(f"render-downstream-tracker: {type(e).__name__}: {e}", file=sys.stderr) + return 1 + print(body) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_render_downstream_tracker.py b/.github/scripts/test_render_downstream_tracker.py new file mode 100644 index 00000000000..15a51073be4 --- /dev/null +++ b/.github/scripts/test_render_downstream_tracker.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Smoke tests for render-downstream-tracker.py. + +Run from the repo root with: python3 .github/scripts/test_render_downstream_tracker.py +Exits 0 on success, non-zero with a diff on failure. +""" + +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import textwrap +from pathlib import Path + +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location( + "render_downstream_tracker", HERE / "render-downstream-tracker.py" +) +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) + + +def write(tmpdir: Path, content: str) -> Path: + p = tmpdir / "catalog.yml" + p.write_text(textwrap.dedent(content)) + return p + + +def expect_value_error(tmpdir: Path, content: str, needle: str) -> None: + p = write(tmpdir, content) + try: + mod.render(p, "1.0", "ether/etherpad") + except ValueError as e: + assert needle in str(e), f"expected {needle!r} in {e!r}" + return + raise AssertionError(f"expected ValueError containing {needle!r}") + + +def main() -> int: + with tempfile.TemporaryDirectory() as td: + tmpdir = Path(td) + + # File targets render as /blob/HEAD/, directory targets render as + # /tree/HEAD/. The two are not interchangeable on GitHub. + body = mod.render(write(tmpdir, """ + downstreams: + - name: A file target + repo: foo/bar + update_type: external_pr + file: src/thing.sh + - name: A dir target + repo: foo/baz + update_type: external_auto + path: charts/etherpad + """), "1.0", "ether/etherpad") + assert "/blob/HEAD/src/thing.sh" in body, body + assert "/tree/HEAD/charts/etherpad" in body, body + + # Validation errors must be raised as ValueError (caught by main() + # and printed as a single CI-friendly line). + expect_value_error(tmpdir, "[]\n", "must be a mapping") + expect_value_error(tmpdir, "downstreams: not-a-list\n", "must be a list") + expect_value_error(tmpdir, """ + downstreams: + - "string item" + """, "must be a mapping") + expect_value_error(tmpdir, """ + downstreams: + - name: missing-update_type + """, "missing required") + expect_value_error(tmpdir, """ + downstreams: + - name: Both + update_type: external_pr + path: dir/ + file: file.txt + """, "both `path` and `file`") + + # Unknown / typo'd update_type must be rejected, not silently dropped. + expect_value_error(tmpdir, """ + downstreams: + - name: Typo + update_type: external-pr + """, "unknown update_type") + + # IO and YAML parse errors must surface cleanly via main(), not a + # bare traceback. Drive main() and check stderr/exit shape. + import io + import contextlib + + # Missing catalog file โ†’ exit 1 with a single stderr line. + argv_backup = sys.argv + try: + sys.argv = ["render-downstream-tracker.py", str(tmpdir / "nope.yml"), "1.0", "ether/x"] + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr), contextlib.redirect_stdout(io.StringIO()): + rc = mod.main() + assert rc == 1, rc + err = stderr.getvalue() + assert "render-downstream-tracker:" in err, err + assert "FileNotFoundError" in err or "OSError" in err, err + finally: + sys.argv = argv_backup + + # Malformed YAML โ†’ exit 1 with a single stderr line. + bad_yaml = tmpdir / "bad.yml" + bad_yaml.write_text("downstreams: [unbalanced\n") + try: + sys.argv = ["render-downstream-tracker.py", str(bad_yaml), "1.0", "ether/x"] + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr), contextlib.redirect_stdout(io.StringIO()): + rc = mod.main() + assert rc == 1, rc + err = stderr.getvalue() + assert "render-downstream-tracker:" in err, err + finally: + sys.argv = argv_backup + + print("ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/release-downstreams.yml b/.github/workflows/release-downstreams.yml new file mode 100644 index 00000000000..164a7fc5678 --- /dev/null +++ b/.github/workflows/release-downstreams.yml @@ -0,0 +1,102 @@ +name: Release โ€” downstream bump tracker + +# Opens a single tracking issue on every release that lists every +# downstream distribution of Etherpad (see docs/downstreams.yml) with +# hints on whether they update automatically or need a manual PR. +# +# Rationale: external catalogs (CasaOS, TrueCharts, BBB, Unraid, โ€ฆ) go +# stale because nobody remembers to poke them at release time. This +# workflow turns "remember to update every downstream" into a checklist +# we can close methodically. +# +# To test before a real release: run `Actions โ†’ Release โ€” downstream +# bump tracker โ†’ Run workflow`, supply a version (e.g. `2.6.1-test`). + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: Version string to use (e.g. 2.6.1). Defaults to the release tag. + required: true + +permissions: + contents: read + issues: write + +jobs: + open-tracking-issue: + runs-on: ubuntu-latest + # Opt-out gate: set the repo variable `SKIP_DOWNSTREAM_TRACKER=true` + # to disable this workflow without removing the file. Default is + # opt-out, not opt-in, because forgetting a flag at release time is + # exactly the failure mode this tracker exists to prevent. + if: vars.SKIP_DOWNSTREAM_TRACKER != 'true' + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: Resolve version + id: v + env: + TAG: ${{ github.event.release.tag_name }} + # `inputs.version` only exists on workflow_dispatch; reading it on + # release events can yield an empty string or a context-evaluation + # error depending on GitHub Actions behavior. Pull the dispatch + # payload directly via github.event.inputs so release runs stay + # clean. + INPUT: ${{ github.event.inputs.version }} + EVENT: ${{ github.event_name }} + run: | + VERSION="${TAG:-$INPUT}" + VERSION="${VERSION#v}" + if [ -z "${VERSION}" ]; then + echo "Could not determine version (event=${EVENT})." >&2 + exit 1 + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Render issue body + id: render + env: + VERSION: ${{ steps.v.outputs.version }} + run: | + python3 -m pip install --quiet pyyaml + BODY=$(mktemp) + python3 .github/scripts/render-downstream-tracker.py \ + docs/downstreams.yml \ + "$VERSION" \ + "$GITHUB_REPOSITORY" \ + > "$BODY" + echo "body-path=$BODY" >> "$GITHUB_OUTPUT" + + - name: Open tracking issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.v.outputs.version }} + BODY_PATH: ${{ steps.render.outputs.body-path }} + run: | + set -euo pipefail + TITLE="Downstream bumps for ${VERSION}" + # Dedupe: re-runs of this workflow (or repeated workflow_dispatch + # for the same version) must not pile up duplicate tracking issues. + # Search both open and closed for the same exact title. + EXISTING=$(gh issue list \ + --repo "$GITHUB_REPOSITORY" \ + --state all \ + --label release \ + --label downstream \ + --search "\"$TITLE\" in:title" \ + --json number,title \ + --jq ".[] | select(.title == \"$TITLE\") | .number" \ + | head -n1) + if [ -n "${EXISTING:-}" ]; then + echo "Tracking issue #$EXISTING already exists for ${VERSION}; skipping create." + exit 0 + fi + gh issue create \ + --repo "$GITHUB_REPOSITORY" \ + --title "$TITLE" \ + --label "release,downstream" \ + --body-file "$BODY_PATH" diff --git a/docs/downstreams.yml b/docs/downstreams.yml new file mode 100644 index 00000000000..44525965501 --- /dev/null +++ b/docs/downstreams.yml @@ -0,0 +1,172 @@ +# Downstream distribution catalog for Etherpad. +# +# This file is the single source of truth for every place Etherpad is +# packaged or listed outside this repository. It is consumed by +# .github/workflows/release-downstreams.yml, which opens a tracking issue +# on every release so maintainers can work through the list and keep +# downstream catalogs from going stale. +# +# When you add a new downstream, put it here first โ€” the release +# workflow will then remind us to update it on the next tag. +# +# update_type values: +# automatic โ€” handled by a workflow in this repo on tag push +# (Docker Hub, Snap, Debian .deb, Home Assistant +# add-on image). No manual step required; the +# checklist item is a smoke-test verification. +# manual_ci โ€” controlled by a file in this repo that a human +# needs to bump (e.g. the HA add-on config.yaml +# `version:` field). The workflow hints which +# file to edit. +# external_auto โ€” a downstream repo's own automation (Renovate, +# a similar bot, or a runtime `check_for_gh_release` +# call) detects new Docker/GitHub tags. No action +# required unless the bot falls behind. +# external_pr โ€” a downstream repo requires a manual PR to bump +# a pinned version. A maintainer opens that PR. +# external_issue โ€” a downstream repo accepts issues rather than PRs +# for version bumps (maintainer-driven). +# external_maintainer โ€” entirely out of our hands; the downstream has an +# owner we can ping but not patch. Keep the entry +# as a reminder to poke them. +# stale โ€” the downstream hasn't tracked upstream in a long +# time. Kept in the list so nobody re-discovers it +# and assumes it's current. + +downstreams: + # ---- In-repo, fully automated ----------------------------------------- + - name: Official Docker image + repo: ether/etherpad-lite + update_type: automatic + workflow: .github/workflows/docker.yml + notes: | + Smoke test: `docker pull etherpad/etherpad:` works and the + container exposes `/health` on 9001 after boot. + + - name: Snap (snapcraft.io) + repo: ether/etherpad-lite + update_type: automatic + workflow: .github/workflows/snap-publish.yml + notes: | + Tag triggers edge publish; stable promotion requires a manual + approval in the `snap-store-stable` GitHub Environment. Smoke test: + `snap info etherpad-lite` shows the new version on the `stable` + channel within 24h. + + - name: Debian .deb (GitHub Releases) + repo: ether/etherpad-lite + update_type: automatic + workflow: .github/workflows/deb-package.yml + notes: | + Tag triggers build; artefacts are attached to the GitHub Release. + Smoke test: `dpkg -i etherpad-lite__amd64.deb` on a clean + Ubuntu 24.04 VM, `curl /health` returns 200. + + - name: Home Assistant add-on (GHCR images) + repo: ether/etherpad-lite + update_type: manual_ci + file: packaging/home-assistant/etherpad/config.yaml + workflow: .github/workflows/hassio-addon.yml + notes: | + Bump the `version:` field in `packaging/home-assistant/etherpad/config.yaml` + and merge to develop. The workflow multi-arch builds and pushes to + GHCR. If the upstream Docker image changes base OS, verify the + add-on Dockerfile's `COPY --from=upstream` still works. + + # ---- External, auto-tracking ------------------------------------------ + - name: Umbrel App Store + repo: getumbrel/umbrel-apps + path: etherpad + update_type: external_auto + notes: | + Renovate tracks the Docker image tag. No action needed if the app + bumps within a week of release; otherwise file an issue. + + - name: TrueCharts + repo: trueforge-org/truecharts + path: charts/stable/etherpad + update_type: external_auto + notes: | + Renovate tracks the official `docker.io/etherpad/etherpad` image + (after PR 47234 merged). Verify the chart catalog shows the new + `appVersion:` within a week. + + - name: Proxmox VE Helper-Script + repo: community-scripts/ProxmoxVED + file: ct/etherpad.sh + update_type: external_auto + notes: | + The script uses `check_for_gh_release` against `ether/etherpad-lite` + at user runtime, so there's no per-release bump โ€” users re-running + the helper script pick up the latest GitHub release automatically. + + # ---- External, maintainer-driven -------------------------------------- + - name: Cloudron + repo: cloudron-io/etherpad.cloudronapp + update_type: external_maintainer + notes: | + Cloudron's automation typically publishes within a week. If the + store page lags by more than 2 weeks, post on + . + + - name: YunoHost + repo: YunoHost-Apps/etherpad_ynh + update_type: external_maintainer + notes: | + Actively maintained; usually bumps automatically. If no release + appears within 2 weeks, file an issue on the repo tagging the + current maintainer. + + - name: Nextcloud Ownpad + repo: otetard/ownpad + update_type: external_maintainer + notes: | + External integration plugin; generally forward-compatible with new + Etherpad versions. Only relevant if a breaking API change lands. + + # ---- External, per-release PR needed ---------------------------------- + - name: CasaOS App Store + repo: IceWhaleTech/CasaOS-AppStore + file: Apps/Etherpad/docker-compose.yml + update_type: external_pr + notes: | + Docker image tag is pinned in the compose file. File a PR bumping + `image: etherpad/etherpad:` and the `version` string in + the `x-casaos` metadata. + + - name: BigBlueButton + repo: bigbluebutton/bigbluebutton + file: bbb-etherpad.placeholder.sh + update_type: external_pr + notes: | + Pinned to a specific tag in the placeholder clone script. Major + version bumps may require additional changes in + `build/packages-template/bbb-etherpad/build.sh` (npm โ†’ pnpm, + Node engine bump). File a PR or issue against `develop`. + + - name: Unraid Community Apps + repo: selfhosters/unRAID-CA-templates + update_type: external_maintainer + notes: | + Community-maintained XML templates. If the template falls behind, + file an issue or PR on the template repo, or contact the + maintainer in the Unraid forum thread for Etherpad. + + # ---- Known stale / low-signal ----------------------------------------- + - name: Sandstorm + repo: sandstorm-io/sandstorm + update_type: stale + notes: | + Market listing hasn't moved since 2015. A full repack requires + vagga-based Sandstorm packaging. Noted here so nobody tries the + app expecting it to be current; removing the listing is a separate + conversation with the Sandstorm team. + + - name: TrueNAS SCALE (legacy) + repo: trueforge-org/truecharts + update_type: stale + notes: | + The TrueNAS SCALE app catalog (iXsystems) was historically sourced + from TrueCharts; both have since diverged. TrueCharts' own + catalog is the authoritative source now; iXsystems ships their + own apps independently.