1
0

actions_changelog_rss.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. #!/usr/bin/env python3
  2. #
  3. # Updates an RSS file on a remote server with updates to the changelog.
  4. # See https://docs.spacestation14.io/en/hosting/changelogs for instructions.
  5. #
  6. # If you wanna test this script locally on Windows,
  7. # you can use something like this in Powershell to set up the env var:
  8. # $env:CHANGELOG_RSS_KEY=[System.IO.File]::ReadAllText($(gci "key"))
  9. import os
  10. import paramiko
  11. import pathlib
  12. import io
  13. import base64
  14. import yaml
  15. import itertools
  16. import html
  17. import email.utils
  18. from typing import List, Any, Tuple
  19. from lxml import etree as ET
  20. from datetime import datetime, timedelta, timezone
  21. MAX_ITEM_AGE = timedelta(days=30)
  22. # Set as a repository secret.
  23. CHANGELOG_RSS_KEY = os.environ.get("CHANGELOG_RSS_KEY")
  24. # Change these to suit your server settings
  25. # https://docs.fabfile.org/en/stable/getting-started.html#run-commands-via-connections-and-run
  26. SSH_HOST = "moon.spacestation14.com"
  27. SSH_USER = "changelog-rss"
  28. SSH_PORT = 22
  29. RSS_FILE = "changelog.xml"
  30. XSL_FILE = "stylesheet.xsl"
  31. HOST_KEYS = [
  32. "AAAAC3NzaC1lZDI1NTE5AAAAIOBpGO/Qc6X0YWuw7z+/WS/65+aewWI29oAyx+jJpCmh"
  33. ]
  34. # RSS feed parameters, change these
  35. FEED_TITLE = "Space Station 14 Changelog"
  36. FEED_LINK = "https://github.com/space-wizards/space-station-14/"
  37. FEED_DESCRIPTION = "Changelog for the official Wizard's Den branch of Space Station 14."
  38. FEED_LANGUAGE = "en-US"
  39. FEED_GUID_PREFIX = "ss14-changelog-wizards-"
  40. FEED_URL = "https://central.spacestation14.io/changelog.xml"
  41. CHANGELOG_FILE = "Resources/Changelog/Changelog.yml"
  42. TYPES_TO_EMOJI = {
  43. "Fix": "🐛",
  44. "Add": "🆕",
  45. "Remove": "❌",
  46. "Tweak": "⚒️"
  47. }
  48. XML_NS = "https://spacestation14.com/changelog_rss"
  49. XML_NS_B = f"{{{XML_NS}}}"
  50. XML_NS_ATOM = "http://www.w3.org/2005/Atom"
  51. XML_NS_ATOM_B = f"{{{XML_NS_ATOM}}}"
  52. ET.register_namespace("ss14", XML_NS)
  53. ET.register_namespace("atom", XML_NS_ATOM)
  54. # From https://stackoverflow.com/a/37958106/4678631
  55. class NoDatesSafeLoader(yaml.SafeLoader):
  56. @classmethod
  57. def remove_implicit_resolver(cls, tag_to_remove):
  58. if not 'yaml_implicit_resolvers' in cls.__dict__:
  59. cls.yaml_implicit_resolvers = cls.yaml_implicit_resolvers.copy()
  60. for first_letter, mappings in cls.yaml_implicit_resolvers.items():
  61. cls.yaml_implicit_resolvers[first_letter] = [(tag, regexp)
  62. for tag, regexp in mappings
  63. if tag != tag_to_remove]
  64. # Hrm yes let's make the fucking default of our serialization library to PARSE ISO-8601
  65. # but then output garbage when re-serializing.
  66. NoDatesSafeLoader.remove_implicit_resolver('tag:yaml.org,2002:timestamp')
  67. def main():
  68. if not CHANGELOG_RSS_KEY:
  69. print("::notice ::CHANGELOG_RSS_KEY not set, skipping RSS changelogs")
  70. return
  71. with open(CHANGELOG_FILE, "r") as f:
  72. changelog = yaml.load(f, Loader=NoDatesSafeLoader)
  73. with paramiko.SSHClient() as client:
  74. load_host_keys(client.get_host_keys())
  75. client.connect(SSH_HOST, SSH_PORT, SSH_USER, pkey=load_key(CHANGELOG_RSS_KEY))
  76. sftp = client.open_sftp()
  77. last_feed_items = load_last_feed_items(sftp)
  78. feed, any_new = create_feed(changelog, last_feed_items)
  79. if not any_new:
  80. print("No changes since last last run.")
  81. return
  82. et = ET.ElementTree(feed)
  83. with sftp.open(RSS_FILE, "wb") as f:
  84. et.write(
  85. f,
  86. encoding="utf-8",
  87. xml_declaration=True,
  88. # This ensures our stylesheet is loaded
  89. doctype="<?xml-stylesheet type='text/xsl' href='./stylesheet.xsl'?>",
  90. )
  91. # Copy in the stylesheet
  92. dir_name = os.path.dirname(__file__)
  93. template_path = pathlib.Path(dir_name, 'changelogs', XSL_FILE)
  94. with sftp.open(XSL_FILE, "wb") as f, open(template_path) as fh:
  95. f.write(fh.read())
  96. def create_feed(changelog: Any, previous_items: List[Any]) -> Tuple[Any, bool]:
  97. rss = ET.Element("rss", attrib={"version": "2.0"})
  98. channel = ET.SubElement(rss, "channel")
  99. time_now = datetime.now(timezone.utc)
  100. # Fill out basic channel info
  101. ET.SubElement(channel, "title").text = FEED_TITLE
  102. ET.SubElement(channel, "link").text = FEED_LINK
  103. ET.SubElement(channel, "description").text = FEED_DESCRIPTION
  104. ET.SubElement(channel, "language").text = FEED_LANGUAGE
  105. ET.SubElement(channel, "lastBuildDate").text = email.utils.format_datetime(time_now)
  106. ET.SubElement(channel, XML_NS_ATOM_B + "link", {"type": "application/rss+xml", "rel": "self", "href": FEED_URL})
  107. # Find the last item ID mentioned in the previous changelog
  108. last_changelog_id = find_last_changelog_id(previous_items)
  109. any = create_new_item_since(changelog, channel, last_changelog_id, time_now)
  110. copy_previous_items(channel, previous_items, time_now)
  111. return rss, any
  112. def create_new_item_since(changelog: Any, channel: Any, since: int, now: datetime) -> bool:
  113. entries_for_item = [entry for entry in changelog["Entries"] if entry["id"] > since]
  114. top_entry_id = max(map(lambda e: e["id"], entries_for_item), default=0)
  115. if not entries_for_item:
  116. return False
  117. attrs = {XML_NS_B + "from-id": str(since), XML_NS_B + "to-id": str(top_entry_id)}
  118. new_item = ET.SubElement(channel, "item", attrs)
  119. ET.SubElement(new_item, "pubDate").text = email.utils.format_datetime(now)
  120. ET.SubElement(new_item, "guid", {"isPermaLink": "false"}).text = f"{FEED_GUID_PREFIX}{since}-{top_entry_id}"
  121. ET.SubElement(new_item, "description").text = generate_description_for_entries(entries_for_item)
  122. # Embed original entries inside the XML so it can be displayed more nicely by specialized tools.
  123. # Like the website!
  124. for entry in entries_for_item:
  125. xml_entry = ET.SubElement(new_item, XML_NS_B + "entry")
  126. ET.SubElement(xml_entry, XML_NS_B + "id").text = str(entry["id"])
  127. ET.SubElement(xml_entry, XML_NS_B + "time").text = entry["time"]
  128. ET.SubElement(xml_entry, XML_NS_B + "author").text = entry["author"]
  129. for change in entry["changes"]:
  130. attrs = {XML_NS_B + "type": change["type"]}
  131. ET.SubElement(xml_entry, XML_NS_B + "change", attrs).text = change["message"]
  132. return True
  133. def generate_description_for_entries(entries: List[Any]) -> str:
  134. desc = io.StringIO()
  135. keyfn = lambda x: x["author"]
  136. sorted_author = sorted(entries, key=keyfn)
  137. for author, group in itertools.groupby(sorted_author, keyfn):
  138. desc.write(f"<h3>{html.escape(author)} updated:</h3>\n")
  139. desc.write("<ul>\n")
  140. for entry in sorted(group, key=lambda x: x["time"]):
  141. for change in entry["changes"]:
  142. emoji = TYPES_TO_EMOJI.get(change["type"], "")
  143. msg = change["message"]
  144. desc.write(f"<li>{emoji} {html.escape(msg)}</li>")
  145. desc.write("</ul>\n")
  146. return desc.getvalue()
  147. def copy_previous_items(channel: Any, previous: List[Any], now: datetime):
  148. # Copy in previous items, if we have them.
  149. for item in previous:
  150. date_elem = item.find("./pubDate")
  151. if date_elem is None:
  152. # Item doesn't have a valid publication date?
  153. continue
  154. date = email.utils.parsedate_to_datetime(date_elem.text or "")
  155. if date + MAX_ITEM_AGE < now:
  156. # Item too old, get rid of it.
  157. continue
  158. channel.append(item)
  159. def find_last_changelog_id(items: List[Any]) -> int:
  160. return max(map(lambda i: int(i.get(XML_NS_B + "to-id", "0")), items), default=0)
  161. def load_key(key_contents: str) -> paramiko.PKey:
  162. key_string = io.StringIO()
  163. key_string.write(key_contents)
  164. key_string.seek(0)
  165. return paramiko.Ed25519Key.from_private_key(key_string)
  166. def load_host_keys(host_keys: paramiko.HostKeys):
  167. for key in HOST_KEYS:
  168. host_keys.add(SSH_HOST, "ssh-ed25519", paramiko.Ed25519Key(data=base64.b64decode(key)))
  169. def load_last_feed_items(client: paramiko.SFTPClient) -> List[Any]:
  170. try:
  171. with client.open(RSS_FILE, "rb") as f:
  172. feed = ET.parse(f)
  173. return feed.findall("./channel/item")
  174. except FileNotFoundError:
  175. return []
  176. main()