16 Commits
v1 ... api

Author SHA1 Message Date
08d27d3183 Ofelia & Docker update 2023-12-30 01:19:42 +01:00
9d5e50c635 Update README 2023-12-30 01:19:24 +01:00
eea62d8685 Use a configurable TOTP protection 2023-12-30 01:19:24 +01:00
9a8a7042b3 API to manage subscription
Adapt docker part
Improve log
Add a just target
2023-12-30 01:19:24 +01:00
fb4ac8ea73 Justfile: improve release process & doc 2023-12-30 01:19:13 +01:00
f4edb03979 Improve HTML template + improve links
Reduce size (from ~15kB to ~4kB)
Reduce incompatibility for emails

Link to project on project name
2023-12-30 01:07:11 +01:00
68fd6e78d9 🔧 Use mailpit instead mailhog
Adjust ofelia crontime to avoid reach Github API limits
2023-12-30 00:39:31 +01:00
e57d9cf656 Improve justfile init phase 2023-11-25 23:53:52 +01:00
8dba474e23 Validate configuration + readable error
Change docker-compose version - use Just to handle version
2023-11-25 01:57:44 +01:00
07dccee235 Just
(cherry picked from commit 5d67747b3a31177010e32ce9cb6a668cd1b5737c)
2023-11-25 01:57:44 +01:00
fd7e6b73d1 Overload config with environment variables
Improves mail configuration management
2023-11-10 00:52:19 +01:00
0ee46a5d9e Fix Pylint problems 2023-11-10 00:51:32 +01:00
9709947c28 Apply pre-commit + keep black commit sha 2023-11-10 00:15:01 +01:00
89e88c8a1b Black formatting 2023-11-10 00:14:00 +01:00
148c647ac7 Install pre-commit 2023-11-10 00:13:24 +01:00
02fad7767c Cron with Ofelia
https://github.com/mcuadros/ofelia
2023-11-09 20:09:40 +01:00
11 changed files with 367 additions and 159 deletions

2
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,2 @@
# Black format commit
89e88c8a1b26e2c6ca242d0bba6cdba8da35c3ae

28
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,28 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
- id: check-merge-conflict
- repo: https://github.com/psf/black
rev: 23.11.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort
- repo: https://github.com/PyCQA/autoflake
rev: v2.1.1
hooks:
- id: autoflake
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.11.0
hooks:
- id: pretty-format-yaml
args: [--autofix, --indent, '2', --offset, '2']

View File

@@ -1,10 +1,12 @@
FROM python:3.10-alpine3.18
RUN pip install requests
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade --requirement requirements.txt
COPY notifier.py template.html /app/
# TODO Dev purporse
COPY conf.ini /app/conf.ini
ENTRYPOINT ["python3", "/app/notifier.py"]
EXPOSE 80
CMD ["uvicorn", "notifier:app", "--host", "0.0.0.0", "--port", "80"]

View File

@@ -1,17 +1,71 @@
# https://github.com/casey/just
up: build
docker compose up -d
docker compose logs
venv := "./venv"
pip := venv / "bin/pip"
python := venv / "bin/python"
build:
docker compose build
last_commit_sha1 := `git rev-parse --short HEAD`
remote_image_name := "gitea.gdemontauzan.fr/maxenceg2m/github-release-notifier"
remote_build_image := remote_image_name + ":" + last_commit_sha1
rebuild: down
docker compose build
# Run the script
run: _ensure_venv_is_ok
{{ python }} notifier.py
down:
docker compose down
# Launch the API
api: _ensure_venv_is_ok
{{ venv }}/bin/uvicorn notifier:app --reload
force-build:
docker compose build --no-cache
# Init python virtual env
init:
python3 -m venv venv
{{ pip }} install --requirement requirements.txt
sha256sum requirements.txt > {{ venv }}/requirements.sha
# Inspiration: https://github.com/behave/behave/blob/afb6b6716cd0f3e028829416475312db804a6aa9/justfile
_ensure_venv_is_ok:
#!/usr/bin/env python3
from subprocess import run
from os import path
if run("sha256sum -c {{ venv }}/requirements.sha", shell=True).returncode != 0:
run("just init", shell=True)
# Clean workspace - remove venv - and init
reinit: hclean init
# Remove virtual env (venv)
hclean:
rm -fr venv
# Run docker compose then show logs
dup: dbuild
docker compose up -d
docker compose logs
# Build with docker compose
dbuild:
docker compose build
# Down docker compose then build
drebuild: ddown dbuild
# Down docker compose
ddown:
docker compose down
# Docker build without cache
dforce-build:
docker compose build --no-cache
# Push a working images on registry, tagged with commit-sha1
dpush: dbuild
docker tag github-release-notifier {{ remote_build_image }}
docker push {{ remote_build_image }}
echo "To push a tagged version, do 'just release <version>'"
# Use just a number! Without 'v'! Release a version - create a tagged images, push it and create git tag.
release version: dbuild
docker tag github-release-notifier {{ remote_image_name }}:{{ version }}
docker push {{ remote_image_name }}:{{ version }}
git tag -a v{{ version }} -m ""
git push --tags

View File

@@ -12,33 +12,71 @@ Why using configuration?...
------------------------
...instead use stared project of your Github account?
Cause I've got about 80 "stared" projects and I don't wan't to be alerted for new releases of each of these project.
But, perhaps I'll add such a feature later on...
And, Github API limits is at 60 requests by seconds, and I want to write this script really quickly in a first time.
But, perhaps I'll add such a feature later on...
How to use?
-----------
Really simple!
* Edit conf.ini file to set `[config]` section
* Install dependances: `pip install -r requirements.txt`
* Edit conf.ini file to set `[config]` section:
* your SMTP server configuration (host and port)
* sender mail
* receiver mail (:warning: not tested with more than 1 receiver)
* Add the projects you want to follow in the section `[project]`
* ...or use environment variable - same name but in upper case e.g. `SMTP_PORT`
* Add projects you want to follow in the section `[project]`
* Be careful to follow a JSON valid syntax as in the provided file, i.e. coma after each `autor/project` except the last one.
Execute the script: `python notifier.py`
After first execution, the `conf.ini` file will be filled with last release tag found by the script, as you can see in the provided file.
Hope you like, and have fun to read your mail!
Hey, what is this API?
--------------------------
Since it's not really conveniant to go on a VM and edit the config.ini every time I find a cool new project whose new versions I want to keep track of, there is an API to do this.
This is a really simple thing with no verification: you can add everything that you want!
Just one endpoint: `subscription`, and two method: GET and PUT.
PUT take three parameters:
* `project`: full project name e.g. `MaxenceG2M/github-release-notifier` or "short" name (without author) e.g. `github-release-notifier`
* (optionnal) `author`: the author of the repository
* (optionnal) `credentials`: just the TOTP check code.
Indeed, to have a minimal better-than-nothing protection, I opted for a simple TOTP code. No user/password or wathever.
You can disable-it in the `config.ini`, or specify a custom key.
If you don't specify a key, information will be displayed at startup (URL and the actual code. Be quick to check if it's ok!).
To start the API:
```sh
$ uvicorn notifier:app
TOTP enabled.
Information: otpauth://totp/Secret?secret=mysuperkey
TOTP check: 377826
INFO: Started server process [28264]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
```
Cron-it with [ofelia](https://github.com/mcuadros/ofelia)
---------------------------------------------------------
Ofelia is very conveniant for croning the verification part while letting the API run continuously.
So the `docker-compose.yml` use it to do this :)
<3
Problems I have to solve really quickly
---------------------------------------
I wrote this script really quickly, certainly faster than this README. So I already have two big proglems:
* The script sends mail even if no new projets or release has been detected
* The biggest, you have to edit script to specify absolute path...
* A lot of other little problems, like the code that's disgusting and so on.
One script to do all things.
For who's asking: yes, it's normal that I have put all code in one main function [like a blind gunner.](https://media.giphy.com/media/1yMexL5rkwYhuiVEmZ/giphy.gif). Really quickly I said!
For who's asking: yes, it's normal that I have put all code in ~~one main function~~ one main script [like a blind gunner.](https://media.giphy.com/media/1yMexL5rkwYhuiVEmZ/giphy.gif).
I want to keep this script as simple as possible.
But overall, the script works and sends mail!
@@ -47,5 +85,3 @@ Hey boy, what is the `pit.db` file?
Oh, just for fun, and because I love this project, I use [pit by michaeldv](https://github.com/michaeldv/pit) to follow my task etc.
It makes me think I should push my python version of this project on occasion when I will take the time to do...

View File

@@ -1,10 +1,14 @@
[config]
github_api_url = https://api.github.com/repos/
smtp_port = 1025
smtp_server = mailhog
smtp_server = mailpit
sender_email = sender@host.eu
receiver_email = receiver@anotherhost.eu
[api.totp]
enabled = true
key = mysuperkey
[projects]
projects = [
"borgbackup/borg",

View File

@@ -3,13 +3,24 @@ version: '3'
services:
notifier:
build: .
image: github-release-notifier:1
image: github-release-notifier
container_name: github-release-notifier
volumes:
- ./conf.ini:/app/conf.ini
mailhog:
image: mailhog/mailhog:v1.0.1
- ./conf.ini:/app/conf.ini
ports:
- "8025:8025"
- "1025:1025"
- 8000:80
mailpit:
image: axllent/mailpit
ports:
- 8025:8025
- 1025:1025
ofelia:
image: mcuadros/ofelia:latest
depends_on:
- notifier
command: daemon --config=/opt/config.ini
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./ofelia.ini:/opt/config.ini

View File

@@ -1,113 +1,215 @@
import os
from configparser import ConfigParser
import json
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import configparser
import datetime
import json
import os
import smtplib
import sys
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import pyotp
import requests
from fastapi import FastAPI, status
from fastapi.responses import JSONResponse
SMTP_PORT = 0
SMTP_SERVER = 'null'
SENDER_EMAIL = 'a@b.c'
RECEIVER_EMAIL = 'd@e.f'
#
# CONF PART
#
SCRIPT_DIR = os.path.dirname(__file__)
CONF_FILE = os.path.join(SCRIPT_DIR, "conf.ini")
TEMPLATE_FILE = os.path.join(SCRIPT_DIR, "template.html")
def main():
global SMTP_PORT, SMTP_SERVER, SENDER_EMAIL, RECEIVER_EMAIL
script_dir = os.path.dirname(__file__)
conf_file = os.path.join(script_dir, 'conf.ini')
template_file = os.path.join(script_dir, 'template.html')
class EnvInjection(configparser.Interpolation):
"""
Derived interpolation to take env variable before file variable.
Permit to keep the ini file for local / traditionnal use
And use env variable to overload configuration in a Docker usage.
"""
parser = ConfigParser()
def before_get(self, parser, section, option, value, defaults):
file_value = super().before_get(parser, section, option, value, defaults)
if section != parser.default_section:
return file_value
env_value = os.getenv(option.upper())
return env_value if env_value else file_value
def load_conf(conf_file=CONF_FILE) -> configparser.ConfigParser:
parser = configparser.ConfigParser(
default_section="config", interpolation=EnvInjection()
)
parser.read(conf_file)
return parser
SMTP_PORT = parser.get('config', 'smtp_port')
SMTP_SERVER = parser.get('config', 'smtp_server')
SENDER_EMAIL = parser.get('config', 'sender_email')
RECEIVER_EMAIL = parser.get('config', 'receiver_email')
projects = json.loads(parser.get('projects', 'projects'))
def load_totp():
parser = load_conf()
if parser.getboolean("api.totp", "enabled", fallback=True):
totp = pyotp.TOTP(parser.get("api.totp", "key", fallback=pyotp.random_base32()))
# TODO Catch totp.now() error
print(
f"""TOTP enabled.
Information: {totp.provisioning_uri()}
TOTP check: {totp.now()}"""
)
return totp
else:
print("WARNING: Api is open without security")
return type("totp", (object,), {"verify": lambda str: True})
#
# API PARTS
#
app = FastAPI()
TOTP = load_totp()
@app.get("/subscriptions")
def get_projects():
parser = load_conf()
projects = json.loads(parser["projects"].get("projects"))
return projects
@app.put("/subscriptions")
def put_projects(
project: str, author: str | None = None, credentials: str | None = None
):
if not TOTP.verify(credentials):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED, content="Unauthorized"
)
# TODO Check if project really exist?
parser = load_conf()
projects = json.loads(parser.get("projects", "projects"))
if author:
project = f"{author}/{project}"
if project in projects:
return project
projects.append(project)
# TODO Watch a converter for list: https://stackoverflow.com/a/53274707/1346391
parser.set("projects", "projects", json.dumps(projects, indent=0))
with open("conf.ini", "w", encoding="utf-8") as configfile:
parser.write(configfile)
return JSONResponse(status_code=status.HTTP_201_CREATED, content=project)
#
# SCRIPT PART
#
def main():
parser = load_conf()
default_config = parser["config"]
try:
projects = json.loads(parser.get("projects", "projects"))
except json.decoder.JSONDecodeError as jse:
print("ERROR: config file is not correctly JSON formatted!", end="\n\t")
print(jse)
sys.exit(1)
new_releases = []
new_projects = []
if not parser.has_section('release'):
parser.add_section('release')
if not parser.has_section("release"):
parser.add_section("release")
for project in projects:
last_release = get_last_release(project)
if not parser.has_option('release', project):
if not parser.has_option("release", project):
new_projects.append(last_release)
else:
last_config_tag = parser.get('release', project)
if last_config_tag != last_release['release_tag']:
last_release['preview_tag'] = last_config_tag
last_config_tag = parser.get("release", project)
if last_config_tag != last_release["release_tag"]:
last_release["previous_tag"] = last_config_tag
new_releases.append(last_release)
parser.set('release', project, last_release['release_tag'])
parser.set("release", project, last_release["release_tag"])
if not new_releases and not new_projects:
print('No new projets or new release detected. Bye!')
print("No new projets or new release detected. Bye!")
return
content = ""
news = []
for new_r in new_releases:
content += """
<li><a href="{}" target="_blank">{}</a>: new release <a href="{}" target="_blank">{}</a> available (old: {}).
(published {})</li>
""".format(
new_r['release_url'],
new_r['project_name'],
new_r['release_url'],
new_r['release_tag'],
new_r['preview_tag'],
convert_date(new_r['published_date']))
news.append(new_r["project_name"])
content += f"""
<li>{get_html_project_link(new_r)}
: new release
{get_html_release_link(new_r)}
available (old: {new_r["previous_tag"]}).
(published {convert_date(new_r["published_date"])})</li>"""
for new_p in new_projects:
content += """
<li><a href="{}" target="_blank">{}</a> was added to your configuration. Last release: <a href="{}" target="_blank">{}</a>
(published {})</li>""".format(
new_p['release_url'],
new_p['project_name'],
new_p['release_url'],
new_p['release_tag'],
convert_date(new_p['published_date']))
news.append(new_p["project_name"])
content += f"""
<li>{get_html_project_link(new_p)}
was added to your configuration.
Last release:
{get_html_release_link(new_p)}
(published {convert_date(new_p["published_date"])})</li>"""
# print(content)
with open(template_file, 'r') as f_template:
with open(TEMPLATE_FILE, "r", encoding="utf-8") as f_template:
template = f_template.read()
send_mail(template.replace('{{content}}', content))
send_mail(template.replace("{{content}}", content), default_config)
print(f"Send a mail for new releases and projects on: {', '.join(news)}")
with open('conf.ini', 'w') as configfile:
with open("conf.ini", "w", encoding="utf-8") as configfile:
parser.write(configfile)
def get_last_release(project):
url = 'https://api.github.com/repos/{}/releases/latest'.format(project)
result = requests.get(url)
url = f"https://api.github.com/repos/{project}/releases/latest"
result = requests.get(url, timeout=10)
print(project)
print(url)
print(f"Check {project} - {url}")
release = result.json()
release_tag = release['tag_name']
published_date = release['published_at']
release_tag = release["tag_name"]
published_date = release["published_at"]
# body = release['body']
release_url = release['html_url']
release_url = release["html_url"]
return {'release_tag': release_tag,
'published_date': published_date,
# 'body': body,
'project_name': project,
'release_url': release_url}
return {
"release_tag": release_tag,
"published_date": published_date,
# 'body': body,
"project_name": project,
"release_url": release_url,
}
def get_html_project_link(el):
project_url = f'https://github.com/{el["project_name"]}'
return f'<a href="{project_url}" target="_blank">{el["project_name"]}</a>'
def get_html_release_link(el):
return f'<a href="{el["release_url"]}" target="_blank">{el["release_tag"]}</a>'
def send_mail(content, config):
smtp_port = config.get("smtp_port")
smtp_server = config.get("smtp_server")
sender_email = config.get("sender_email")
receiver_email = config.get("receiver_email")
def send_mail(content):
message = MIMEMultipart("alternative")
message["Subject"] = "New Github releases"
message["From"] = SENDER_EMAIL
message["To"] = RECEIVER_EMAIL
message["From"] = sender_email
message["To"] = receiver_email
# part1 = MIMEText(text, "plain")
part2 = MIMEText(content, "html")
@@ -115,11 +217,13 @@ def send_mail(content):
# message.attach(part1)
message.attach(part2)
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
server.sendmail(SENDER_EMAIL, RECEIVER_EMAIL, message.as_string())
with smtplib.SMTP(smtp_server, smtp_port) as server:
server.sendmail(sender_email, receiver_email, message.as_string())
def convert_date(date: str, dest_format="%d %b %Y at %H:%M") -> str:
return datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ").strftime(dest_format)
def convert_date(date: str, format='%d %b %Y at %H:%M') -> str:
return datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ").strftime(format)
if __name__ == "__main__":
main()

10
ofelia.ini Normal file
View File

@@ -0,0 +1,10 @@
[global]
smtp-host = mailpit
smtp-port = 1025
email-to = max@ence.fr
email-from = ofelia@container.sh
[job-exec "notifier"]
schedule = @every 30s
container = github-release-notifier
command = python3 /app/notifier.py

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
black
fastapi
pre-commit
pyotp
requests
uvicorn[standard]

View File

@@ -5,62 +5,13 @@
<title></title>
<style>
button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{font-family:sans-serif;line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}a
udio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{color:inherit;display:table;max-width:100%;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=se
arch]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none}
body {
margin: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; }
.wysiwyg-content main > :first-child {
padding-top: 0 !important;
margin-top: 0 !important; }
.wysiwyg-content main > :last-child {
padding-bottom: 0 !important;
margin-bottom: 0 !important; }
.wysiwyg-content img {
max-width: 100%; }
.wysiwyg-content iframe {
display: block;
max-width: 100%;
border: 0; }
.wysiwyg-content .emoji .emoji-text {
font-size: 0; }
/* https://github.com/sindresorhus/github-markdown-css/blob/gh-pages/github-markdown.css */
/* From GitHub markdown view style */
body {
max-width: 980px; }
main {
padding: 45px; }
/* https://github.com/sindresorhus/github-markdown-css/blob/gh-pages/github-markdown.css */
.markdown-body hr::after,.markdown-body:after{clear:both}.markdown-body hr::after,.markdown-body hr::before,.markdown-body:after,.markdown-body:before{display:table;content:""}@font-face{font-family:octicons-link;src:url(data:font/woff;charset=utf-8;base64,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) format('woff')}.markdown-body{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;color:#333;font-family:"Helvetica Neue",Helvetica,"Segoe UI",Arial,freesans,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-size
:16px;line-height:1.6;word-wrap:break-word}.markdown-body a{background-color:transparent;-webkit-text-decoration-skip:objects;color:#4078c0;text-decoration:none}.markdown-body a:active,.markdown-body a:hover{outline-width:0;text-decoration:underline}.markdown-body strong{font-weight:bolder}.markdown-body h1{margin:.67em 0}.markdown-body img{border-style:none}.markdown-body svg:not(:root){overflow:hidden}.markdown-body hr{box-sizing:content-box}.markdown-body input{margin:0;overflow:visible;font:13px/1.4 Helvetica,arial,nimbussansl,liberationsans,freesans,clean,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"}.markdown-body [type=button]:-moz-focusring,.markdown-body [type=reset]:-moz-focusring,.markdown-body [type=submit]:-moz-focusring,.markdown-body button:-moz-focusring{outline:ButtonText dotted 1px}.markdown-body [type=checkbox]{box-sizing:border-box;padding:0}.mar
kdown-body td,.markdown-body th{padding:0}.markdown-body h1,.markdown-body h2{padding-bottom:.3em;border-bottom:1px solid #eee}.markdown-body *{box-sizing:border-box}.markdown-body blockquote{margin:0}.markdown-body ol ol,.markdown-body ul ol{list-style-type:lower-roman}.markdown-body ol ol ol,.markdown-body ol ul ol,.markdown-body ul ol ol,.markdown-body ul ul ol{list-style-type:lower-alpha}.markdown-body dd{margin-left:0}.markdown-body code{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace}.markdown-body pre{font:12px Consolas,"Liberation Mono",Menlo,Courier,monospace;word-wrap:normal}.markdown-body .pl-0{padding-left:0!important}.markdown-body .pl-1{padding-left:3px!important}.markdown-body .pl-2{padding-left:6px!important}.markdown-body .pl-3{padding-left:12px!important}.markdown-body .pl-4{padding-left:24px!important}.markdown-body .pl-5{padding-left:36px!important}.m
arkdown-body .pl-6{padding-left:48px!important}.markdown-body .form-select::-ms-expand{opacity:0}.markdown-body>:first-child{margin-top:0!important}.markdown-body>:last-child{margin-bottom:0!important}.markdown-body a:not([href]){color:inherit;text-decoration:none}.markdown-body .anchor{display:inline-block;padding-right:2px;margin-left:-18px}.markdown-body .anchor:focus{outline:0}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4,.markdown-body h5,.markdown-body h6{margin-top:1em;margin-bottom:16px;font-weight:700;line-height:1.4}.markdown-body h1 .octicon-link,.markdown-body h2 .octicon-link,.markdown-body h3 .octicon-link,.markdown-body h4 .octicon-link,.markdown-body h5 .octicon-link,.markdown-body h6 .octicon-link{color:#000;vertical-align:middle;visibility:hidden}.markdown-body h1:hover .anchor,.markdown-body h2:hover .anchor,.markdown-body h3:hover .anchor,.
markdown-body h4:hover .anchor,.markdown-body h5:hover .anchor,.markdown-body h6:hover .anchor{text-decoration:none}.markdown-body h1:hover .anchor .octicon-link,.markdown-body h2:hover .anchor .octicon-link,.markdown-body h3:hover .anchor .octicon-link,.markdown-body h4:hover .anchor .octicon-link,.markdown-body h5:hover .anchor .octicon-link,.markdown-body h6:hover .anchor .octicon-link{visibility:visible}.markdown-body h1{font-size:2.25em;line-height:1.2}.markdown-body h1 .anchor{line-height:1}.markdown-body h2{font-size:1.75em;line-height:1.225}.markdown-body h2 .anchor{line-height:1}.markdown-body h3{font-size:1.5em;line-height:1.43}.markdown-body h3 .anchor,.markdown-body h4 .anchor{line-height:1.2}.markdown-body h4{font-size:1.25em}.markdown-body h5 .anchor,.markdown-body h6 .anchor{line-height:1.1}.markdown-body h5{font-size:1em}.markdown-body h6{font-size:1em;color:#777}.markd
own-body blockquote,.markdown-body dl,.markdown-body ol,.markdown-body p,.markdown-body pre,.markdown-body table,.markdown-body ul{margin-top:0;margin-bottom:16px}.markdown-body hr{overflow:hidden;background:#e7e7e7;height:4px;padding:0;margin:16px 0;border:0}.markdown-body ol,.markdown-body ul{padding-left:2em}.markdown-body ol ol,.markdown-body ol ul,.markdown-body ul ol,.markdown-body ul ul{margin-top:0;margin-bottom:0}.markdown-body li>p{margin-top:16px}.markdown-body dl{padding:0}.markdown-body dl dt{padding:0;margin-top:16px;font-size:1em;font-style:italic;font-weight:700}.markdown-body dl dd{padding:0 16px;margin-bottom:16px}.markdown-body blockquote{padding:0 15px;color:#777;border-left:4px solid #ddd}.markdown-body blockquote>:first-child{margin-top:0}.markdown-body blockquote>:last-child{margin-bottom:0}.markdown-body table{border-spacing:0;border-collapse:collapse;display:bl
ock;width:100%;overflow:auto;word-break:normal;word-break:keep-all}.markdown-body table th{font-weight:700}.markdown-body table td,.markdown-body table th{padding:6px 13px;border:1px solid #ddd}.markdown-body table tr{background-color:#fff;border-top:1px solid #ccc}.markdown-body table tr:nth-child(2n){background-color:#f8f8f8}.markdown-body img{max-width:100%;box-sizing:content-box;background-color:#fff}.markdown-body code{padding:.2em 0;margin:0;font-size:85%;background-color:rgba(0,0,0,.04);border-radius:3px}.markdown-body code:after,.markdown-body code:before{letter-spacing:-.2em;content:"\00a0"}.markdown-body pre>code{padding:0;margin:0;font-size:100%;word-break:normal;white-space:pre;background:0 0;border:0}.markdown-body .highlight{margin-bottom:16px}.markdown-body .highlight pre,.markdown-body pre{padding:16px;overflow:auto;font-size:85%;line-height:1.45;background-color:#f7f7f
7;border-radius:3px}.markdown-body .highlight pre{margin-bottom:0;word-break:normal}.markdown-body pre code{display:inline;max-width:initial;padding:0;margin:0;overflow:initial;line-height:inherit;word-wrap:normal;background-color:transparent;border:0}.markdown-body pre code:after,.markdown-body pre code:before{content:normal}.markdown-body .pl-c{color:#969896}.markdown-body .pl-c1,.markdown-body .pl-s .pl-v{color:#0086b3}.markdown-body .pl-e,.markdown-body .pl-en{color:#795da3}.markdown-body .pl-s .pl-s1,.markdown-body .pl-smi{color:#333}.markdown-body .pl-ent{color:#63a35c}.markdown-body .pl-k{color:#a71d5d}.markdown-body .pl-pds,.markdown-body .pl-s,.markdown-body .pl-s .pl-pse .pl-s1,.markdown-body .pl-sr,.markdown-body .pl-sr .pl-cce,.markdown-body .pl-sr .pl-sra,.markdown-body .pl-sr .pl-sre{color:#183691}.markdown-body .pl-v{color:#ed6a43}.markdown-body.pl-id{color:#b52a1d}.mark
down-body .pl-ii{background-color:#b52a1d;color:#f8f8f8}.markdown-body .pl-sr .pl-cce{color:#63a35c;font-weight:700}.markdown-body .pl-ml{color:#693a17}.markdown-body .pl-mh,.markdown-body .pl-mh .pl-en,.markdown-body .pl-ms{color:#1d3e81;font-weight:700}.markdown-body .pl-mq{color:teal}.markdown-body .pl-mi{color:#333;font-style:italic}.markdown-body .pl-mb{color:#333;font-weight:700}.markdown-body .pl-md{background-color:#ffecec;color:#bd2c00}.markdown-body .pl-mi1{background-color:#eaffea;color:#55a532}.markdown-body .pl-mdr{color:#795da3;font-weight:700}.markdown-body .pl-mo{color:#1d3e81}.markdown-body kbd{display:inline-block;padding:3px 5px;font:11px Consolas,"Liberation Mono",Menlo,Courier,monospace;line-height:10px;color:#555;vertical-align:middle;background-color:#fcfcfc;border:1px solid #ccc;border-bottom-color:#bbb;border-radius:3px;box-shadow:inset 0 -1px 0 #bbb}
.markdown-body .full-commit .btn-outline:not(:disabled):hover{color:#4078c0;border:1px solid #4078c0}.markdown-body :checked+.radio-label{position:relative;z-index:1;border-color:#4078c0}.markdown-body .octicon{display:inline-block;vertical-align:text-top;fill:currentColor}.markdown-body .task-list-item{list-style-type:none}.markdown-body .task-list-item+.task-list-item{margin-top:3px}.markdown-body .task-list-item input{margin:0 .2em .25em -1.6em;vertical-align:middle}.markdown-body hr{border-bottom-color:#eee}
.markdown-body a,.markdown-body h1:hover .anchor,.markdown-body h2:hover .anchor,.markdown-body h3:hover .anchor,.markdown-body h4:hover .anchor,.markdown-body h5:hover .anchor,.markdown-body h6:hover .anchor{text-decoration:none}body{margin:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;max-width:980px;padding:45px}.markdown-body{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;color:#333;font-family:"Helvetica Neue",Helvetica,"Segoe UI",Arial,freesans,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-size:16px;line-height:1.6;word-wrap:break-word}.markdown-body a{background-color:transparent;-webkit-text-decoration-skip:objects;color:#4078c0}.markdown-body a:hover{outline-width:0;text-decoration:underline}.markdown-body strong{font-weight:bolder}.markdown-body h1{margin:.67em 0}.markdown-body h1,.markdown-body h2{padding-bottom:.3em;border-bottom:1px solid #eee}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4,.markdown-body h5,.markdown-body h6{margin-top:1em;margin-bottom:16px;font-weight:700;line-height:1.4}.markdown-body h1 .octicon-link,.markdown-body h2 .octicon-link,.markdown-body h3 .octicon-link,.markdown-body h4 .octicon-link,.markdown-body h5 .octicon-link,.markdown-body h6 .octicon-link{color:#000;vertical-align:middle;visibility:hidden}.markdown-body h1:hover .anchor .octicon-link,.markdown-body h2:hover .anchor .octicon-link,.markdown-body h3:hover .anchor .octicon-link,.markdown-body h4:hover .anchor .octicon-link,.markdown-body h5:hover .anchor .octicon-link,.markdown-body h6:hover .anchor .octicon-link{visibility:visible}.markdown-body h1{font-size:2.25em;line-height:1.2}.markdown-body h1 .anchor,.markdown-body h2 .anchor{line-height:1}.markdown-body h2{font-size:1.75em;line-height:1.225}.markdown-body h3{font-size:1.5em;line-height:1.43}.markdown-body h3 .anchor,.markdown-body h4 .anchor{line-height:1.2}.markdown-body .octicon{display:inline-block;vertical-align:text-top;fill:currentColor}.markdown-body hr{border-bottom-color:#eee}
</style>
</head>
<body class="markdown-body wysiwyg-content">
<main><h1 id="-some-new-release-on-github-project-available-">
<img src="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" height="32">
Some new release on Github project available!</h1>
<p><ul>{{content}}</ul></p>
</main>
</body>
<div class="markdown-body wysiwyg-content">
<h1 id="-some-new-release-on-github-project-available-">
<img src="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" height="32">
Some new release on Github project available!</h1>
<p><ul>{{content}}</ul></p>
</div>
</html>