visit
Git automation, either in the form of or, is amazing. It enables you to automate a lot of software maintenance tasks (testing, monitoring, mirroring repositories, generating documentation, building and distributing packages etc.) that until a couple of years ago used to take a lot of development time. These forms of automation have democratized CI/CD, bringing to the open-source world benefits that until recently either belonged mostly to the enterprise world (such as TeamCity) or had a steep curve in terms of configuration (such as Jenkins).
I have been using Github actions myself for a long time on the Platypush codebase, with a Travis-CI integration to run integration tests online and a ReadTheDocs integration to automatically generate online documentation.
However, a few things have changed lately, and I don't feel like I should rely much on the tools mentioned above for my CI/CD pipelines.
Github has in DMCA disputes since it's been acquired by Microsoft. The, but the damage in the eyes of many developers, myself included, was done, all despite the friendly olive branch to the community handed over IRC.
Most of all, that doesn't change the fact that Github has in 2020 alone, often without any appeal or legal support.
The CEO bowed down in the case of youtube-dl only because of the massive publicity that the takedown attracted. Moreover, Github has yet to overcome its biggest contradiction: it advertises itself as the home for open-source software, but its own source code is not open-source, so you can't spin up your own instance on your own server.
There's also increasing evidence in support of my initial suspicion that the Github acquisition was nothing but another old-school . When you want to clone a Github repo you won't be prompted anymore with the HTTPS/SSH link by default, you'll be prompted with the Github CLI command, which extends the standard git command, but it introduces a couple of naming inconsistencies here and there.
They could have contributed to improving the git tool for everyone's benefit instead of providing their new tool as the new default, but they have opted not to do so. I'm old enough to have seen quite a few of these examples in the past, and it never ended well for the extended party. As a consequence of these actions, I have - which comes with much more freedom, but also no more Github actions.
And, after the announcement of the , with a greater focus on enterprise, a limited credit system for open-source projects and a largely manual migration process, I have also realized that Travis-CI is another service that can’t be relied upon anymore when it comes to open-source software. And, again, Travis-CI is plagued by the same contradiction as Github — it claims to be open-source friendly, but it’s not open-source itself, and you can’t install it on your own machine.
ReadTheDocs, luckily, seems to be still coherent with its mission of
supporting open-source developers, but I’m also keeping an eye on them
just in case :)
Even though abandoning closed-source and unreliable cloud development tools is probably the right thing to do, that leaves a hole behind: how do we bring the simplicity of the automation provided by those tools to our new home — and, preferably, in such a format that it can be hosted and moved anywhere?
Github and Travis-CI provide a very easy way of setting up CI/CD pipelines. You read the documentation, upload a YAML file to your repo, and all the magic happens. I wanted to build something that was that easy to configure, but that could run anywhere, not only in someone else’s cloud.
Building a self-hosted pipeline, however, also brings its advantages. Besides liberating yourself of the concern of handing your hard-worked code to someone else who can either change their mind about their mission or take it down overnight, you have the freedom of setting up the environment for build and test however you please and customize it however you please.
And you can easily set up integrations such as automated notifications over whichever channel you like, without the headache of installing and configuring all the dependencies to run on someone else’s cloud.
In this article, we’ll see how to use Platypush to set up a pipeline that:
on new tags.Note: since I have moved my projects to a self-hosted Gitlab server, I
could have also relied on the native Gitlab CI/CD pipelines, but I have
eventually opted not to do so for two reasons:
However, if either you have enough hardware resources and time to set up your own Kubernetes infrastructure to integrate with Gitlab, or you don’t mind running your CI/CD logic on the Google cloud, Gitlab CI/CD pipelines are something that you may consider — if you don’t have the constraints above then they are very powerful, flexible and easy to set up.
Let’s start by installing Platypush with the required integrations.
If you want to set up an automation that reacts to Gitlab events then you’ll only need the
http
integration, since we'll use to trigger the automation:$ [sudo] pip install 'platypush[http]'
If you want to set up the automation on a Github repo you’ll only have
one or two additional dependencies, installed through the
github
integration:$ [sudo] pip install 'platypush[http,github]'
If you want to be notified of the status of your builds then you may want to install the integration required by the communication mean that you want to use. We’ll use in this example because it’s easy to set up and it natively supports notifications both on mobile and desktop:
$ [sudo] pip install 'platypush[pushbullet]'
Feel free however to pick anything else — for instance, you can refer to for a Telegram set up or for a mail set up, or take a look at the if you want automated notifications over SMS or Whatsapp.
Once installed, create a
~/.config/platypush/config.yaml
file that contains the service configuration - for now, we'll just enable the webserver:# The backend listens on port 8008 by default
backend.http:
enabled: True
Gitlab webhooks are a very simple and powerful way of triggering things when something happens on a Gitlab repo. All you have to do is setting up a URL that should be called upon a repository event (push, tag, new issue, merge request etc.), and set up automation on the endpoint that reacts to the event.
The only requirement for this mechanism to work is that the endpoint must be reachable from the Gitlab host — it means that the host running the Platypush web service must either be publicly accessible, on the same
network or VPN as the Gitlab host or the Platypush web port must be
tunnelled/proxied to the Gitlab host.
Platypush offers a very easy way to expose custom endpoints through the . All you have to do is set up an event hook that reacts to a WebhookEvent at a specific endpoint. For example, create a new event hook under
~/.config/platypush/scripts/gitlab.py
:from platypush.event.hook import hook
from platypush.message.event.http.hook import WebhookEvent
# Token to be used to authenticate the calls from Gitlab
gitlab_token = 'YOUR_TOKEN_HERE'
@hook(WebhookEvent, hook='repo-push')
def on_repo_push(event, **context):
# Check that the token provided over the
# X-Gitlab-Token header is valid
assert event.headers.get('X-Gitlab-Token') == gitlab_token, \
'Invalid Gitlab token'
print('Add your logic here')
This hook will react when an HTTP request is received on //your-host:8008/hook/repo-push. Note that, unlike most of the other Platypush endpoints, custom hooks are not authenticated - that's because they may be called from any context, and you don't necessarily want to share your Platypush instance credentials or token with 3rd-parties. Instead, it's up to you to implement whichever authentication policy you like over the requests.
After adding your endpoint, start Platypush:
$ platypush
Now, to set up a new webhook, navigate to your Gitlab project -> Settings -> Webhooks.
Enter the URL to your webhook and the secret token and select the events you want to react to — in this example, we’ll select new push events.
You can now test the endpoint through the Gitlab interface itself. If it all went well, you should see a
Received event
line with some content like this on the standard output or log file of Platypush:{
"type": "event",
"target": "platypush-host",
"origin": "gitlab-host",
"args": {
"type": "platypush.message.event.http.hook.WebhookEvent",
"hook": "repo-push",
"method": "POST",
"data": {
"object_kind": "push",
"event_name": "push",
"before": "previous-commit-id",
"after": "current-commit-id",
"ref": "refs/heads/master",
"checkout_sha": "current-commit-id",
"message": null,
"user_id": 1,
"user_name": "Your User",
"user_username": "youruser",
"user_email": "[email protected]",
"user_avatar": "path to your avatar",
"project_id": 1,
"project": {
"id": 1,
"name": "My project",
"description": "Project description",
"web_url": "//git.platypush.tech/platypush/platypush",
"avatar_url": "//git.platypush.tech/uploads/-/system/project/avatar/3/icon-256.png",
"git_ssh_url": "[email protected]:platypush/platypush.git",
"git_http_url": "//git.platypush.tech/platypush/platypush.git",
"namespace": "My project",
"visibility_level": 20,
"path_with_namespace": "platypush/platypush",
"default_branch": "master",
"ci_config_path": null,
"homepage": "//git.platypush.tech/platypush/platypush",
"url": "[email protected]:platypush/platypush.git",
"ssh_url": "[email protected]:platypush/platypush.git",
"http_url": "//git.platypush.tech/platypush/platypush.git"
},
"commits": [
{
"id": "current-commit-id",
"message": "This is a commit",
"title": "This is a commit",
"timestamp": "2022-03-06T20:02:25+01:00",
"url": "//git.platypush.tech/platypush/platypush/-/commit/current-commit-id",
"author": {
"name": "Your Name",
"email": "[email protected]"
},
"added": [],
"modified": [
"tests/my_test.py"
],
"removed": []
}
],
"total_commits_count": 1,
"push_options": {},
"repository": {
"name": "My project",
"url": "[email protected]:platypush/platypush.git",
"description": "Project description",
"homepage": "//git.platypush.tech/platypush/platypush",
"git_http_url": "//git.platypush.tech/platypush/platypush.git",
"git_ssh_url": "[email protected]:platypush/platypush.git",
"visibility_level": 20
}
},
"args": {},
"headers": {
"Content-Type": "application/json",
"User-Agent": "GitLab/version",
"X-Gitlab-Event": "Push Hook",
"X-Gitlab-Token": "YOUR GITLAB TOKEN",
"Connection": "close",
"Host": "platypush-host:8008",
"Content-Length": "lenght"
}
}
}
If you want to keep using Github but run the CI/CD pipelines on another
host with no dependencies on the Github actions, you can leverage the to monitor your repos and fire that you can build your hooks on when something happens.
First, head to your Github profile to create a new . Then add the configuration to
~/.config/platypush/config.yaml
under the backend.github
section:backend.github:
user: your_user
user_token: your_token
# Optional list of repos to monitor (default: all user repos)
repos:
- https://github.com/you/myrepo1.git
- https://github.com/you/myrepo2.git
# How often the backend should poll for updates (default: 60 seconds)
poll_seconds: 60
# Maximum events that will be triggered if a high number of events has
# been triggered since the last poll (default: 10)
max_events_per_scan: 10
Start the service, and on e.g. the first repository push event you should see a
Received event
logline like this:{
"type": "event",
"target": "your-host",
"origin": "your-host",
"args": {
"type": "platypush.message.event.github.GithubPushEvent",
"actor": {
"id": 1234,
"login": "you",
"display_login": "You",
"url": "//api.github.com/users/you",
"avatar_url": "//avatars.githubusercontent.com/u/1234?"
},
"event_type": "PushEvent",
"repo": {
"id": 12345,
"name": "you/myrepo1",
"url": "//api.github.com/repos/you/myrepo1"
},
"created_at": "2022-03-03T18:20:27+00:00",
"payload": {
"push_id": 123456,
"size": 1,
"distinct_size": 1,
"ref": "refs/heads/master",
"head": "current-commit-id",
"before": "previous-commit-id",
"commits": [
{
"sha": "current-commit-id",
"author": {
"email": "[email protected]",
"name": "You"
},
"message": "This is a commit",
"distinct": true,
"url": "//api.github.com/repos/you/myrepo1/commits/current-commit-id"
}
]
}
}
}
You can easily create an event hook that reacts to such events to run your automation — e.g. under
~/.config/platypush/scripts/github.py
:from platypush.event.hook import hook
from platypush.message.event.github import GithubPushEvent
@hook(GithubPushEvent)
def on_repo_push(event, **context):
# Run this action only for a specific repo
if event.repo['name'] != 'you/myrepo1':
return
print('Add your logic here')
And here you go — you should now be ready to create your automation routines on Github events.
Even though I have moved the Platypush repos to a self-hosted domain, I
still keep a mirror of them on Github. That’s because lots of people have already cloned the repos over the years and may lose updates if they haven’t seen the announcement about the transfer. Also, registering to a new domain is often a barrier for users who want to create issues.
So, even though I and Github are no longer friends, I still need a way to easily mirror each new commit on my domain to Github — but you might as well have another compelling case for backing up/mirroring your repos. The way I’m currently achieving this is by cloning the main instance of the repo on the machine that runs the Platypush service:
$ git clone [email protected]:you/myrepo.git /opt/repo
Then add a new remote that points to your mirror repo:
$ cd /opt/repo
$ git remote add mirror [email protected]:/you/myrepo.git
$ git fetch
Then try a first
git push --mirror
to ensure that the repos are aligned and all conflicts are solved:$ git push --mirror -v mirror
Then add a new
sync_to_mirror
function in your Platypush script file that looks like this:import logging
import os
import subprocess
repo_path = '/opt/repo'
# ...
def sync_to_mirror():
logging.info('Synchronizing commits to mirror')
os.chdir(repo_path)
# Pull the updates from the main repo
subprocess.run(['git', 'pull', '--rebase', 'origin', 'master'])
# Sync the updates to the repo
subprocess.run(['git', 'push', '--mirror', '-v', 'mirror'])
logging.info('Synchronizing commits to mirror: DONE')
And just call it from the previously defined on_repo_push hook, either the Gitlab or Github variant:
# ...
def on_repo_push(event, **_):
# ...
sync_to_mirror()
# ...
Now on each push, the repository clone stored under /opt/repo will be updated and any new commits and tags will be mirrored to the mirror repository.
If our project is properly set up, then it probably has a suite of unit/integration tests that are supposed to be run on each change to verify that nothing is broken. It’s quite easy to configure the previously created hook so that it runs the tests on each push. For instance, if your tests are stored under the tests folder of your project and you use
pytest
:import datetime
import os
import pathlib
import shutil
import subprocess
from platypush.event.hook import hook
from platypush.message.event.http.hook import WebhookEvent
# Path where the latest version of the repo will be cloned
tmp_path = '/tmp/repo'
# Path where the results of the tests will be stored
logs_path = '/var/log/tests'
# ...
def run_tests():
# Clone the repo in /tmp
shutil.rmtree(tmp_path, ignore_errors=True)
subprocess.run(['git', 'clone', '[email protected]:you/myrepo.git', tmp_path])
os.chdir(os.path.join(tmp_path, 'tests'))
passed = False
try:
# Run the tests
tests = subprocess.Popen(['pytest'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stdout = tests.communicate()[0].decode()
passed = tests.returncode == 0
# Write the stdout to a logfile
pathlib.Path(logs_path).mkdir(parents=True, exist_ok=True)
logfile = os.path.join(logs_path,
f'{datetime.datetime.now().isoformat()}_'
f'{"PASSED" if passed else "FAILED"}.log')
with open(logfile, 'w') as f:
f.write(stdout)
finally:
shutil.rmtree(tmp_path, ignore_errors=True)
# Return True if the tests passed, False otherwise
return passed
# ...
@hook(WebhookEvent, hook='repo-push')
# or
# @hook(GithubPushEvent)
def on_repo_push(event, **_):
# ...
passed = run_tests()
# ...
Upon push event, the latest version of the repo will be cloned under and the suite of tests will be run. The output of each session will be stored under/var/log/tests in a file formatted like
<ISO timestamp>_<PASSED|FAILED>.log
. To make things even more robust, you can create a new virtual environment under the temporary directory, install your repo with all of its dependency in the new virtual environment and run the tests from there, or spin a Docker instance with the required configuration, to make sure that the tests would also pass on a fresh installation and prevent the"but it works on my box" issue.
Now you can simply serve /var/log/tests over an HTTP server and the logs can be accessed from your browser. Simple case:
$ cd /var/log/tests
$ python -m http.server 8000
The logs will be served on
//host:8000
. You can also serve the directory through a proper web server like Nginx or Apache.It doesn’t come with all the bells and whistles of the Jenkins or Travis-CI UI, but it’s simple and good enough for its job — and it’s not hard to extend it with a fancier UI if you like.
Another nice addition is to download some of those nice passed/failed badge images that you find on many Github repositories to your Platypush box. When a test run completes, just edit your hook to copy the associated banner image (e.g.
passed.svg
or failed.svg
) to e.g. /var/log/test/status.svg
:import os
import shutil
# ...
def run_tests():
# ...
passed = tests.returncode == 0
badge_path = '/path/to/passed.svg' if passed else '/path/to/failed.svg'
shutil.copy(badge_path, os.path.join(logs_path, 'status.svg'))
# ...
Then embed the status in your
README.md
:[![Tests Status](//your-host:8000/status.svg)](//your-host:8000)
And there you go — you can now show off a dynamically generated and self-hosted status badge on your README without relying on any cloud runner.
Another useful feature of the popular cloud services is the ability to send notifications when a build status changes. This is quite easy to set up with Platypush, as the application provides several plugins for messaging.
Let’s look at an example where a change in the status of our tests triggers a notification to our Pushbullet account, which can be delivered both to our desktop and mobile devices. if you want the notifications to be delivered to your mobile, get an and then install the dependencies for the Pushbullet integration for Platypush:
$ [sudo] pip install 'platypush[pushbullet]'
Then configure the Pushbullet plugin and backend in
~/.config/platypush/config.yaml
:backend.pushbullet:
token: YOUR_PUSHBULLET_TOKEN
device: platypush
pushbullet:
enabled: True
Now simply modify your push hook to send a notification when the status of the build changes. We will also use the to retrieve and store the latest status so that notifications are triggered only when the status changes:
from platypush.context import get_plugin
from platypush.event.hook import hook
from platypush.message.event.http.hook import WebhookEvent
# Name of the variable that holds the latest run status
last_tests_passed_var = 'LAST_TESTS_PASSED'
# ...
def run_tests():
# ...
passed = tests.returncode == 0
# ...
return passed
# ...
@hook(WebhookEvent, hook='repo-push')
# or
# @hook(GithubPushEvent)
def on_repo_push(event, **_):
variable = get_plugin('variable')
pushbullet = get_plugin('pushbullet')
# Get the status of the last run
response = variable.get(last_tests_passed_var).output
last_tests_passed = int(response.get(last_tests_passed_var, 0))
# ...
passed = run_tests()
if passed and not last_tests_passed:
pushbullet.send_note(body='The tests are now PASSING',
# If device is not set then the notification will
# be sent to all the devices connected to the account
device='my-mobile-name')
elif not passed and last_tests_passed:
pushbullet.send_note(body='The tests are now FAILING',
device='my-mobile-name')
# Update the last_test_passed variable
variable.set(**{last_tests_passed_var: int(passed)})
# ...
The nice addition of this approach is that any other Platypush device
with the Pushbullet backend enabled and connected to the same account
will receive a when a Pushbullet note is sent, and you can easily leverage this to build some downstream logic with hooks that react to these events.
Once we have a logic in place that automatically mirrors and tests our code
and notifies us about status changes, we can take things a step further and set up our pipeline to also build a package for our applications if the tests are successful.
Let’s consider in this article the example of a Python application whose new releases are tagged through git tags, and each time a new version is released we want to create a pip package and upload it to the online PyPI registry. However, you can easily adapt this example to work with any build and release process.
is a quite popular option when it comes to uploading packages to the PyPI registry. Let's install it:
$ [sudo] pip install twine
Then create a Gitlab webhook that reacts to tag events, or react to a if you are using Github, and create a Platypush hook that reacts to tag events by running the logic of
on_repo_push
, and additionally triggers a package build and uploads it with Twine if the tests are successful:import importlib
import os
import subprocess
from platypush.event.hook import hook
from platypush.message.event.http.hook import WebhookEvent
# Path where the latest version of the repo has been cloned
tmp_path = '/tmp/repo'
# Initialize these variables with your PyPI credentials
os.environ['TWINE_USERNAME'] = 'your-pypi-user'
os.environ['TWINE_PASSWORD'] = 'your-pypi-pass'
# ...
def upload_pip_package():
os.chdir(tmp_path)
# Build the package
subprocess.run(['python', 'setup.py', 'sdist', 'bdist_wheel'])
# Check the version of your app - for example from the
# yourapp/__init__.py __version__ field
app = importlib.import_module('yourapp')
version = app.__version__
# Check that the archive file has been created
archive_file = os.path.join('.', 'dist', f'yourapp-{version}.tar.gz')
assert os.path.isfile(archive_file), \
f'The target file {archive_file} was not created'
# Upload the archive file to PyPI
subprocess.run(['twine', 'upload', archive_file])
@hook(WebhookEvent, hook='repo-tag')
# or
# @hook(GithubCreateEvent)
def on_repo_tag(event, **_):
# ...
passed = run_tests()
if passed:
upload_pip_package()
# ...
And here you go — you now have an automated way of building and releasing your application!
We have seen in this article some examples of CI/CD for stand-alone
applications with a complete test+build+release pipeline. The same concept also applies to web services and applications. If your repository stores the source code of a website, then you can easily create pieces of automation that react to push events and pull the changes on the webserver and restart the web service if required.
This is in fact the way I’m currently managing updates on the Platypush and . Let’s see a small example where we have a Platypush instance running on the same machine as the webserver, and suppose that our website is served under
/srv/http/myapp
(and, of course, that the user that runs the Platypush service has write permissions on this location). It's quite easy to tweak the previous hook example so that it reacts to push events on this repo by pulling the latest changes, runs e.g.
npm run build
to build the new dist files and then copies the dist
folder to our web server directory:import os
import shutil
import subprocess
from platypush.event.hook import hook
from platypush.message.event.http.hook import WebhookEvent
# Path where the latest version of the repo has been cloned
tmp_path = '/tmp/repo'
# Path of the web application
webapp_path = '/srv/http/myapp'
# Backup path of the web application
backup_webapp_path = '/srv/http/myapp-backup'
# ...
def update_webapp():
os.chdir(tmp_path)
# Build the app
subprocess.run(['npm', 'install'])
subprocess.run(['npm', 'run', 'build'])
# Verify that the dist folder has been created
dist_path = os.path.join('.', 'dist')
assert os.path.isdir(dist_path), 'dist path not created'
# Remove the previous app backup folder if present
shutil.rmtree(backup_webapp_path, ignore_errors=True)
# Backup the old web app folder
shutil.move(webapp_path, backup_webapp_path)
# Move the dist folder to the web app folder
shutil.move(dist_path, webapp_path)
@hook(WebhookEvent, hook='repo-push')
# or
# @hook(GithubPushEvent)
def on_repo_tag(event, **_):
# ...
passed = run_tests()
if passed:
update_webapp()
# ...
Previously published at