Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions .github/scripts/render-downstream-tracker.py
Original file line number Diff line number Diff line change
@@ -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 <catalog.yml> <version> <repo>
"""

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", [])
Comment on lines +33 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. 4-space indent in script πŸ“˜ Rule violation βš™ Maintainability

The new Python script uses 4-space indentation, but the compliance checklist requires 2-space
indentation (and no tabs) for code changes. This introduces inconsistent formatting against the
mandated whitespace standard.
Agent Prompt
## Issue description
The added Python script uses 4-space indentation, but the repository compliance checklist requires 2-space indentation (and no tabs) for code changes.

## Issue Context
This affects the newly added `.github/scripts/render-downstream-tracker.py` functions.

## Fix Focus Areas
- .github/scripts/render-downstream-tracker.py[33-109]

β“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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))

Comment on lines +47 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Unknown update_type omitted 🐞 Bug ≑ Correctness

render() only renders entries whose update_type matches one of the hard-coded GROUPS; a typo
or new update_type in docs/downstreams.yml will be silently dropped from the tracking issue.
This breaks the β€œsingle source of truth” behavior by making missing checklist items undetectable in
CI.
Agent Prompt
### Issue description
`render()` validates that each downstream item has an `update_type`, but it does not validate that the value is one of the supported categories. Because rendering only iterates over the hard-coded `GROUPS`, any typo/new value will be silently omitted from the generated checklist.

### Issue Context
This workflow is intended to make `docs/downstreams.yml` a single source of truth; silent omission defeats that by producing an incomplete tracking issue without a CI failure.

### Fix Focus Areas
- .github/scripts/render-downstream-tracker.py[22-30]
- .github/scripts/render-downstream-tracker.py[47-63]
- .github/scripts/render-downstream-tracker.py[80-87]

### Suggested change
- Build an `allowed_update_types` set from `GROUPS`.
- During the per-item validation loop, raise a `ValueError` if `item['update_type']` is not in the allowed set (include the bad value and the allowed values in the message).

β“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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/<dir>` and `/tree/<file>` 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())
125 changes: 125 additions & 0 deletions .github/scripts/test_render_downstream_tracker.py
Original file line number Diff line number Diff line change
@@ -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}")
Comment on lines +24 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. 4-space indent in smoke test πŸ“˜ Rule violation βš™ Maintainability

The newly added smoke test script uses 4-space indentation, violating the 2-space indentation
requirement. This introduces inconsistent formatting in committed source files.
Agent Prompt
## Issue description
The added Python smoke test uses 4-space indentation, but the repository requires 2-space indentation.

## Issue Context
Keeping indentation consistent avoids style drift and potential formatter/linter conflicts.

## Fix Focus Areas
- .github/scripts/test_render_downstream_tracker.py[24-82]

β“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



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())
102 changes: 102 additions & 0 deletions .github/workflows/release-downstreams.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

- 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)
Comment on lines +85 to +93
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Jq string injection breaks dedupe 🐞 Bug ☼ Reliability

The workflow builds a jq filter by interpolating $TITLE directly into the jq program, so a
version/title containing a double-quote or backslash can break jq parsing and make the workflow fail
before creating (or deduping) the tracking issue.
Agent Prompt
### Issue description
The workflow interpolates `$TITLE` into the jq program string passed to `gh issue list --jq`, which can break parsing if `$VERSION` contains characters like `"` or `\`.

### Issue Context
`TITLE` is derived from `$VERSION` (workflow_dispatch input or release tag). Even if unusual in real tags, dispatch testing can easily include these characters and will cause the step to fail.

### Fix Focus Areas
- .github/workflows/release-downstreams.yml[81-93]

### Suggested fix
Avoid embedding `$TITLE` inside the jq program. Instead pipe JSON to `jq` and pass the title via `--arg`, e.g.:

```bash
EXISTING=$(gh issue list \
  --repo "$GITHUB_REPOSITORY" \
  --state all \
  --label release \
  --label downstream \
  --search "$TITLE in:title" \
  --json number,title \
  | jq -r --arg title "$TITLE" '.[] | select(.title == $title) | .number' \
  | head -n1)
```

(Or, alternatively, properly escape `$TITLE` before embedding it into the jq string.)

β“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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"
Loading
Loading