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