How I safely use GitHub Actions to build a static website with Hugo and deploy it using SSH without any third-party dependencies
This article describes how I safely use GitHub Actions to build a static website with Hugo and deploy it using SSH without any third-party dependencies. Code executed in continuous deployment pipelines may have access to secrets (like FTP credentials and SSH keys). Supply-chain attacks are becoming more frequent, including self-sabotage by open-source authors. Without 2FA, the code of well-intentioned maintainers is one stolen password away from becoming malicious. For these reasons I find it imperative to eliminate third-party Actions from my CI/CD pipelines wherever possible.
β οΈ WARNING: Third-party Actions in the GitHub Actions Marketplace may be compromised to run malicious code and leak secrets. There are hundreds of public actions claiming to help with Hugo, SSH, and Rsync execution. I advise avoiding third-party actions in your CI/CD pipeline whenever possible.
This article assumes you have at least some familiarity with GitHub Actions, but if you’re never used them before I recommend taking 5 minutes to work through the Quickstart for GitHub Actions.
This is my cicd-website.yaml
workflow for building a Hugo website and deploying it with SSH. Most people can just copy/paste what they need from here, but the rest of the article will discuss the purpose and rationale for each of these sections in more detail.
name: Website
on:
workflow_dispatch:
push:
jobs:
build:
name: Build and Deploy
runs-on: ubuntu-latest
steps:
- name: π Checkout
uses: actions/checkout@v2
- name: β¨ Setup Hugo
env:
HUGO_VERSION: 0.92.2
run: |
mkdir ~/hugo
cd ~/hugo
curl -L "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-64bit.tar.gz" --output hugo.tar.gz
tar -xvzf hugo.tar.gz
sudo mv hugo /usr/local/bin
- name: π οΈ Build
run: hugo --source website --minify
- name: π Install SSH Key
run: |
install -m 600 -D /dev/null ~/.ssh/id_rsa
echo "${{ secrets.PRIVATE_SSH_KEY }}" > ~/.ssh/id_rsa
echo "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: π Deploy
run: rsync --archive --delete --stats -e 'ssh -p 18765' 'website/public/' ${{ secrets.REMOTE_DEST }}
The on
section determines which triggers will initiate this workflow (building/deploying the site). The following will run the workflow after every push to the GitHub repository. The workflow_dispatch
allows the workflow to be triggered manually through the GitHub Actions web interface.
on:
workflow_dispatch:
push:
I store my hugo site in the subfolder ./website
, so if I wanted to only rebuild/redeploy when the website files are changed (and not other files in the repository) I could add a paths
filter. If your repository has multiple branches you likely want a branches
filter as well.
on:
workflow_dispatch:
push:
paths:
- "website/**"
branches:
- main
This step defines the Hugo version I want as a temporary environment variable, downloads latest binary from the Hugo Releases page on GitHub, extracts it, and moves the executable file to the user’s bin
folder so it can be subsequently run from any folder.
- name: β¨ Setup Hugo
env:
HUGO_VERSION: 0.92.2
run: |
mkdir ~/hugo
cd ~/hugo
curl -L "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-64bit.tar.gz" --output hugo.tar.gz
tar -xvzf hugo.tar.gz
sudo mv hugo /usr/local/bin
I store my hugo site in the subfolder ./website
, so when I build the site I must define the source folder. Check-out the Hugo build commands page for documentation about all the available options.
- name: π οΈ Build
run: hugo --source website --minify
This part is likely the most confusing for new users, so I’ll keep it as minimal as possible. Before you start, I recommend you follow your hosting provider’s guide for setting-up SSH. Once you can SSH from your own machine, it will be much easier to set it up in GitHub Actions.
- Start by creating a private/public key pair
ssh-keygen -t ed25519 -C "you@gmail.com"
- Code here assumes you use an empty passphrase
- The public key is one long line that starts with
ssh-rsa
- The private key is a multi-line text block that starts and ends with
---
- You give the PUBLIC key to your hosting provider to remember
- When you log in SSH you present your PRIVATE key
- GitHub Actions will need your PRIVATE key, so store it as a GitHub Encrypted Secret (
PRIVATE_SSH_KEY
)
To protect you from leaking your private key to a compromised host, you can retrieve your host’s public key and check against it later to be sure it does not change. To get keys for your hosts run the following command:
My hosting provider uses a non-standard SSH port, so I must specify it with:
ssh-keyscan -p 12345 example.com
The host’s public keys will be a short list of text. Store it as a GitHub Encrypted Secret (KNOWN_HOSTS
)
These commands will create text files in your .ssh
folder containing your private key and the public keys of your host. Later rsync
will complain if your private key is in a file with general read/write access, so the install
command is used to create an empty file with user-only read/write access (chmod 600), then an echo
command is used to populate that file with your private key information.
- name: π Install SSH Key
run: |
install -m 600 -D /dev/null ~/.ssh/id_rsa
echo "${{ secrets.PRIVATE_SSH_KEY }}" > ~/.ssh/id_rsa
echo "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts
Rsync is an application for synchronizing files over networks which is available on most Linux distributions. It only sending files with different modification times and file sizes, so it can be used to efficiently deploy changes to very large websites.
Many people are okay with the defaults:
- name: π Deploy
run: rsync --archive public/ username@example.com:~/www/
I use additional arguments (see rsync documentation) to:
- allow remote deletion of files
- use a non-standard SSH port (12345)
- store my remote destination as a GitHub Encrypted Secret - not because it’s private, but so I don’t accidentally mess it up by incorrectly managing my workflow yaml (which could result in remote data deletion)
- display a small stats section after finishing (see screenshot)
- name: π Deploy
run: rsync --archive --delete --stats -e 'ssh -p 12345' website/public/ ${{ secrets.REMOTE_DEST }}
That’s a lot to figure-out and set-up the first time, but once you have your SSH keys ready and some YAML you can copy/paste across multiple projects it’s not that bad.
I find rsync
to be extremely fast compared to something like FTP run in GitHub Actions, and I’m very satisfied that I can achieve all these steps using Linux console commands and not depending on any other Actions.
- This content was written after recently creating
C# Data Visualization (a Hugo site built and deployed with GitHub Actions).
- The official Hosting and Deployment site has information for:
Google Cloud, AWS, Azure, Netlify, GitHub Pages, KeyCDN, Render CDN, Bitbucket, Netlify, Firebase, GitLab, and Rsync over SSH.
- A collection of my personal notes related to Hugo is in my code-notes/Hugo repository.
- Deploying a Hugo site with Github Actions by Jono Fotografie
- Hugo: Deployment with Rsync
- Rsync documentation and argument information: rsync(1)
How to perform math on generic types in C# with .NET 6
Generic types are great, but it has traditionally been difficult to do math with them. Consider the simple task where you want to accept a generic array and return its sum. With .NET 6 (and features currently still in preview), this got much easier!
public static T Sum<T>(T[] values) where T : INumber<T>
{
T sum = T.Zero;
for (int i = 0; i < values.Length; i++)
sum += values[i];
return sum;
}
To use this feature today you must:
- Install the System.Runtime.Experimental NuGet package
- Add these lines to the
PropertyGroup
in your csproj file:
<langversion>preview</langversion>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
Note that the generic math function above is equivalent in speed to one that accepts and returns double[]
, while a method which accepts a generic but calls Convert.ToDouble()
every time is about 3x slower than both options:
// this code works on older versions of .NET but is about 3x slower
public static double SumGenericToDouble<T>(T[] values)
{
double sum = 0;
for (int i = 0; i < values.Length; i++)
sum += Convert.ToDouble(values[i]);
return sum;
}
How to determine if a point is inside a rotated rectangle with C#
I recently had the need to determine if a point is inside a rotated rectangle. This need arose when I wanted to make a rotated rectangular textbox draggable, but I wanted to determine if the mouse was over the rectangle. I know the rectangle’s location, size, and rotation, and the position of the mouse cursor, and my goal is to tell if the mouse is inside the rotated rectangle. In this example I’ll use Maui.Graphics
to render a test image in a Windows Forms application (with SkiaSharp and OpenGL), but the same could be achieved with System.Drawing
or other similar 2D graphics libraries.
I started just knowing the width and height of my rectangle. I created an array of points representing its corners.
float rectWidth = 300;
float rectHeight = 150;
PointF[] rectCorners =
{
new(0, 0),
new(rectWidth, 0),
new(rectWidth, rectHeight),
new(0, rectHeight),
};
I then rotated the rectangle around an origin point by applying a rotation transformation to each corner.
PointF origin = new(200, 300); // center of rotation
double angleRadians = 1.234;
PointF[] rotatedCorners = rectCorners.Select(x => Rotate(origin, x, angleRadians)).ToArray();
private PointF Rotate(PointF origin, PointF point, double radians)
{
double dx = point.X * Math.Cos(radians) - point.Y * Math.Sin(radians);
double dy = point.X * Math.Sin(radians) + point.Y * Math.Cos(radians);
return new PointF(origin.X + (float)dx, origin.Y + (float)dy);
}
To determine if a given point is inside the rotated rectangle I called this method which accepts the point of interest and an array containing the four corners of the rotated rectangle.
public bool IsPointInsideRectangle(PointF pt, PointF[] rectCorners)
{
double x1 = rectCorners[0].X;
double x2 = rectCorners[1].X;
double x3 = rectCorners[2].X;
double x4 = rectCorners[3].X;
double y1 = rectCorners[0].Y;
double y2 = rectCorners[1].Y;
double y3 = rectCorners[2].Y;
double y4 = rectCorners[3].Y;
double a1 = Math.Sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
double a2 = Math.Sqrt((x2 - x3) * (x2 - x3) + (y2 - y3) * (y2 - y3));
double a3 = Math.Sqrt((x3 - x4) * (x3 - x4) + (y3 - y4) * (y3 - y4));
double a4 = Math.Sqrt((x4 - x1) * (x4 - x1) + (y4 - y1) * (y4 - y1));
double b1 = Math.Sqrt((x1 - pt.X) * (x1 - pt.X) + (y1 - pt.Y) * (y1 - pt.Y));
double b2 = Math.Sqrt((x2 - pt.X) * (x2 - pt.X) + (y2 - pt.Y) * (y2 - pt.Y));
double b3 = Math.Sqrt((x3 - pt.X) * (x3 - pt.X) + (y3 - pt.Y) * (y3 - pt.Y));
double b4 = Math.Sqrt((x4 - pt.X) * (x4 - pt.X) + (y4 - pt.Y) * (y4 - pt.Y));
double u1 = (a1 + b1 + b2) / 2;
double u2 = (a2 + b2 + b3) / 2;
double u3 = (a3 + b3 + b4) / 2;
double u4 = (a4 + b4 + b1) / 2;
double A1 = Math.Sqrt(u1 * (u1 - a1) * (u1 - b1) * (u1 - b2));
double A2 = Math.Sqrt(u2 * (u2 - a2) * (u2 - b2) * (u2 - b3));
double A3 = Math.Sqrt(u3 * (u3 - a3) * (u3 - b3) * (u3 - b4));
double A4 = Math.Sqrt(u4 * (u4 - a4) * (u4 - b4) * (u4 - b1));
double difference = A1 + A2 + A3 + A4 - a1 * a2;
return difference < 1;
}
Consider 4 triangles formed by lines between the point and the 4 corners…
If the point is inside the rectangle, the area of the four triangles will equal the area of the rectangle.
If the point is outside the rectangle, the area of the four triangles will be greater than the area of the rectangle.
The code above calculates the area of the 4 rectangles and returns true
if it is approximately equal to the area of the rectangle.
-
In practice you’ll probably want to use a more intelligent data structure than a 4-element Pointf[]
when calling these functions.
-
The points in the array are clockwise, but I assume this method will work regardless of the order of the points in the array.
-
At the very end of IsPointInsideRectangle()
the final decision is made based on a distance being less than a given value. It’s true that the cursor will be inside the rectangle if the distance is exactly zero, but with the possible accumulation of floating-point math errors this seemed like a safer option.
How to smooth X/Y data using spline interpolation in Csharp
I recently had the need to create a smoothed curve from a series of X/Y data points in a C# application. I achieved this using cubic spline interpolation. I prefer this strategy because I can control the exact number of points in the output curve, and the generated curve (given sufficient points) will pass through the original data making it excellent for data smoothing applications.
The code below is an adaptation of original work by Ryan Seghers (links below) that I modified to narrow its scope, support double
types, use modern language features, and operate statelessly in a functional style with all static
methods.
-
It targets .NET Standard 2.0
so it can be used in .NET Framework and .NET Core applications.
-
Input Xs
and Ys
must be the same length but do not need to be ordered.
-
The interpolated curve may have any number of points (not just even multiples of the input length), and may even have fewer points than the original data.
-
Users cannot define start or end slopes so the curve generated is a natural spline.
public static class Cubic
{
/// <summary>
/// Generate a smooth (interpolated) curve that follows the path of the given X/Y points
/// </summary>
public static (double[] xs, double[] ys) InterpolateXY(double[] xs, double[] ys, int count)
{
if (xs is null || ys is null || xs.Length != ys.Length)
throw new ArgumentException($"{nameof(xs)} and {nameof(ys)} must have same length");
int inputPointCount = xs.Length;
double[] inputDistances = new double[inputPointCount];
for (int i = 1; i < inputPointCount; i++)
{
double dx = xs[i] - xs[i - 1];
double dy = ys[i] - ys[i - 1];
double distance = Math.Sqrt(dx * dx + dy * dy);
inputDistances[i] = inputDistances[i - 1] + distance;
}
double meanDistance = inputDistances.Last() / (count - 1);
double[] evenDistances = Enumerable.Range(0, count).Select(x => x * meanDistance).ToArray();
double[] xsOut = Interpolate(inputDistances, xs, evenDistances);
double[] ysOut = Interpolate(inputDistances, ys, evenDistances);
return (xsOut, ysOut);
}
private static double[] Interpolate(double[] xOrig, double[] yOrig, double[] xInterp)
{
(double[] a, double[] b) = FitMatrix(xOrig, yOrig);
double[] yInterp = new double[xInterp.Length];
for (int i = 0; i < yInterp.Length; i++)
{
int j;
for (j = 0; j < xOrig.Length - 2; j++)
if (xInterp[i] <= xOrig[j + 1])
break;
double dx = xOrig[j + 1] - xOrig[j];
double t = (xInterp[i] - xOrig[j]) / dx;
double y = (1 - t) * yOrig[j] + t * yOrig[j + 1] +
t * (1 - t) * (a[j] * (1 - t) + b[j] * t);
yInterp[i] = y;
}
return yInterp;
}
private static (double[] a, double[] b) FitMatrix(double[] x, double[] y)
{
int n = x.Length;
double[] a = new double[n - 1];
double[] b = new double[n - 1];
double[] r = new double[n];
double[] A = new double[n];
double[] B = new double[n];
double[] C = new double[n];
double dx1, dx2, dy1, dy2;
dx1 = x[1] - x[0];
C[0] = 1.0f / dx1;
B[0] = 2.0f * C[0];
r[0] = 3 * (y[1] - y[0]) / (dx1 * dx1);
for (int i = 1; i < n - 1; i++)
{
dx1 = x[i] - x[i - 1];
dx2 = x[i + 1] - x[i];
A[i] = 1.0f / dx1;
C[i] = 1.0f / dx2;
B[i] = 2.0f * (A[i] + C[i]);
dy1 = y[i] - y[i - 1];
dy2 = y[i + 1] - y[i];
r[i] = 3 * (dy1 / (dx1 * dx1) + dy2 / (dx2 * dx2));
}
dx1 = x[n - 1] - x[n - 2];
dy1 = y[n - 1] - y[n - 2];
A[n - 1] = 1.0f / dx1;
B[n - 1] = 2.0f * A[n - 1];
r[n - 1] = 3 * (dy1 / (dx1 * dx1));
double[] cPrime = new double[n];
cPrime[0] = C[0] / B[0];
for (int i = 1; i < n; i++)
cPrime[i] = C[i] / (B[i] - cPrime[i - 1] * A[i]);
double[] dPrime = new double[n];
dPrime[0] = r[0] / B[0];
for (int i = 1; i < n; i++)
dPrime[i] = (r[i] - dPrime[i - 1] * A[i]) / (B[i] - cPrime[i - 1] * A[i]);
double[] k = new double[n];
k[n - 1] = dPrime[n - 1];
for (int i = n - 2; i >= 0; i--)
k[i] = dPrime[i] - cPrime[i] * k[i + 1];
for (int i = 1; i < n; i++)
{
dx1 = x[i] - x[i - 1];
dy1 = y[i] - y[i - 1];
a[i - 1] = k[i - 1] * dx1 - dy1;
b[i - 1] = -k[i] * dx1 + dy1;
}
return (a, b);
}
}
This sample .NET 6 console application uses the class above to create a smoothed (interpolated) curve from a set of random X/Y points. It then plots the original data and the interpolated curve using ScottPlot.
// generate sample data using a random walk
Random rand = new(1268);
int pointCount = 20;
double[] xs1 = new double[pointCount];
double[] ys1 = new double[pointCount];
for (int i = 1; i < pointCount; i++)
{
xs1[i] = xs1[i - 1] + rand.NextDouble() - .5;
ys1[i] = ys1[i - 1] + rand.NextDouble() - .5;
}
// Use cubic interpolation to smooth the original data
(double[] xs2, double[] ys2) = Cubic.InterpolateXY(xs1, ys1, 200);
// Plot the original vs. interpolated data
var plt = new ScottPlot.Plot(600, 400);
plt.AddScatter(xs1, ys1, label: "original", markerSize: 7);
plt.AddScatter(xs2, ys2, label: "interpolated", markerSize: 3);
plt.Legend();
plt.SaveFig("interpolation.png");
There are many different methods that can smooth data. Common methods include BΓ©zier splines, Catmull-Rom splines, corner-cutting Chaikin curves, and Cubic splines. I recently implemented these strageies to include with ScottPlot (a MIT-licensed 2D plotting library for .NET). Visit ScottPlot.net to find the source code for that project and search for the Interpolation
namespace.
A set of X/Y points can be interpolated such that they are evenly spaced on the X axis. This 1D interpolation can be used to change the sampling rate of time series data. For more information about 1D interpolation see my other blog post: Resample Time Series Data using Cubic Spline Interpolation