-
-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(ci): open a downstream-bump tracking issue on every release #7561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| #!/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", []) | ||
| 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`" | ||
| ) | ||
| 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
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Unknown update_type omitted 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
|
||
| 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 as e: | ||
| # Surface validation errors as a clean CI failure with a single | ||
| # actionable line, instead of a Python traceback. | ||
| print(f"render-downstream-tracker: {e}", file=sys.stderr) | ||
| return 1 | ||
| print(body) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| #!/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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. 4-space indent in smoke test 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
|
||
|
|
||
|
|
||
| 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`") | ||
|
|
||
| print("ok") | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
| 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" | ||
|
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) | ||
| 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" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. 4-space indent in script
π Rule violationβ MaintainabilityAgent Prompt
β Copy this prompt and use it to remediate the issue with your preferred AI generation tools