1 Commits
Author SHA1 Message Date
neil 3eb4d15825 WIP - outline for pinecil homeassistant / ble article
ci/woodpecker/pr/woodpecker Pipeline was successful
2023-04-02 18:35:36 -04:00
54 changed files with 212 additions and 2701 deletions
-208
View File
@@ -1,208 +0,0 @@
# Sample workflow for building and deploying a Hugo site to GitHub Pages
name: Deploy Hugo site to Pages
on:
# Runs on pushes targeting the default branch
push:
branches:
- main
# Runs on pull requests
pull_request:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: write
pages: write
id-token: write
issues: write
pull-requests: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
# Default to bash
defaults:
run:
# GitHub-hosted runners automatically enable `set -eo pipefail` for Bash shells.
shell: bash
jobs:
# Build job
build:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.148.2
HUGO_ENVIRONMENT: production
TZ: America/New_York
steps:
- name: Checkout for gh CLI
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Check for existing PR artifact (main branch only)
if: github.ref == 'refs/heads/main'
id: check-pr
run: |
PR_NUMBER=$(gh pr list --state merged --limit 10 --json number,mergeCommit --jq ".[] | select(.mergeCommit.oid == \"${{ github.sha }}\") | .number" | head -1)
if [ -n "$PR_NUMBER" ]; then
echo "Found PR #$PR_NUMBER for this commit"
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "has_pr=true" >> $GITHUB_OUTPUT
else
echo "No PR found, will build normally"
echo "has_pr=false" >> $GITHUB_OUTPUT
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Try to download existing PR artifact
if: github.ref == 'refs/heads/main' && steps.check-pr.outputs.has_pr == 'true'
id: download-existing
uses: dawidd6/action-download-artifact@v3
continue-on-error: true
with:
pr: ${{ steps.check-pr.outputs.pr_number }}
name: github-pages
path: ./public
- name: Check if build is needed
id: build-needed
run: |
if [[ "${{ github.event_name }}" == "pull_request" || "${{ steps.download-existing.outcome }}" == "failure" || "${{ steps.check-pr.outputs.has_pr }}" == "false" ]]; then
echo "need_build=true" >> $GITHUB_OUTPUT
else
echo "need_build=false" >> $GITHUB_OUTPUT
fi
- name: Install Hugo CLI
if: steps.build-needed.outputs.need_build == 'true'
run: |
wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb
sudo dpkg -i ${{ runner.temp }}/hugo.deb
- name: Install Dart Sass
if: steps.build-needed.outputs.need_build == 'true'
run: sudo snap install dart-sass
# - name: Setup Pages
# if: steps.build-needed.outputs.need_build == 'true'
# id: pages
# uses: actions/configure-pages@v5
- name: Cache Restore
if: steps.build-needed.outputs.need_build == 'true'
id: cache-restore
uses: actions/cache/restore@v4
with:
path: |
${{ runner.temp }}/hugo_cache
key: hugo-${{ github.run_id }}
restore-keys:
hugo-
- name: Configure Git
if: steps.build-needed.outputs.need_build == 'true'
run: git config core.quotepath false
- name: Build with Hugo
if: steps.build-needed.outputs.need_build == 'true'
run: |
hugo \
--gc \
--minify \
--cacheDir "${{ runner.temp }}/hugo_cache"
- name: Create CNAME file
if: steps.build-needed.outputs.need_build == 'true'
run: |
cat << EOF > public/CNAME
thepotato.tech
neilhanlon.com
neilhanlon.me
hanlon.ninja
EOF
- name: Cache Save
if: steps.build-needed.outputs.need_build == 'true'
id: cache-save
uses: actions/cache/save@v4
with:
path: |
${{ runner.temp }}/hugo_cache
key: ${{ steps.cache-restore.outputs.cache-primary-key }}
- uses: actions/upload-artifact@v4
with:
name: github-pages
path: ./public
# Deployment job
deploy:
if: github.ref == 'refs/heads/main'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: github-pages
path: ./public
- name: Deploy to GitHub Pages
id: deployment
run: |
cd public
git init -b gh-pages
git remote add origin git+ssh://git@github.com/$GITHUB_REPOSITORY.git
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
git add .
git commit -m "deploy"
- name: Force push to destination branch
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: gh-pages
force: true
directory: ./public/
# PR Preview job
preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: build
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: github-pages
path: ./preview
- name: Extract
run: |
cd preview
tar -xf artifact.tar
- name: Install surge
run: npm install -g surge
- name: Deploy to surge.sh
run: |
export SURGE_DOMAIN="thepotato-tech-pr-${{ github.event.number }}.surge.sh"
surge ./preview --domain $SURGE_DOMAIN --token ${{ secrets.SURGE_TOKEN }}
echo "Preview URL: https://$SURGE_DOMAIN" >> $GITHUB_STEP_SUMMARY
- name: Comment PR
uses: actions/github-script@v7
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const domain = `thepotato-tech-pr-${{ github.event.number }}.surge.sh`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `🚀 Preview deployed to: https://${domain}`
});
-1
View File
@@ -1,4 +1,3 @@
public/ public/
resources/ resources/
pages.git/ pages.git/
.hugo_build.lock
+13
View File
@@ -0,0 +1,13 @@
image: registry.gitlab.com/pages/hugo/hugo_extended:latest
variables:
GIT_SUBMODULE_STRATEGY: recursive
pages:
script:
- hugo
artifacts:
paths:
- public
only:
- main
+3
View File
@@ -0,0 +1,3 @@
[submodule "themes/hugo-sustain"]
path = themes/hugo-sustain
url = https://git.shrug.pw/neil/hugo-sustain
+57
View File
@@ -0,0 +1,57 @@
---
branches: main
variables:
surge_settings: &surge_settings
path: public
surge_token:
from_secret: SURGE_TOKEN
forge_type: gitea
forge_url: https://git.shrug.pw
forge_repo_token:
from_secret: SURGE_GITEA
clone:
git:
image: woodpeckerci/plugin-git
settings:
recursive: true
pipeline:
build:
image: registry.gitlab.com/pages/hugo/hugo_extended:latest
commands:
- hugo
preview:
image: woodpeckerci/plugin-surge-preview
secrets: [SURGE_TOKEN, SURGE_GITEA]
settings: *surge_settings
when:
event: pull_request
teardown-preview:
image: woodpeckerci/plugin-surge-preview
secrets: [SURGE_TOKEN, SURGE_GITEA]
settings: *surge_settings
environment:
- CI_BUILD_EVENT=pull_close
when:
event: push
publish:
image: git.shrug.pw/neil/containers/woodpecker-ci:latest
secrets: [ssh_key]
environment:
- GIT_AUTHOR_NAME="Woody the Woodpecker"
- GIT_AUTHOR_EMAIL=ci@shrug.pw
commands:
- mkdir -p ~/.ssh/
- echo "$SSH_KEY" > ~/.ssh/id_rsa
- chmod 0700 ~/.ssh/
- chmod 0600 ~/.ssh/id_rsa
- bash deploy.sh
when:
branch: main
event: [push]
-192
View File
@@ -1,192 +0,0 @@
# Sample workflow for building and deploying a Hugo site to GitHub Pages
name: Deploy Hugo site to Pages
on:
# Runs on pushes targeting the default branch
push:
branches:
- main
# Runs on pull requests
pull_request:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
issues: write
pull-requests: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
# Default to bash
defaults:
run:
# GitHub-hosted runners automatically enable `set -eo pipefail` for Bash shells.
shell: bash
jobs:
# Build job
build:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.148.2
HUGO_ENVIRONMENT: production
TZ: America/New_York
steps:
- name: Checkout for gh CLI
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Check for existing PR artifact (main branch only)
if: github.ref == 'refs/heads/main'
id: check-pr
run: |
PR_NUMBER=$(gh pr list --state merged --limit 10 --json number,mergeCommit --jq ".[] | select(.mergeCommit.oid == \"${{ github.sha }}\") | .number" | head -1)
if [ -n "$PR_NUMBER" ]; then
echo "Found PR #$PR_NUMBER for this commit"
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "has_pr=true" >> $GITHUB_OUTPUT
else
echo "No PR found, will build normally"
echo "has_pr=false" >> $GITHUB_OUTPUT
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Try to download existing PR artifact
if: github.ref == 'refs/heads/main' && steps.check-pr.outputs.has_pr == 'true'
id: download-existing
uses: dawidd6/action-download-artifact@v3
continue-on-error: true
with:
pr: ${{ steps.check-pr.outputs.pr_number }}
name: github-pages
path: ./public
- name: Check if build is needed
id: build-needed
run: |
if [[ "${{ github.event_name }}" == "pull_request" || "${{ steps.download-existing.outcome }}" == "failure" || "${{ steps.check-pr.outputs.has_pr }}" == "false" ]]; then
echo "need_build=true" >> $GITHUB_OUTPUT
else
echo "need_build=false" >> $GITHUB_OUTPUT
fi
- name: Install Hugo CLI
if: steps.build-needed.outputs.need_build == 'true'
run: |
wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb
sudo dpkg -i ${{ runner.temp }}/hugo.deb
- name: Install Dart Sass
if: steps.build-needed.outputs.need_build == 'true'
run: sudo snap install dart-sass
- name: Setup Pages
if: steps.build-needed.outputs.need_build == 'true'
id: pages
uses: actions/configure-pages@v5
- name: Cache Restore
if: steps.build-needed.outputs.need_build == 'true'
id: cache-restore
uses: actions/cache/restore@v4
with:
path: |
${{ runner.temp }}/hugo_cache
key: hugo-${{ github.run_id }}
restore-keys:
hugo-
- name: Configure Git
if: steps.build-needed.outputs.need_build == 'true'
run: git config core.quotepath false
- name: Build with Hugo
if: steps.build-needed.outputs.need_build == 'true'
run: |
hugo \
--gc \
--minify \
--baseURL "${{ steps.pages.outputs.base_url }}/" \
--cacheDir "${{ runner.temp }}/hugo_cache"
- name: Create CNAME file
if: steps.build-needed.outputs.need_build == 'true'
run: |
cat << EOF > public/CNAME
neilhanlon.me
neilhanlon.com
hanlon.ninja
thepotato.tech
EOF
- name: Cache Save
if: steps.build-needed.outputs.need_build == 'true'
id: cache-save
uses: actions/cache/save@v4
with:
path: |
${{ runner.temp }}/hugo_cache
key: ${{ steps.cache-restore.outputs.cache-primary-key }}
- uses: actions/upload-artifact@v4
with:
name: github-pages
path: ./public
- name: Force push to destination branch
uses: ad-m/github-push-action@latest
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: gh-pages
force: true
directory: ./public/
- name: Upload new artifact
if: steps.build-needed.outputs.need_build == 'true'
run: |
cd public
git init
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
git commit -m "deploy"
# PR Preview job
preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: build
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: github-pages
path: ./preview
- name: Extract site
run: |
cd preview
tar -xf artifact.tar
- name: Install surge
run: npm install -g surge
- name: Deploy to surge.sh
run: |
export SURGE_DOMAIN="thepotato-tech-pr-${{ github.event.number }}.surge.sh"
surge ./preview --domain $SURGE_DOMAIN --token ${{ secrets.SURGE_TOKEN }}
echo "Preview URL: https://$SURGE_DOMAIN" >> $GITHUB_STEP_SUMMARY
- name: Comment PR
uses: actions/github-script@v7
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const domain = `thepotato-tech-pr-${{ github.event.number }}.surge.sh`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `🚀 Preview deployed to: https://${domain}`
});
-98
View File
@@ -1,98 +0,0 @@
/* ==========================================================================
thepotato.tech — Base: reset, elements, typography primitives
========================================================================== */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
html{scroll-behavior:smooth;-webkit-text-size-adjust:100%;}
body{
font-family:var(--font-serif);
font-size:var(--text-lg);
line-height:1.65;
color:var(--text);
background:var(--bg);
-webkit-font-smoothing:antialiased;
-moz-osx-font-smoothing:grayscale;
position:relative;
min-height:100vh;
display:flex;
flex-direction:column;
}
/* Film grain overlay */
body::before{
content:"";position:fixed;inset:0;z-index:9999;pointer-events:none;
background-image:var(--noise);mix-blend-mode:multiply;opacity:.6;
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) body::before{mix-blend-mode:screen;}}
:root[data-theme="dark"] body::before{mix-blend-mode:screen;}
.site-main{flex:1 0 auto;}
h1,h2,h3,h4,h5,h6{
font-family:var(--font-mono);
line-height:1.15;
font-weight:700;
color:var(--text);
letter-spacing:-.01em;
text-wrap:balance;
}
a{color:var(--link);text-decoration:none;transition:color var(--tr);}
a:hover{color:var(--link-hover);}
strong{font-weight:700;color:var(--text);}
em{font-style:italic;}
img{max-width:100%;height:auto;}
::selection{background:var(--sprout);color:#fff;}
:focus-visible{outline:2px solid var(--russet);outline-offset:3px;border-radius:3px;}
.sr-only{
position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
clip:rect(0,0,0,0);white-space:nowrap;border:0;
}
/* Skip link */
.skip-link{
position:absolute;left:-999px;top:0;z-index:10000;
background:var(--russet);color:#fff;padding:var(--s3) var(--s5);
font-family:var(--font-mono);font-size:var(--text-sm);border-radius:0 0 var(--radius) 0;
}
.skip-link:focus{left:0;color:#fff;}
/* --- Layout primitives --- */
.container{max-width:var(--max);margin:0 auto;padding:0 var(--s6);}
.container--text{max-width:var(--max-text);}
.section{padding:var(--s20) 0;}
.section--surface{
background:var(--bg-surface);
border-top:1px solid var(--border);
border-bottom:1px solid var(--border);
}
/* --- Shared mono devices --- */
.eyebrow{
font-family:var(--font-mono);font-size:var(--text-sm);color:var(--sprout);
display:inline-flex;align-items:center;gap:var(--s2);letter-spacing:.02em;
}
.section-label{
font-family:var(--font-mono);font-size:var(--text-sm);color:var(--russet);
letter-spacing:.04em;display:block;margin-bottom:var(--s3);
}
/* The potato "eye" / sprout — leitmotif dot */
.dot{
display:inline-block;width:8px;height:8px;border-radius:50%;
background:var(--dot);flex:0 0 auto;
box-shadow:0 0 0 3px color-mix(in srgb,var(--sprout) 22%,transparent);
}
.dot--pulse{animation:pulse 2.4s ease-in-out infinite;}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.4;}}
@media (prefers-reduced-motion:reduce){
.dot--pulse{animation:none;}
html{scroll-behavior:auto;}
}
-227
View File
@@ -1,227 +0,0 @@
/* ==========================================================================
thepotato.tech — Components: nav, buttons, chips, cards, post rows,
breadcrumb, prose, footer
========================================================================== */
/* --- Navbar --- */
.navbar{height:var(--nav-h);display:flex;align-items:center;border-bottom:1px solid var(--border);}
.navbar__inner{
max-width:var(--max);margin:0 auto;width:100%;padding:0 var(--s6);
display:flex;align-items:center;justify-content:space-between;gap:var(--s4);
}
.navbar__logo{
display:flex;align-items:center;gap:var(--s3);
font-family:var(--font-mono);font-weight:700;color:var(--text);font-size:var(--text-lg);
}
.navbar__logo .spud{width:1.5rem;height:1.5rem;display:block;}
.navbar__logo .shrug{color:var(--text-faint);font-weight:400;font-size:var(--text-sm);}
.navbar__logo:hover{color:var(--russet);}
.navbar__links{
display:flex;align-items:center;gap:var(--s6);list-style:none;
font-family:var(--font-mono);font-size:var(--text-sm);
}
.navbar__links a{color:var(--text-muted);}
.navbar__links a:hover,.navbar__links a[aria-current="page"]{color:var(--russet);}
.navbar__hamburger{display:none;background:none;border:0;color:var(--text);cursor:pointer;padding:var(--s2);}
.theme-toggle{
background:var(--bg-elevated);border:1px solid var(--border-strong);
color:var(--text);width:34px;height:34px;border-radius:999px;cursor:pointer;
display:inline-flex;align-items:center;justify-content:center;transition:border-color var(--tr);
}
.theme-toggle:hover{border-color:var(--russet);}
.theme-toggle .icon-moon{display:none;}
:root[data-theme="dark"] .theme-toggle .icon-sun{display:none;}
:root[data-theme="dark"] .theme-toggle .icon-moon{display:inline;}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]) .theme-toggle .icon-sun{display:none;}
:root:not([data-theme="light"]) .theme-toggle .icon-moon{display:inline;}
}
/* --- Buttons --- */
.btn{
font-family:var(--font-mono);font-size:var(--text-sm);font-weight:700;
padding:.7rem 1.3rem;border-radius:999px;transition:all var(--tr);
border:1px solid transparent;display:inline-flex;align-items:center;gap:var(--s2);cursor:pointer;
}
.btn--primary{background:var(--russet);color:#fff;}
.btn--primary:hover{background:var(--russet-deep);color:#fff;transform:translateY(-1px);}
.btn--ghost{border-color:var(--border-strong);color:var(--text);}
.btn--ghost:hover{border-color:var(--russet);color:var(--russet);}
/* --- Chips --- */
.chip{
display:inline-block;font-family:var(--font-mono);font-size:var(--text-xs);
letter-spacing:.06em;text-transform:uppercase;padding:.15rem .5rem;border-radius:999px;
}
.chip--draft{color:var(--violet);border:1px solid color-mix(in srgb,var(--violet) 45%,transparent);}
.chip--tag{color:var(--text-muted);border:1px solid var(--border-strong);}
/* --- Section header --- */
.section-head{margin-bottom:var(--s10);}
.section-head h2{font-size:var(--text-3xl);}
.section-head p{color:var(--text-muted);max-width:var(--max-text);margin-top:var(--s3);}
/* --- Post list rows --- */
.posts{display:flex;flex-direction:column;}
.post-row{
display:grid;grid-template-columns:130px 1fr;gap:var(--s6);
padding:var(--s6) 0;border-top:1px solid var(--border);align-items:baseline;
}
.posts .post-row:last-child{border-bottom:1px solid var(--border);}
.post-row__meta{
font-family:var(--font-mono);font-size:var(--text-sm);color:var(--text-faint);
display:flex;flex-direction:column;gap:var(--s2);
}
.post-row__title{
font-family:var(--font-mono);font-size:var(--text-xl);font-weight:700;color:var(--text);
margin-bottom:var(--s2);transition:color var(--tr);text-wrap:balance;
}
.post-row:hover .post-row__title{color:var(--russet);}
.post-row__dek{color:var(--text-muted);font-size:var(--text-base);max-width:60ch;}
/* --- Cards (projects / now) --- */
.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:var(--s6);}
.card{
border:1px solid var(--border);border-radius:var(--radius-lg);
padding:var(--s6);background:var(--bg-elevated);transition:border-color var(--tr),transform var(--tr);
}
.card:hover{border-color:var(--border-strong);transform:translateY(-2px);}
.card__title{font-size:var(--text-lg);margin-bottom:var(--s2);display:flex;align-items:center;gap:var(--s3);}
.card__role{font-family:var(--font-mono);font-size:var(--text-sm);color:var(--sprout);margin-bottom:var(--s4);}
.card__when{color:var(--text-faint);}
.card__body{color:var(--text-muted);font-size:var(--text-base);}
.card__link{margin-top:var(--s3);}
.card__body a{text-decoration:underline;text-underline-offset:2px;}
.card--past{background:transparent;border-style:dashed;}
.card--past .dot{background:var(--text-faint);box-shadow:none;}
.card--past .card__role{color:var(--text-faint);}
.card--cta{background:transparent;border-style:dashed;border-color:var(--border-strong);display:flex;flex-direction:column;justify-content:center;}
.card--cta:hover{border-color:var(--russet);transform:none;}
.card--cta .card__title{color:var(--russet);}
.card--cta .card__link{color:var(--russet);font-family:var(--font-mono);font-size:var(--text-sm);}
/* --- Breadcrumb --- */
.breadcrumb{
font-family:var(--font-mono);font-size:var(--text-sm);color:var(--text-faint);
margin-bottom:var(--s6);display:flex;gap:var(--s2);align-items:center;flex-wrap:wrap;
}
.breadcrumb a{color:var(--text-muted);}
.breadcrumb a:hover{color:var(--russet);}
/* --- Prose (post body) --- */
.prose{max-width:var(--max-text);margin:0 auto;}
.prose>*+*{margin-top:var(--s6);}
.prose p{color:var(--text);}
.prose h2{font-size:var(--text-2xl);margin-top:var(--s12);}
.prose h2::before{content:"// ";color:var(--russet);}
.prose h3{font-size:var(--text-xl);margin-top:var(--s10);}
.prose a{text-decoration:underline;text-decoration-color:color-mix(in srgb,var(--russet) 45%,transparent);text-underline-offset:3px;}
.prose a:hover{text-decoration-color:var(--russet);}
.prose ul,.prose ol{padding-left:1.4rem;}
.prose li{margin-bottom:var(--s2);}
.prose li::marker{color:var(--sprout);}
.prose blockquote{
border-left:3px solid var(--sprout);padding:var(--s2) 0 var(--s2) var(--s5);
color:var(--text-muted);font-style:italic;
}
.prose img{border-radius:var(--radius-lg);border:1px solid var(--border);margin:var(--s4) 0;}
.prose figure{margin:var(--s6) 0;}
.prose figcaption{font-family:var(--font-mono);font-size:var(--text-sm);color:var(--text-faint);margin-top:var(--s2);text-align:center;}
.prose code{
font-family:var(--font-mono);font-size:.9em;background:var(--bg-surface);
border:1px solid var(--border);border-radius:5px;padding:.1em .4em;
}
.prose pre{
font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-soil);
color:var(--text-on-soil);padding:var(--s5);border-radius:var(--radius-lg);
overflow-x:auto;line-height:1.55;
}
.prose pre code{background:none;border:0;padding:0;color:inherit;font-size:inherit;}
.prose hr{border:0;border-top:1px solid var(--border);margin:var(--s10) 0;}
.prose table{width:100%;border-collapse:collapse;font-size:var(--text-base);}
.prose th,.prose td{border:1px solid var(--border);padding:var(--s2) var(--s3);text-align:left;}
.prose th{font-family:var(--font-mono);background:var(--bg-surface);}
/* --- Post footer --- */
.post-foot{
max-width:var(--max-text);margin:var(--s16) auto 0;padding-top:var(--s6);
border-top:1px solid var(--border);font-family:var(--font-mono);font-size:var(--text-sm);
color:var(--text-muted);display:flex;justify-content:space-between;gap:var(--s4);flex-wrap:wrap;
}
/* --- Footer --- */
.footer{border-top:1px solid var(--border);padding:var(--s12) 0;background:var(--bg-surface);}
.footer__inner{
max-width:var(--max);margin:0 auto;padding:0 var(--s6);
display:flex;justify-content:space-between;gap:var(--s6);flex-wrap:wrap;align-items:flex-start;
}
.footer__note{font-family:var(--font-mono);font-size:var(--text-sm);color:var(--text-faint);max-width:46ch;line-height:1.7;}
.footer__links{display:flex;gap:var(--s5);font-family:var(--font-mono);font-size:var(--text-sm);flex-wrap:wrap;}
.footer__links a{color:var(--text-muted);}
.footer__links a:hover{color:var(--russet);}
@media (max-width:720px){
.post-row{grid-template-columns:1fr;gap:var(--s2);}
.navbar__hamburger{display:inline-flex;}
.navbar__logo .shrug{display:none;}
.navbar__links{
position:absolute;top:var(--nav-h);left:0;right:0;flex-direction:column;align-items:flex-start;
gap:var(--s4);padding:var(--s5) var(--s6);background:var(--bg-elevated);
border-bottom:1px solid var(--border);box-shadow:var(--shadow-md);
display:none;z-index:150;
}
.navbar__links.is-open{display:flex;}
}
/* ── Résumé page ─────────────────────────────────────────────────── */
.resume__grid{
display:grid;grid-template-columns:minmax(0,15rem) minmax(0,1fr);
gap:var(--s12);align-items:start;
}
.resume__aside{
position:sticky;top:calc(var(--nav-h) + var(--s6));
display:flex;flex-direction:column;gap:var(--s8);
}
.resume__block .section-label{display:block;margin-bottom:var(--s3);}
.resume__list{
list-style:none;display:flex;flex-direction:column;gap:var(--s1);
font-size:var(--text-sm);color:var(--text-muted);line-height:1.4;
}
.resume__list a:hover{color:var(--russet);}
.resume__list--skills li{position:relative;padding-left:var(--s4);}
.resume__list--skills li::before{
content:"·";position:absolute;left:0;color:var(--russet);font-weight:700;
}
.resume__heading{display:inline-block;margin-bottom:var(--s5);}
.resume__main .resume__entries + .resume__heading{margin-top:var(--s12);}
.resume-entry{
padding-bottom:var(--s6);margin-bottom:var(--s6);
border-bottom:1px solid var(--border);
}
.resume-entry:last-child{border-bottom:none;margin-bottom:0;}
.resume-entry__head{
display:flex;justify-content:space-between;align-items:baseline;
gap:var(--s4);flex-wrap:wrap;
}
.resume-entry__role{font-size:var(--text-lg);font-weight:700;line-height:1.2;}
.resume-entry__period{
font-family:var(--font-mono);font-size:var(--text-sm);
color:var(--text-faint);white-space:nowrap;
}
.resume-entry__org{color:var(--russet);font-weight:600;margin-top:var(--s1);}
.resume-entry__org a:hover{text-decoration:underline;}
.resume-entry__loc{color:var(--text-faint);font-weight:400;}
.resume-entry__detail{color:var(--text-muted);font-weight:400;font-size:var(--text-sm);}
.resume-entry__highlights{
margin-top:var(--s3);padding-left:var(--s5);
display:flex;flex-direction:column;gap:var(--s2);
color:var(--text-muted);font-size:var(--text-sm);line-height:1.5;
}
.resume-entry__highlights li{list-style:disc;}
.resume-entry__highlights strong{color:var(--text);font-weight:700;}
@media (max-width:720px){
.resume__grid{grid-template-columns:1fr;gap:var(--s8);}
.resume__aside{position:static;}
}
-51
View File
@@ -1,51 +0,0 @@
/* ==========================================================================
thepotato.tech — Layouts: hero, home sections, page hero, soil strip
========================================================================== */
/* --- Home hero --- */
.hero{padding:var(--s24) 0 var(--s16);}
.hero__eyebrow{margin-bottom:var(--s5);}
.hero__title{font-size:clamp(2.6rem,7vw,var(--text-6xl));line-height:1.05;margin-bottom:var(--s6);}
.hero .grad{
background:linear-gradient(102deg,var(--russet),var(--sprout-bright));
-webkit-background-clip:text;background-clip:text;color:transparent;
}
.hero__lede{font-size:var(--text-xl);max-width:var(--max-text);color:var(--text-muted);}
.hero__lede p{margin-bottom:var(--s4);}
.hero__lede strong{color:var(--text);}
.hero__lede a{text-decoration:underline;text-underline-offset:3px;text-decoration-color:color-mix(in srgb,var(--russet) 45%,transparent);}
.actions{display:flex;gap:var(--s4);flex-wrap:wrap;align-items:center;margin-top:var(--s8);}
.social-inline{
display:flex;gap:var(--s5);margin-top:var(--s10);
font-family:var(--font-mono);font-size:var(--text-sm);flex-wrap:wrap;
}
.social-inline a{color:var(--text-muted);display:inline-flex;gap:var(--s2);align-items:center;}
.social-inline a:hover{color:var(--sprout);}
/* --- "Now" / soil strip --- */
.soil{background:var(--bg-soil);color:var(--text-on-soil);}
.soil h1,.soil h2,.soil h3{color:var(--text-on-soil);}
.soil .section-label{color:var(--sprout-bright);}
.soil p{color:var(--text-on-soil-muted);}
.now-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:var(--s6);}
.now-card{border:1px solid rgba(255,255,255,.09);border-radius:var(--radius-lg);padding:var(--s6);background:rgba(255,255,255,.02);}
.now-card h3{font-size:var(--text-base);margin-bottom:var(--s2);display:flex;align-items:center;gap:var(--s2);}
.now-card p{font-size:var(--text-sm);}
.now-card .k{font-family:var(--font-mono);color:var(--sprout-bright);}
/* --- Page hero (single / list) --- */
.page-hero{padding:var(--s16) 0 var(--s10);border-bottom:1px solid var(--border);}
.page-hero__eyebrow{margin-bottom:var(--s5);}
.page-hero__title{font-size:clamp(2rem,5vw,var(--text-5xl));line-height:1.08;margin:var(--s4) 0 var(--s5);}
.page-hero__subtitle{font-size:var(--text-xl);color:var(--text-muted);max-width:var(--max-text);}
.page-hero__meta{
font-family:var(--font-mono);font-size:var(--text-sm);color:var(--text-muted);
display:flex;gap:var(--s4);flex-wrap:wrap;align-items:center;margin-top:var(--s4);
}
@media (max-width:640px){
.hero{padding:var(--s16) 0 var(--s12);}
.section{padding:var(--s16) 0;}
}
-122
View File
@@ -1,122 +0,0 @@
/* ==========================================================================
thepotato.tech — Design Tokens
Forked from the shrug.host editorial system, recolored to a potato palette:
russet skin, sprout-green, a rare purple-potato pop. Potato-flesh light,
soil dark. Light mode is the default; dark overrides via [data-theme="dark"]
and prefers-color-scheme.
========================================================================== */
/* --- Self-hosted fonts (no external requests) --- */
@font-face{
font-family:'Source Serif 4';
src:url('/fonts/SourceSerif4.woff2') format('woff2');
font-weight:200 900; font-style:normal; font-display:swap;
}
@font-face{
font-family:'Source Serif 4';
src:url('/fonts/SourceSerif4-Italic.woff2') format('woff2');
font-weight:200 900; font-style:italic; font-display:swap;
}
@font-face{
font-family:'Commit Mono';
src:url('/fonts/CommitMono-400-Regular.woff2') format('woff2');
font-weight:400; font-style:normal; font-display:swap;
}
@font-face{
font-family:'Commit Mono';
src:url('/fonts/CommitMono-400-Italic.woff2') format('woff2');
font-weight:400; font-style:italic; font-display:swap;
}
@font-face{
font-family:'Commit Mono';
src:url('/fonts/CommitMono-700-Regular.woff2') format('woff2');
font-weight:700; font-style:normal; font-display:swap;
}
@font-face{
font-family:'Commit Mono';
src:url('/fonts/CommitMono-700-Italic.woff2') format('woff2');
font-weight:700; font-style:italic; font-display:swap;
}
/* --- Light mode tokens (default) --- */
:root{
/* Potato palette */
--russet:#8a5622; /* potato skin — links, CTA */
--russet-deep:#6f4318;
--sprout:#697a2b; /* light-struck / sprouting green — leitmotif dot */
--sprout-bright:#7d9134;
--violet:#6a4a86; /* purple-potato — rare pop (draft chips) */
/* Backgrounds */
--bg:#efe7d6; /* potato-flesh paper */
--bg-surface:#e7dcc5;
--bg-elevated:#f6efe1;
--bg-soil:#241d12; /* dark inset sections */
/* Text */
--text:#241d12; /* soil */
--text-muted:#6d5f45;
--text-faint:#93856a;
--text-on-soil:#f2ead8;
--text-on-soil-muted:#c3b591;
/* Borders */
--border:#d8cbac;
--border-strong:#c4b492;
/* Semantic */
--link:var(--russet);
--link-hover:var(--russet-deep);
--dot:var(--sprout);
/* Shadows */
--shadow-sm:0 1px 2px rgba(60,42,16,.10);
--shadow-md:0 6px 20px rgba(60,42,16,.10),0 2px 6px rgba(60,42,16,.06);
/* Typography */
--font-mono:'Commit Mono','JetBrains Mono',ui-monospace,monospace;
--font-serif:'Source Serif 4',Georgia,ui-serif,serif;
--text-xs:.75rem; --text-sm:.875rem; --text-base:1rem; --text-lg:1.125rem;
--text-xl:1.25rem; --text-2xl:1.5rem; --text-3xl:1.875rem; --text-4xl:2.25rem;
--text-5xl:3rem; --text-6xl:3.9rem;
/* Spacing */
--s1:.25rem; --s2:.5rem; --s3:.75rem; --s4:1rem; --s5:1.25rem; --s6:1.5rem;
--s8:2rem; --s10:2.5rem; --s12:3rem; --s16:4rem; --s20:5rem; --s24:6rem;
/* Layout */
--max:1080px; --max-text:680px;
--radius:8px; --radius-lg:14px;
--nav-h:60px;
--tr:.18s ease;
/* Film-grain overlay */
--noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
}
/* --- Dark mode: soil at night --- */
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--russet:#c8823f; --russet-deep:#dd9a57;
--sprout:#9fb653; --sprout-bright:#b6cf63; --violet:#b192d6;
--bg:#17130d; --bg-surface:#201a11; --bg-elevated:#2a2216; --bg-soil:#0e0b07;
--text:#efe6d4; --text-muted:#b6a884; --text-faint:#8a7c60;
--text-on-soil:#efe6d4; --text-on-soil-muted:#b6a884;
--border:#33291b; --border-strong:#463a26;
--shadow-sm:0 1px 2px rgba(0,0,0,.4);
--shadow-md:0 6px 20px rgba(0,0,0,.5),0 2px 6px rgba(0,0,0,.3);
--noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E");
}
}
:root[data-theme="dark"]{
--russet:#c8823f; --russet-deep:#dd9a57;
--sprout:#9fb653; --sprout-bright:#b6cf63; --violet:#b192d6;
--bg:#17130d; --bg-surface:#201a11; --bg-elevated:#2a2216; --bg-soil:#0e0b07;
--text:#efe6d4; --text-muted:#b6a884; --text-faint:#8a7c60;
--text-on-soil:#efe6d4; --text-on-soil-muted:#b6a884;
--border:#33291b; --border-strong:#463a26;
--shadow-sm:0 1px 2px rgba(0,0,0,.4);
--shadow-md:0 6px 20px rgba(0,0,0,.5),0 2px 6px rgba(0,0,0,.3);
--noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E");
}
+18
View File
@@ -0,0 +1,18 @@
$primary-color: #ABC9E3;
$hover-color: #5E7191;
html, body {
border-top-color: $primary-color;
}
div.footer {
border-bottom-color: $primary-color;
}
.container a {
color: $primary-color;
}
.container a:hover {
color: $hover-color;
}
.panel-default h4 a, h5 a {
color: $primary-color;
}
Executable
+52
View File
@@ -0,0 +1,52 @@
#baseURL = "https://drop1.neilhanlon.me:1313/"
baseURL = "/"
languageCode = "en-US"
title = "Neil Hanlon"
# Enable comments by entering your Disqus shortname
disqusShortname = ""
# Enable Google Analytics by entering your tracking code
googleAnalytics = ""
theme = "hugo-sustain"
[permalinks]
post = "/:year/:month/:day/:slug"
[params]
avatar = "img/profile.png"
author = "Neil Hanlon"
description = "Neil Hanlon - Senior Network/DevOps/Infrastructure Engineer"
# Custom assets can be linked with their paths relative to static/
custom_css = []
custom_scss = ['sass/overrides.scss']
custom_js = []
[params.social]
Github = "NeilHanlon"
Email = "neil@shrug.pw.com"
Twitter = "NeilHanlon"
LinkedIn = "hanlonneil"
#Stackoverflow = "username"
#Medium = "hanlon.neil"
Instagram = "noellathekitty"
[params.volunteer]
Rocky = "https://rockylinux.org"
## Main Menu
[[menu.main]]
name = "posts"
weight = 100
identifier = "posts"
url = "/posts/"
[[menu.main]]
name = "projects"
identifier = "projects"
weight = 200
url = "/projects/"
[[menu.main]]
name = "resume"
identifier = "resume"
weight = 300
url = "/resume.pdf"
+4 -7
View File
@@ -1,23 +1,20 @@
--- ---
--- ---
I'm **Neil Hanlon**, an Infrastructure, Network, Systems, and Potato Expert I'm **Neil Hanlon**, a DevOps/Network/Potato Engineer
I love Open Source, Linux, and building cool shit with great people. I love Open Source, Linux, and building cool things with great people.
Feel free to take a peek at some of my [projects], or check out my cat's [Instagram]. Feel free to take a peek at some of my [projects], or check out my cat's [Instagram].
For more check out my academic & professional [resume]. For more check out my academic & professional [resume].
I'm also available for [consulting]. Contact me at [@NeilHanlon] or by yelling really really loud.
Contact me at [@shrug.pw] or by yelling really really loud.
[projects]: /projects [projects]: /projects
[resume]: https://shrug.pw/resume.pdf [resume]: https://shrug.pw/resume.pdf
[@shrug.pw]: https://bsky.app/profile/shrug.pw [@NeilHanlon]: https://twitter.com/NeilHanlon
[instagram]: https://instagram.com/noellathekitty [instagram]: https://instagram.com/noellathekitty
[consulting]: https://shrugpw.com
-140
View File
@@ -1,140 +0,0 @@
---
title: "I Put My Son in a Swamp"
description: "I modeled my newborn as a typed resource with a sync method and a stack of reports. It's the newest of five ways I track one baby, and somewhere in the absurdity there's a real reason two exhausted people instrument everything."
date: 2026-07-11T00:45:00-04:00
draft: false
categories: ['automation', 'homelab']
tags: ['swamp', 'babybuddy', 'parenting', 'home-assistant', 'mcp', 'pebble', 'devops']
---
My son is a typed resource now. He has a `sync` method that pulls the last seven
days of his life into a versioned snapshot, a stack of reports that summarize how
he slept and ate, and — I did think about this — a delete that's careful to be
idempotent, because deleting the same diaper twice shouldn't be an error.
I did not set out to put a baby in a swamp. It followed, the way these things do,
from having already put everything else in one.
## What "in a swamp" means
[swamp](https://github.com/swamp-club/swamp) models real resources as typed
objects with methods, and records every run as versioned data other models can
read. You write extensions in TypeScript. I used it [last week to automate my home
certificate authority]({{< ref "the-wifi-cert-that-didnt-exist-an-hour-ago" >}}),
and the whole time I kept thinking about the thing I actually spend all night
dealing with, which is not a certificate.
We run [Baby Buddy](https://github.com/babybuddy/babybuddy) at home — a small,
self-hosted app for tracking a newborn's feedings, diapers, sleep, pumping, meds.
It has a clean REST API. A clean REST API, at 2am, to a certain kind of brain, is
a dare.
So I wrote `@kneel/babybuddy`: one model that reads the whole instance into a
snapshot and writes new entries back, with a stack of reports that run over the
snapshot. Log a feeding, fix a mis-logged one, patch a bad timestamp, then ask it
questions.
## The reports tell on you
Summarizing a week of a newborn produces numbers that are, on their own, a little
unhinged. In the last seven days: ninety-six feedings. Sixty-five separate sleep
sessions. His longest unbroken stretch was four hours and forty-six minutes,
which I know to the minute because there's a report called `sleep-longest-stretch`
and I run it the way other people check the weather.
The one that got me was the medication report. I built it to answer "are we giving
the vitamin D on schedule," and it answered a question I hadn't asked, which is
"when does Neil actually remember the vitamin D." Not on schedule, it turns out.
It's a little cluster of timestamps at midnight, 2am, 3am — whenever I surfaced
enough to think of it. The schedule in my head and the schedule in the database
were not the same schedule, and only one of them keeps records.
That's the actual value of instrumenting anything: some night the dashboard hands
you back a picture of yourself you weren't planning to volunteer.
## This isn't the first interface. It's the fifth.
Here's the genuinely stupid part, and I say that with affection for the man who
did it: the swamp extension is the *newest* way I have to track this kid, not the
only one, and not by a wide margin.
There's a **mobile app** — a Capacitor wrapper around Baby Buddy with the sharp
edges filed down. Biometric lock, so a phone left on the couch doesn't show the
whole night's log. Log-a-diaper you can say out loud, because your hands are full,
because your hands are always full. A running feed timer on the lock screen next
to the elapsed time you're trying not to look at. iOS and Android, because of
course iOS and Android.
There's a **Pebble watchapp**, because the best possible interface for logging a
feed at 3am is a physical button on your wrist that emits about four photons.
Start a timer, stop it, pick the amount, done, without the wake-your-whole-nervous-
system glare of a phone. I'll admit the watchapp is currently ahead of my
hardware — I don't have the watch yet. There's a ring interface too. I don't have
the ring yet either. I wrote software for two devices I'm still waiting on, which
tells you roughly everything about the state of mind here.
There's a **conversational server** — an MCP that lets me, or Claude, or the voice
assistant it hangs off of, just *say* what happened. "He ate ninety at eleven." It
writes the same feeding record the app would, the same one the watch would, the
same one the swamp model syncs back out.
And there's **Home Assistant**, the layer that nags. At 10:30 it reminds whoever's
still upright to make the overnight bottle. At 11 it reminds us the vitamin D goes
in it. If nobody acknowledges, it asks again. It's a very polite robot with a
clipboard, and right now it has a better memory than either of us.
None of these sit on a stock Baby Buddy, either — the one underneath isn't stock
anymore. Somewhere in here I ended up bolting push notifications onto it via FCM,
adding per-record audit fields so I can see who logged what, soft-deleting timers
so a fat-fingered stop doesn't just vanish, and writing a handful of new reports.
"Track the baby" quietly became "re-platform the baby tracker," which is a
sentence I'd be embarrassed by if I'd slept.
Five interfaces. One baby. Every one of them a different door into the same little
REST API.
## Why though
You could read all of this as a man building toys instead of sleeping and you
wouldn't be all the way wrong. But there's a real thing underneath it, and it's
the least technical part of the post.
A newborn is a 24-hour operation run by two people getting maybe five hours each,
in shifts. The hardest part isn't any single feeding. It's the handoff — the 1am
moment where one of us goes horizontal and the other picks up the watch, and that
only works if we're both looking at the same set of facts. When did he last eat.
How much. Is he due. Did the vitamin D happen or did I dream it.
All the tracking — app, watch, voice, nags, swamp — isn't quantified-self stuff.
It's a shared source of truth for two very tired adults trying to hold one
continuous story between them across a shift change, in the dark, without waking
each other up to ask. The baby is just where the data comes from.
## The bit worth keeping
If one engineering lesson survived contact with a real newborn, it's the same one
from the [certificate post]({{< ref "the-wifi-cert-that-didnt-exist-an-hour-ago" >}})
wearing a smaller hat: data that claims to be complete when it isn't is worse than
no data.
When I wrote the `sync` method it capped each query at some number of records. A
sensible default — until you picture a parent syncing a long window, quietly
dropping the oldest entries, and getting a summary that looks whole. So the
snapshot carries a `truncated` flag now, an honest "there was more than this," and
the summary says so out loud instead of lying with a tidy little table.
Same instinct is why the timers soft-delete instead of actually deleting, and why
stop-timer won't silently throw away a running one. Two parents, two devices, one
baby: someone is going to stop the wrong thing or delete the same thing twice. The
tool's job is to make that boring, not to punish you for the race condition of
being a family.
He's asleep right now — nineteen minutes in, per the timer I am absolutely going
to stop checking, because checking it wakes the report, which wakes me. The
extension's open source on the swamp registry if you also run Baby Buddy and also
cannot leave a working API alone.
---
I point these same instincts at other people's infrastructure through
[Shrug PW](https://shrugpw.com). The stakes are higher and the sleep is better.
@@ -1,57 +0,0 @@
---
title: 'Joining ResolveX as a Technical Advisor: Infrastructure and Crisis Response'
description: "I've joined ResolveX as a technical advisor to help scale their crisis response software. Working on infrastructure, security, and scaling decisions for 988 suicide prevention lines and mental health crisis centers."
date: 2025-08-08T10:17:20-04:00
draft: false
categories: ['consulting', 'social-impact']
tags: ['crisis-response', 'mental-health', 'infrastructure', 'advisory', 'resolvex', '988']
---
I'm excited to share that I've joined [ResolveX](https://resolvex.com) as a technical advisor. ResolveX
builds software and data solutions for crisis call centers—988 suicide
prevention lines, domestic violence hotlines, and other critical mental health
resources.
## Why This Matters
Mental health crisis response has significant gaps that technology can help address. ResolveX builds tools to make crisis counselors more effective and help researchers understand patterns that could prevent crises.
They serve 988 operators—resource-constrained nonprofits handling life-or-death situations. The technical requirements are unique: rock-solid reliability, security for sensitive mental health data, and cost-effectiveness for organizations that measure impact in lives saved.
## The Technical Challenge
ResolveX is hitting the classic startup infrastructure inflection point—scaling from scrappy solutions to supporting a primary mental health data warehouse without over-engineering.
My role: provide neutral perspective on infrastructure, security, and scaling decisions. Lightweight advisory rather than hands-on implementation, helping spot issues early and suggest practical solutions within their budget and mission.
## What I'm Doing
The immediate priorities are pretty straightforward:
- **Infrastructure & Security Audit**: Review current hosting, architecture, and configurations. Light security testing and access review.
- **Ongoing Advisory**: Lightweight consultation (~5 hours/week) for feedback and neutral oversight on scaling decisions.
- **Content Collaboration**: Educational content that benefits both crisis response and broader tech communities.
## The Bigger Picture
This isn't just consulting work—I've been getting into emergency response volunteering (CERT, amateur radio) and care deeply about how we handle mental health crises. ResolveX has potential to become an open-source-friendly, public-impact organization changing mental health data collection and analysis.
## Technical Philosophy
The constraints make this engaging: high reliability and security requirements, minimal budget, maximum impact. Every architectural decision must justify real value to crisis counselors and those they help.
This requires "appropriate technology"—sophisticated enough for requirements but simple enough to maintain and afford. Systems engineering with purpose, not hope-based engineering.
## Looking Forward
I'll write follow-ups about specific technical challenges as this develops. Infrastructure engineering for social impact deserves more attention.
If you're working on mission-driven technology or considering how your skills could have broader social impact, reach out. **There's more of this work that needs doing.**
---
If you're interested in similar advisory or consulting work, you can reach me
through [my consulting practice](https://shrugpw.com) or find me on the usual places around the
internet.
@@ -1,10 +1,10 @@
--- ---
title: 'Smarthome Soldering Iron with Home Assistant and ESPHome (also: web GUIs for Pinecil)' title: 'Smarthome Soldering Iron with Home Assistant and ESPHome'
description: 'Learn how to create a smart soldering iron using Pinecil with Home Assistant and ESPHome, as well as explore the options for controlling and monitoring your Pinecil with Bluetooth' description: 'Learn how to create a smart soldering iron using Pinecil with Home Assistant and ESPHome.'
date: 2023-03-28T19:01:25-04:00 date: 2023-03-28T19:01:25-04:00
draft: false draft: false
categories: [Home Automation, DIY, FOSS] categories: [Home Automation, DIY]
tags: [Pinecil, Home Assistant, DIY, RISC-V, Blisp, PineSAM, Pine64, esphome] tags: [Pinecil, Home Assistant, DIY, RISC-V, Blisp]
--- ---
About a year ago, [wesdottoday](https://hachyderm.io/@wesdottoday) told me to About a year ago, [wesdottoday](https://hachyderm.io/@wesdottoday) told me to
@@ -15,14 +15,9 @@ Pinecil's firmware (IronOS), and am spending my weekends playing around with
Bluetooth Low Energy (BLE) and Home Assistant to automatically turn on my fan Bluetooth Low Energy (BLE) and Home Assistant to automatically turn on my fan
when I start tinkering at my desk, lest my lungs die from the fumes. when I start tinkering at my desk, lest my lungs die from the fumes.
<p>
{{< figure src="pinecil-esp32.jpg" alt="ESP-WROOM-32 Development MCU with a Pinecil v2 leaning on it" class="inline-40 left" >}}
By popular demand, I'm writing a blog post about the Pinecil, how to flash the By popular demand, I'm writing a blog post about the Pinecil, how to flash the
latest firmware, and then what you can (currently) do with bidirectional latest firmware, and then what you can (currently) do with bidirectional
communication to your soldering iron. communication to your soldering iron.
</p>
We'll also go over two Pinecil community projects offering in-browser We'll also go over two Pinecil community projects offering in-browser
experiences (PineSam and Joric's 'Pinecil'), and lastly some instructions on experiences (PineSam and Joric's 'Pinecil'), and lastly some instructions on
@@ -32,16 +27,6 @@ using an [ESP32](https://www.espressif.com/en/products/socs/esp32) and
visualize your soldering statistics, to automatically turn on an exhaust fan visualize your soldering statistics, to automatically turn on an exhaust fan
when you start working. when you start working.
### Acknowledgements
- [Ben Brown (Ralim)](https://github.com/ralim) - Maintainer and developer of IronOS for Pinecil and Miniware Irons
- Support them on [Ko-fi](https://ko-fi.com/ralim)
- [Gamiee (Gamelaster)](https://github.com/gamelaster) - Developer and maintainer of pine64_updater, IronOS
- [River-Mochi](https://github.com/river-mochi) for reviewing this article and their excellent organization and documentation work for Pine64 and the Pinecil.
- [TomW1605](https://github.com/TomW1605) & [ithinkido](https://github.com/ithinkido) for their work on the implementation of the Pinecil ESP32 integration
- [Builder555](https://github.com/builder555) - PineSAM maintainer and developer
- [Joric](https://github.com/joric) - Developer of original BLE GATT application for Pinecil
## The Pinecil ## The Pinecil
The [Pinecil](https://www.pine64.org/pinecil/) is an open-source soldering iron The [Pinecil](https://www.pine64.org/pinecil/) is an open-source soldering iron
@@ -67,8 +52,6 @@ a genuine Pinecil, and how to avoid fakes.
tricks, as well as up to date purchasing and troubleshooting information. tricks, as well as up to date purchasing and troubleshooting information.
- Shout out to River-Mochi for their _awesome_ work on keeping this up to date - Shout out to River-Mochi for their _awesome_ work on keeping this up to date
and useful and useful
- [Pine64 Updater](https://github.com/pine64/pine64_updater) - v1 Pinecil updater tool
- [Pinecil Development Projects](https://wiki.pine64.org/wiki/Pinecil#Development_Projects) - Updated links to all community projects
## Blisp ## Blisp
@@ -91,8 +74,6 @@ and development on the BLE stack on the main tree in the last few months and the
developers have been making sure the BLE features are ready before they are developers have been making sure the BLE features are ready before they are
released to a much larger audience. released to a much larger audience.
{{< figure src="blisp-flash.png" alt="Flashing the Pinecilv2 with blisp CLI" class="" >}}
Once v2.21 is released, binaries can be retrieved from the Once v2.21 is released, binaries can be retrieved from the
[IronOS Releases](https://github.com/Ralim/IronOS/releases) page. [IronOS Releases](https://github.com/Ralim/IronOS/releases) page.
@@ -102,42 +83,27 @@ These steps assume you have a compiled version of `blisp` in your system path,
either by installing from my COPR, or compiling on your own using the either by installing from my COPR, or compiling on your own using the
instructions in the repository. instructions in the repository.
1. Find the latest sucessful actions run on the ironos repo 1. Find the latest sucessful actions run on the blisp repo
[here](https://github.com/Ralim/IronOS/actions/workflows/push.yml?query=branch%3Adev+event%3Apush). [here](https://github.com/Ralim/IronOS/actions/workflows/push.yml?query=branch%3Adev+event%3Apush).
2. Download the `Pinecilv2` zip file for that run, and unzip it 2. Download the `Pinecilv2_multi-lang` binary for that run, and unzip it
3. Plug your Pinecil into your computer while holding down the 'Minus' (-) 3. Plug your Pinecil into your computer while holding down the 'Minus' (-)
button. The screen should **not** turn on. If on Linux, `dmesg` should report button. The screen should **not** turn on. If on Linux, `dmesg` should report
seeing the BL706 as a serial device. seeing the BL706 as a serial device.
4. Run the following command to flash the firmware to your Pinecil: 4. Run the following command to flash the firmware to your Pinecil:
``` ```
blisp write -c bl70x --reset /path/to/Pinecilv2_extracted/Pinecilv2_EN.bin blisp write -c bl70x --reset /path/to/Pinecilv2_multi-lang/Pinecilv2_EN.bin
``` ```
5. Un-plug and plug the Pinecil back in to boot the new firmware 5. Un-plug and plug the Pinecil back in to boot the new firmware
NOTE: You may ignore the .dfu and .hex files provided in the .zip file. ### Pineflash
##### Pinecilv2 vs Pinecilv2_multi-lang [Spagett1](https://github.com/Spagett1) is working on a new tool which allows
for a non-command-line experience for flashing new versions of IronOS on the
Two variants of firmwares for Pinecil are provided: combined mutli-language binaries, and individual per-language binaries. The per-language binaries are much smaller, and while the Pinecilv2 has more flash available to store the mutli-language images, for general usage, you will have an easier time with the language-specific binary rather than the multi-lang binaries. Pinecil, similar to the Pine64 updater utility for the v1 Pinecil. You can find
Multi-lang provides three bundles of languages:
1. Chinese and Japanese
2. Russian, Ukranian, Serbian, and Bulgarian
3. European (All not mentioned above)
Ultimately, it is up to you which you decide to use
#### Pineflash
[Spagett1](https://github.com/Spagett1) updated their Pineflash tool which allows
for a non-command-line experience for flashing new versions of IronOS on the Pinecil V1 and V2, similar to the Pine64 updater (add link) utility for the v1 Pinecil. You can find
more information on the PineFlash more information on the PineFlash
[GitHub repo](https://github.com/Spagett1/PineFlash). Feel free to check it out [GitHub repo](https://github.com/Spagett1/PineFlash). Feel free to check it out
and give them feedback! and give them feedback!
Pineflash supports Linux and MacOS at this time (2023-04-03), and support for Windows is a work in progress. If you would like to help test, please join the chat and make yourself known.
## Bluetooth Low Energy ## Bluetooth Low Energy
Bluetooth Low Energy (BLE) is a wireless communication protocol that is designed Bluetooth Low Energy (BLE) is a wireless communication protocol that is designed
@@ -145,9 +111,7 @@ to consume less energy than classic Bluetooth. There is upcoming support in
browser APIs to allow access to BLE devices, and so there are a handful of browser APIs to allow access to BLE devices, and so there are a handful of
options for how to get your Pinecil talking to your computer. options for how to get your Pinecil talking to your computer.
## Interacting with your Pinecil over Bluetooth ## PineSAM and Joric's UI
### PineSAM by [Builder555](https://github.com/builder555)
PineSAM (Pinecil Settings and Menus) started out as an in-browser way to see and PineSAM (Pinecil Settings and Menus) started out as an in-browser way to see and
change settings. It's served a multitude of uses from helping people with change settings. It's served a multitude of uses from helping people with
@@ -155,52 +119,45 @@ cracked or non-functional screens, all the way to adding really helpful
accessibility features to those who struggle to read the small screen on the accessibility features to those who struggle to read the small screen on the
Pinecil. It's is a Python and Vue-based application that has to have a server Pinecil. It's is a Python and Vue-based application that has to have a server
component running locally--along with a machine that has bluetooth. You can find component running locally--along with a machine that has bluetooth. You can find
the source code for PineSAM on [GitHub](https://github.com/builder555/PineSAM/), but read on below for how to grab precompiled versions of the application. the source code for PineSAM on [GitHub](https://github.com/builder555/PineSAM/).
{{< figure src="pinesam.png" alt="PineSAM UI" class="inline" >}} < insert pic of PineSAM ui >
Another project is a more simple web UI that uses in-browser Bluetooth support
(currently only really well supported in Chromium/Firefox, and even then it's
not universal or without bugs. This UI shows a nice graph of your Pinecil's
temperature and power supply information, but is limited to devices supporting
WebBLE, and also is unable to change settings on the device. You can also find
the source code for Joric UI on [GitHub](https://github.com/joric/pinecil).
< insert pic of Joric UI>
PineSAM allows the user to not only see the live temperature, wattage, and PineSAM allows the user to not only see the live temperature, wattage, and
voltage of their device but change the settings and temperatures at a click. voltage of their device but change the settings and temperatures at a click.
Uniquely, it allows users to set temperature presets for one-click changes Uniquely, it allows users to set temperature presets for one-click changes
between temperatures--for example to switch between leaded and unleaded solder. between temperatures--for example to switch between leaded and unleaded solder.
You can access PineSAM from your mobile device once it's running on your computer to have a touch-capable way of controlling your settings. On your phone, flip the screen to portait mode in order to see the graph as on desktop. < insert pic of PineSAM on Mobile >
{{< figure src="pinesam-mobile.png" alt="Screenshot of PineSAM UI on Mobile device" class="inline-40 left" >}}
The PineSAM project is working to integrate a "Work" screen which takes The PineSAM project is working to integrate a "Work" screen which takes
inspiration from Joric's UI. Due to this, it's likely these two projects will inspiration from Joric's UI. Due to this, it's likely these two projects will
end up combining into one, in my opinion, despite their distinct mechanisms for end up combining into one, in my opinion, despite their distinct mechanisms for
retrieving BLE data from the Pinecil. retrieving BLE data from the Pinecil.
#### Setting up PineSAM ### Setting up PineSAM
See the See the
[project readme](https://github.com/builder555/PineSAM#i-using-pre-made-binaries) [project readme](https://github.com/builder555/PineSAM#i-using-pre-made-binaries)
for the most up-to-date instructions. If you run into any trouble, come find us for the most up-to-date instructions. If you run into any trouble, come find us
in the #pinecil channel on Pine64's Discord or Telegram chat. in the #pinecil channel on Pine64's Discord or Telegram chat.
### [Joric](https://github.com/joric/)'s BLE API ### Setting up Joric's UI
Another project is a more simple web UI that uses in-browser Bluetooth support
(currently only really well supported in Chromium/Firefox, and even then it's
not universal or without bugs. This UI shows a nice graph of your Pinecil's
temperature and power supply information, but is limited to devices supporting
WebBLE, and also is only able to change the set point (temperature) on the
device. You can also find the source code for Joric UI on
[GitHub](https://github.com/joric/pinecil).
{{< figure src="joric-ui.png" alt="Joric's UI" class="inline-60 right" >}}
#### Setting up Joric's UI
No setup needed! Just browse to No setup needed! Just browse to
[https://joric.github.io/pinecil/](https://joric.github.io/pinecil/) in a [https://joric.github.io/pinecil/](https://joric.github.io/pinecil/) in a
compatible browser. I've personally tested Firefox and Chromium on Fedora 37, compatible browser. I've personally tested Firefox and Chromium on Fedora 37,
but I know others have got it working on Windows and MacOS, too. but I know others have got it working on Windows and MacOS, too.
As with PineSAM, feel free to come to chat for help and support.
## Home Assistant Setup with ESPHome ## Home Assistant Setup with ESPHome
If Home Assistant (HASS) is more your speed, read on below. Be warned to get If Home Assistant (HASS) is more your speed, read on below. Be warned to get
@@ -215,16 +172,14 @@ control your ESP8266/ESP32 using just YAML configurations. In this section, I'll
walk though how to setup an ESP32 with will show you how to create a smart walk though how to setup an ESP32 with will show you how to create a smart
soldering iron using Pinecil with Home Assistant and ESPHome. soldering iron using Pinecil with Home Assistant and ESPHome.
{{< figure src="hass-soldering.png" alt="Home Assistant Soldering UI" class="inline" >}}
To make this work, we'll use an To make this work, we'll use an
[ESPHome configuration file](https://github.com/TomW1605/esphome_pinecilv2_ble/blob/main/esphome_pinecilv2_ble.yaml) [ESPHome configuration file]((https://github.com/TomW1605/esphome_pinecilv2_ble/blob/main/esphome_pinecilv2_ble.yaml))
put together by Pine64 community member TomW1605. Thank you again, Tom! put together by Pine64 community member TomW1605. Thank you again, Tom!
## Requirements ## Requirements
- ESP32 device (Non affiliate link: - ESP32 device (Non affiliate link:
[ESP-WROOM-32 Development MCU on Amazon](https://www.amazon.com/ESP-WROOM-32-Development-Microcontroller-Integrated-Compatible/dp/B08D5ZD528)) [ESP-WROOM-32 Development MCU on Amazon](https://www.amazon.com/ESP-WROOM-32-Development-Microcontroller-Integrated-Compatible/dp/B08D5ZD528)
- Home Assistant already setup - Home Assistant already setup
- ESPHome already setup - ESPHome already setup
@@ -239,20 +194,19 @@ put together by Pine64 community member TomW1605. Thank you again, Tom!
2. Setup an encryption key. This must be a base64-encoded, 32 bit string. 2. Setup an encryption key. This must be a base64-encoded, 32 bit string.
- You can create one on the CLI using `openssl rand -base64 32` - You can create one on the CLI using `openssl rand -base64 32`
3. Change OTA password to desired 3. Change OTA password to desired
4. Change wifi SSID and password for your network in ESPHome secrets 4. Change wifi SSID and password for your network
5. Change wifi access point fallback settings to desired 5. Change wifi access point fallback settings to desired
6. **Important** - Change ble_client mac address to your Pinecil's MAC. This 6. **Important** - Change ble_client mac address to your Pinecil's MAC. This
can be found in the settings as well as in logs from the above tools can be found in the settings as well as in logs from the above tools
(Joric/PineSAM) (Joric/PineSAM)
{{< figure src="pinecil-esphome.png" alt="Pinecil esp32 configuration example" class="inline" >}}
4. Flash the firmware to the ESP32 device 4. Flash the firmware to the ESP32 device
5. Add the device to Home Assistant 5. Add the device to Home Assistant
6. Create a Home Assistant dashboard to control and monitor your smart soldering 6. Create a Home Assistant dashboard to control and monitor your smart soldering
iron. iron.
- An example dashboard can be found at - An example dashboard can be found at
https://gist.github.com/NeilHanlon/83d6e2cdc6eb83cb205b617f80c2a7c3 https://gist.github.com/NeilHanlon/83d6e2cdc6eb83cb205b617f80c2a7c3
- It uses the 'mini-graph-card' and 'auto-entities' integrations from HACS
{{< gist neilhanlon 83d6e2cdc6eb83cb205b617f80c2a7c3 >}} < insert pic of dashboard >
Now that you've got that setup, you should start to see data coming in about Now that you've got that setup, you should start to see data coming in about
your Pinecil's settings! Go on and automate thy solder. your Pinecil's settings! Go on and automate thy solder.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

@@ -1,144 +0,0 @@
---
title: "The WiFi Certificate That Didn't Exist an Hour Ago"
description: "I taught swamp to drive FreeIPA, then used it to issue my laptop an 802.1X client cert and get onto my own network. Mostly this is about knowing when to write things down."
date: 2026-07-10T18:30:00-04:00
draft: false
categories: ['infrastructure', 'automation']
tags: ['freeipa', 'swamp', 'pki', '802.1x', 'eap-tls', 'radius', 'homelab', 'devops']
---
My laptop is on WiFi right now on a certificate that didn't exist an hour ago. I
made the keypair, my own CA signed it, the private key went into my secret store,
and the machine authenticated onto the 802.1X network and pulled a DHCP lease —
all through code I'd finished writing about twenty minutes earlier. I want to
talk about the boring part that made it safe, because the boring part is the only
part I'd defend.
## The itch
I run [FreeIPA](https://www.freeipa.org/) at home. Kerberos realm, LDAP, a CA —
the same identity stack plenty of companies run, except mine is operated entirely
by hand. `kinit`, the `ipa` CLI, a shell script when I'm feeling fancy. Every
cert, every group, every bit of it is me typing commands and hoping I remembered
the flags. It works, and it's also exactly the sort of thing I'll fat-finger at
11pm.
I wanted it declarative: describe an identity object, have something reconcile
it, keep a record of what it did, and let me wire one piece of the homelab into
the next.
## swamp, quickly
[swamp](https://github.com/swamp-club/swamp) models real resources as typed
objects with methods — create, read, mutate — and keeps every run as versioned
data other models can read. You write extensions in TypeScript. It's the good
parts of infrastructure-as-code without the pretense that the world holds still.
FreeIPA had nothing in the registry, so I got to start from scratch, which is my
favorite way to start.
## Not one model, a family
FreeIPA is a platform, not a service, so I split it into a few small packages
instead of one enormous one. `@shrug/freeipa/domain` is read-only: it logs into
the JSON-RPC API and snapshots the realm, the server inventory, the replication
topology. Understand the domain before you touch it.
Then the ones that write: `user`, `group`, and `cert`. Certs got their own
package on purpose — in FreeIPA a certificate can belong to a user, a host, or a
service, so hanging cert issuance off the "user" model would have been wrong the
second I wanted a device cert. (I wanted a device cert.)
I built the three write packages at the same time, in parallel: three agents,
each in its own git worktree, each cloning the shape of the read-only package and
each carrying an identical copy of the same "write-kernel."
### What the write-kernel actually is
That last bit needs unpacking, because "byte-for-byte identical write-kernel" is
the kind of phrase that sounds like it means something and might not. It's not a
shared library — each package vendors its own copy. It's a small module that
every write goes through: it takes the mutation you want, runs it, and decides
what gets persisted and when. Same code, same rules, in all three packages, so
"how do we write things down" gets answered once and copied, not reinvented three
times slightly differently. Why it's worth copying is the next section.
## The actual point: when do you write it down?
Here's a question that sounds trivial and isn't: when a mutating operation runs,
*when* do you record what happened?
swamp's default guidance — and honestly most people's reflex — is throw before
you write. If the op fails, persist nothing, because a stored record claiming a
success the world never delivered is a lie, and downstream models will read that
lie and act on it. That's correct, and I kept it. But it quietly assumes every
kind of "what happened" wants to be written at the same instant, and two of them
don't.
The first is **irreplaceable material.** When I issue a cert, the model generates
a fresh private key, sends the signing request, and the CA signs it. The cert
exists now. If something after that throws and I'd followed "throw before
writing," I'd have thrown away the only private key that matches a certificate
that's already, really, out in the world. So that one flips: write the
irreplaceable thing the moment it's real, before anything else gets a chance to
fail.
The second is **partial failure.** One operation creates a steering group as both
a user-group and a host-group — two separate calls. If the first lands and the
second blows up, "it failed" is technically true and completely useless. What I
need to know is which half exists, so I can go fix the other one.
So the kernel follows three rules instead of a slogan:
1. **State** — the object itself — is written only on success. A failed mutation
never publishes state that says otherwise. (The original rule, untouched.)
2. **Irreplaceable material** — a generated key, a signed cert — is written the
moment it's real, before any later throw can eat it.
3. An **audit record** is written on *both* paths, success and failure. It's
telemetry, not state; when it says `success: false` it's telling the truth, so
writing it on failure isn't lying about anything.
That's the whole idea. "Only write on success" is a fine default right up until
the domain hands you a case where it's actively wrong — and then you break it on
purpose and leave a comment saying why.
## The payoff, which is deliberately boring
With that in place the rest is unglamorous, which was the goal. One method makes
sure the VLAN-steering group exists as both group types and shrugs off "already
exists" so I can re-run it. Another generates an RSA keypair and a PKCS#10 request
in process, hands it to the CA, and stashes the resulting private key encrypted in
my secret store — never in plaintext, never in the model's own data.
Then the last mile, by hand, because it should be: pull the signed cert and key
out to files, point NetworkManager's EAP-TLS profile at them, bring the interface
up. RADIUS checked the cert against my CA, said yes, dropped me on a VLAN, and
DHCP did the rest. Online — on an SSID with a potato in the name, because it's my
network and I get to name the SSID.
A certificate that didn't exist when I sat down, issued by infrastructure I
automated the same afternoon, carrying my laptop onto the network. Loop closed.
## Why bother
I could have typed the `ipa` commands. I have, a hundred times. But automating
something is how you're forced to actually understand it — every flag you'd been
copy-pasting on faith, every failure mode you'd been quietly getting away with.
The write-safety stuff up top didn't come from wanting clever code. It came from
asking "what happens if this throws *here*" over and over until the answer
stopped being "you lose the private key."
And yes, I can hear it: *you could do this with Ansible.* I could do this with
Ansible. I *have* done this with Ansible — there's a `freeipa` collection, it's
real, it works. You **should** do this with Ansible... probably? I went with swamp
because I wanted the versioned data and the models that read each other, and a
playbook doesn't hand me that. But if you just need certs issued and you already
live in playbooks, nobody's going to arrest you for reaching for the boring tool.
The packages are open source on the swamp registry. If you run FreeIPA and want
to stop hand-driving it — or you just like arguing about when to persist a
failure — come find me.
---
This is what I do for money too, through [Shrug PW](https://shrugpw.com): bigger
networks, fewer potatoes.
@@ -1,249 +0,0 @@
---
title: "What Does Fedora Want From Me Today?"
description: "I taught swamp to read Fedora (accounts, Bugzilla, the packager dashboard) so one command tells me what my packages need. The answer was 135 things."
date: 2026-07-14T13:54:48-04:00
draft: false
categories: ['open-source', 'automation']
tags: ['fedora', 'swamp', 'packaging', 'bugzilla', 'rpm', 'devops', 'adhd']
---
I began contributing as a packager with Fedora a few years ago, in concert with
my participation in founding and bootstrapping Rocky Linux from the ground up.
That alone deserves a blog post series I may write some day... but more to the
point... I haven't done a great job of maintaining my packages. There's a host
of reasons I could get into for why, but particularly in light of having a
newborn and therefore much more limited time and energy than I ever thought was
possible, optimizing my workflows and maximizing my impact (and reducing
ownership of things where needed) are the most powerful knobs I can use to make
the greatest difference not just in Fedora but across all the things I involve
myself with.
As many of these things go, I turned to the swamp.
## The problem
Fedora is by its nature a distributed consensus model playing out in real life.
It moves constantly, and can be really hard to keep up with. There are attempts
to help with this, and they deserve talking about: everything that happens in
the project lands on the [fedora-messaging](https://fedora-messaging.readthedocs.io/)
bus, [datanommer](https://github.com/fedora-infra/datanommer) archives all of
it, datagrepper lets you query the archive, and [Fedora Notifications](https://notifications.fedoraproject.org/)
will happily email you about any of it. And then there's the
[Packager Dashboard](https://packager-dashboard.fedoraproject.org/), which does
the actually-hard aggregation work (dist-git, Bodhi, Koschei, Bugzilla, crash
reports) and presents it per-packager. These are good tools built by people who
understand the problem.
But at the end of the day, pulling together all of these sources of information
into a single "pain of glass" is a difficult problem, particularly if you have
ADHD and might spread yourself a bit thin in the best of times with various
interests. The bus and the notifications are push: streams and inboxes, more
things arriving whether or not I'm ready for them. What I actually needed was a
pull... one command I run when I have the spoons for it, that answers "what
does Fedora want from me today?" and writes the answer down somewhere I can
diff later.
## CVEs, Bugs, and FTBFS... oh my!
So here's where I ended up:
```sh
swamp workflow run fedora-attention
swamp report get "@kneel/fedora-packager-dashboard/attention" \
--model fedora-dash --markdown
```
...and here's a real excerpt of what came back:
```markdown
# Fedora attention — neil
- **Actionable items:** 135 across 66 packages
- **Open bugs:** 96 · **PRs:** 24 · **Build failures:** 15
- **Snapshot:** 2026-07-13T15:11:48.390Z
## Security / CVE (32)
- **d2** #2455622 — CVE-2026-34986 Go JOSE: DoS via crafted JWE object
- **grpc** #2408295 — CVE-2025-58189 go crypto/tls ALPN negotiation ...
(30 more)
## FTBFS / build failures (7)
- **xastir** #2435195 — xastir: FTBFS in Fedora rawhide/f44
- **slurm** #2484402 — F45FailsToInstall: slurm
(5 more)
## Top packages by attention
| Package | Bugs | PRs | Build fails | Total |
| ------- | ---- | --- | ----------- | ----- |
| grpc | 12 | 5 | 4 | 21 |
| slurm | 13 | 1 | 0 | 14 |
| d2 | 11 | 0 | 2 | 13 |
```
135 actionable items across 66 packages. Cool. Cool cool cool.
Weirdly, though, seeing it all at once made the whole thing feel smaller. Most
of those 32 CVEs are the same handful of Go stdlib and golang.org/x vulns
fanned out across every Go package I touch (`d2` and `grpc` between them
account for the bulk of it), so in practice it's more like two or three
rebuild-and-update passes than 32 separate crises. The FTBFS list is seven
entries across five packages, two of which are `xastir` reminding me that the
ham radio software needs love again (it always needs love again).
### Whose packages are these, anyway?
Funny thing about `d2`, actually: I don't own it directly at all. I went and
checked the snapshot — its entry says `users: []`, `groups: ["mkdocs-sig"]`
so it lands on my list purely through a SIG I'm in. And once I knew to look,
it turns out 41 of my 66 packages are the same way: all mkdocs-sig, accounting
for 49 of the 135 items. My *personal* queue is really 25 packages and 86
items. Which means the report already has its first improvement queued up: it
doesn't distinguish *why* a package is mine — direct ownership versus
inherited through a group ACL — and that distinction changes what I actually
do about an item. A CVE on a package I personally own is my problem; the same
CVE on a group-maintained package mostly means checking whether someone else
in the SIG is already on it. The best part is the data's already sitting in
every snapshot (each package entry carries its `users` and `groups`), so the
fix is pure rendering. More on extending the report below.
### The shame table
`grpc` deserves its own aside, because it's the poster child for everything
this post is about. It sits at the top of the attention table with 21 items,
and it's a giant pain to maintain, largely because of protobuf, which it's
chained to, and which has an ongoing Fedora change effort against it and has
been behind upstream for years. I picked grpc up knowing some of that, and I've
been going back and forth on dropping it ever since: its surface is huge and I
don't particularly have the time it deserves. Looking at it holistically in the
report, it feels like a smaller problem than it does when it's ambushing me one
bugmail at a time... sorta. I still might drop it. At minimum I'll be asking
for co-maintainers (consider this the soft launch of that ask).
And that "top packages by attention" table doubles as a divestment worksheet:
if `slurm` generates 14 items a cycle and I haven't used it in years, the
kindest thing I can do for Fedora is help it find a better home. Remember what
I said up top about reducing ownership? The report is how I'm actually going to
do it, instead of just feeling vaguely bad about it. The good news is there's an
active group (OpenHPC) that has a vested interest in slurm and so the
co-maintainer pool is deeper than most.
### What one run actually does
The `fedora-attention` workflow behind the two commands up top refreshes three
models in one shot: the dashboard snapshot (which regenerates the attention
report as a side effect), the bugs assigned to me in Bugzilla, and my current
FAS group membership. Every run is versioned data in the swamp datastore, so a
month from now I can diff what Fedora wanted against what Fedora wants...
which will either be a burndown chart or a horror story. I plan to publish it
either way.
## Data-gathering and AI fan-out
Full disclosure: I didn't type most of the *code* by hand (these words, yes...
the TypeScript, no). I've been running Claude agents against my swamp
workspaces for a couple weeks now — the same setup that
[issued my WiFi certificate]({{< ref "the-wifi-cert-that-didnt-exist-an-hour-ago" >}})
and [put my son in a swamp]({{< ref "i-put-my-son-in-a-swamp" >}}) — and this
went the same way. I described the three planes I wanted (identity, bugs,
packages), and agents went off to research the APIs and build read-only models
in parallel while I did other things (changing diapers, probably), with me
wandering back periodically to say "no, not like that." and "good god why would
you think this is a good idea?!"
What landed:
- `@kneel/fasjson` — Fedora Accounts over the FASJSON REST API. Who am I,
what groups am I in, who else is in this group.
- `@kneel/bugzilla` — Red Hat Bugzilla's REST API. My account, my assigned
bugs, and open bugs across every component I maintain.
- `@kneel/fedora-packager-dashboard` — the Packager Dashboard's public
Oraculum v2 API, which does the cross-source aggregation so I don't have to.
This one was also the durable choice on purpose: it sits *above* dist-git,
so it survives the Pagure→Forgejo migration that a direct dist-git model
would not.
All three are read-only on purpose. I want to watch this stuff for a good
while before I let anything mutate it, if I ever do.
The research pass surfaced the kind of API trivia you only learn by actually
integrating, and it's now baked into the models so nobody has to learn it
twice. Red Hat's Bugzilla only accepts `Authorization: Bearer` header auth
now; the legacy `?Bugzilla_api_key=` query param that half the example code on
the internet still uses gets you an HTTP 400. There's no `/whoami` endpoint
(404), so identity goes through `/user?match=`. `component` comes back as a
JSON array even when there's exactly one. And the dashboard's first uncached
query for a packager can take tens of seconds while Oraculum pulls live data,
which is worth knowing before you decide it's hung. As far as I can tell none
of this is written down in any one place, but now it's encoded in TypeScript
with tests, which is arguably better.
(The Bugzilla token lives in a swamp vault — backed by PasswordStore and my
GPG key — and reaches the model through a CEL expression:
`vault.get("bugzilla", "API_TOKEN")` — so it's never in a model definition,
my shell history, or git.)
### Your own fedora-attention report
The extensions are on the swamp registry, so if you're a Fedora packager who
also wants a morning number:
```sh
swamp extension pull @kneel/fedora-packager-dashboard
swamp model create @kneel/fedora-packager-dashboard fedora-dash \
--global-arg users=<your-fas-username>
swamp model method run fedora-dash dashboard
swamp report get "@kneel/fedora-packager-dashboard/attention" \
--model fedora-dash --markdown
```
The dashboard API is public (no token, no Kerberos) so that part works with
zero credential setup. The model takes `users`, `groups`, or `packages`
selectors, so you can point it at a whole SIG (`groups=go-sig`) or a package
set instead of a person. Add `@kneel/bugzilla` with a vaulted token if you want
your assigned-bugs list next to it, `@kneel/fasjson` for the accounts plane,
and wire them into a workflow like my `fedora-attention` so one command
refreshes everything.
And if the report doesn't slice things the way your brain wants (mine is tuned
for "CVEs first, then broken builds, then a shame table"), you can reshape it.
The attention report is one small TypeScript file that reads the latest
`dashboard` snapshot out of the datastore and renders markdown, and every
`sync`'d category — Bodhi updates, Koschei state, orphans, new upstream
releases, crash reports — is already sitting in that snapshot, unrendered.
Grab the source, copy the report into your own workspace's
`extensions/reports/`, and go to town; swamp picks it up on the next bundle,
and the model doesn't care how many reports read it. You can also add methods
to the model itself with `export const extension` if you want a different
slice of the API. If you build something generally useful, send a pull
request. "Orphan candidates sorted by attention-per-month-of-neglect" is
sitting right there, and so is the one I owe myself from the `d2` discovery
above, so group-inherited noise stops masquerading as personal debt.
## The duct tape I could hear
The big lesson is the thesis of this whole series: modeling a system forces
you to actually understand it. And when your attention is the bottleneck,
versioned pull beats well-intentioned push every time.
The smaller lesson is the one that wouldn't leave me alone. Two of the three
models are pure `fetch`, no dependencies. The third isn't. FASJSON speaks
*only* Kerberos/GSSAPI — every endpoint answers `WWW-Authenticate: Negotiate`,
and there's no password or token fallback of any kind. The standard answer
(the one every integration I've ever seen uses) is to shell out: `kinit`, then
`curl --negotiate`, and now your model depends on a subprocess, an ambient
credential cache, and whether the machine happens to have krb5-workstation
installed. That's what my model did. It worked. It was also the only part of
the whole build that felt like duct tape, and every time the workflow ran I
could hear it.
I got annoyed enough to do something unreasonable about it.
That's the next post.
---
This is what I do for money too, through [Shrug PW](https://shrugpw.com):
making other people's operational queues legible. Fewer ham radio packages,
usually.
-1
View File
@@ -3,5 +3,4 @@ categories: ["projects"]
date: "2016-10-02T22:55:05-04:00" date: "2016-10-02T22:55:05-04:00"
tags: ["projects"] tags: ["projects"]
title: "Projects" title: "Projects"
layout: "projects"
--- ---
-5
View File
@@ -1,5 +0,0 @@
---
title: "Résumé"
description: "Neil Hanlon — infrastructure consultant. Experience, open-source work, and skills, with a PDF download."
layout: "resume"
---
+3 -73
View File
@@ -1,76 +1,6 @@
name: Projects name: Projects
source: source:
# --- Currently --- - icon: fa fa-github
- name: Shrug PW Consulting name: Rocky Linux
role: Founder & Principal Consultant description: All the infrastructure for Rocky Linux, from soup to nuts. Work with a team of professionals to architect, design, and implement secure systems and proceudres to write Infrastructure as Code to provide systems for the various teams within the Rocky Enterprise Software Foundation, especially Rocky Linux as it gets off the ground. Also work closely with Release Engineering on building next generation Build Automation pipeline tools and ensuring reliability and security in the supply chain.
status: current
description: Infrastructure architecture, automation, and technical advisory for mission-driven orgs — reliable systems on real budgets.
url: https://shrugpw.com/
- name: shrug.host
role: Founder
status: current
description: Human-scale hosting for small organizations and open source projects. Reliable infrastructure without the enterprise complexity.
url: https://shrug.host/
- name: shrug.games
role: Founder
status: current
description: Game server hosting that just works. Built with Taylor and Lexi.
url: https://shrug.games/
- name: swamp.club
role: OG Swamper
status: current
description: Early member of the swamp.club community (formerly System Initiative) — model-based infrastructure automation, from back when it was still finding its shape.
url: https://swamp.club/
- name: RDO / CentOS Cloud SIG
role: PTL
status: current
description: Leading RDO's transition into the CentOS Cloud SIG — steering where RDO's OpenStack packaging lives and how it ships.
url: https://www.rdoproject.org/
- name: OpenStack-Ansible
role: Core reviewer
status: current
description: Reviewing patches and keeping the deployment automation honest for running OpenStack on enterprise Linux.
url: https://opendev.org/openstack/openstack-ansible
- name: Fedora Project
role: Contributor · RISC-V SIG, CommOps
status: current
description: Packaging, build systems, and community tooling — with a standing effort to get more of Fedora booting on RISC-V.
url: https://fedoraproject.org/
- name: OpenELA
role: Technical Steering Committee
status: current
description: Governance for a vendor-neutral source of enterprise Linux — supply-chain transparency, less drama.
url: https://openela.org/
- name: ResolveX
role: Board member & technical advisor
status: current
description: Infrastructure and scaling advice for a team building software for crisis call centers — 988 and mental-health lines.
url: https://resolvex.com/
- name: Amateur Radio & Emergency Comms
role: RACES / ARES · CERT
status: current
description: Volunteer emergency communications — RACES/ARES nets, public-service events, and CERT. When the internet's down, the radio still works.
# --- Previously ---
- name: Rocky Linux
role: Infrastructure architect · core team
status: past
when: through 2026
description: Built and ran everything from bare metal to cloud — CI/CD, build systems, supply-chain security — and worked with Release Engineering on next-generation build automation.
url: https://github.com/rocky-linux/ url: https://github.com/rocky-linux/
- name: Rocky Enterprise Software Foundation
role: Board member
status: past
when: through 2026
description: Governance, financial oversight, and mission for the non-profit behind Rocky Linux.
url: https://rockylinux.org/about
-256
View File
@@ -1,256 +0,0 @@
# Generated by @kneel/resume (render-hugo) — do not edit by hand.
# Canonical source: the swamp résumé model. Edit there and re-render.
summary: >-
Infrastructure consultant specializing in Linux systems, automation, and
reliable platform delivery. I help organizations build, migrate, and operate
production environments — and contribute upstream to the tools that power
them.
pdf: 'https://shrugpw.com/resume.pdf'
contact:
email: neil@shrug.pw
location: Massachusetts
skills:
- group: Open Source
items:
- Rocky Linux Infra Lead
- Fedora / EPEL Contributor
- Community Governance
- Upstream-first Development
- Release Engineering
- OpenELA TSC
- group: Systems Engineering
items:
- Secure Infra Architecture
- CDN + Edge Services
- 'Cloud: AWS, Azure, GCP'
- Infrastructure-as-Code
- Containers / OCI / K8s
- group: Networking
items:
- 'BGP, OSPF, Juniper'
- Linux Routing / Firewalling
- Cisco ASA / R+S
- 'Arista EOS, Mikrotik'
- IPv6 Transition
- SD-WAN
- Load Balancing / ADC
- group: Programming
items:
- 'Python, Bash, Golang'
- 'Ansible, Puppet, Terraform'
- 'Pulumi, Lua, Ruby, PHP'
- group: Tooling
items:
- 'Git / GitOps, CI/CD'
- 'Prometheus, Grafana'
- rpm / mock / koji
- 'GNU/Linux, Ubuntu'
- Red Hat Enterprise Linux
experience:
- period: 2025
role: 'Owner, Principal Consultant'
org: Shrug PW Consulting
location: 'Bedford, MA'
url: 'https://shrugpw.com'
highlights:
- >-
Architect and deploy bare metal, containerized, and cloud infrastructure
for clients across defense, education, and enterprise sectors.
- >-
Design and build standardized Linux system images (Ubuntu, RHEL, Rocky)
and deployment pipelines using Ansible, Puppet, and custom automation
tooling.
- >-
Lead complex infrastructure migrations and disaster recovery efforts,
including crisis response and system restoration for time-critical
environments.
- >-
Build custom monitoring, alerting, and observability solutions
delivering actionable insights while reducing alert fatigue.
- >-
Design and deploy production network infrastructure including routing,
firewalls, VLANs, wireless, and fiber connectivity for multi-site
organizations.
- >-
Develop provisioning documentation, runbooks, and support procedures for
client handoff and knowledge transfer.
- >-
Provide open source strategy consulting and technical governance for
organizations adopting or contributing to FOSS ecosystems.
- period: 2025
role: Advisory Board Member & Technical Advisor
org: ResolveX Inc.
location: Remote
url: 'https://resolvex.com'
highlights:
- >-
Advise on infrastructure architecture and operational resilience for
advanced call-center software serving crisis helplines, including the
988 Suicide & Crisis Lifeline.
- >-
Provide scaling and reliability guidance for a team building
mission-critical systems for mental-health and crisis lines.
- period: 2022
role: OpenStack-Ansible Core Reviewer
org: OpenStack
location: Remote (Volunteer)
url: 'https://opendev.org/openstack/openstack-ansible'
highlights:
- >-
Core reviewer for OpenStack-Ansible, maintaining and improving the
deployment tooling and automation for running OpenStack on enterprise
Linux.
- >-
Review patches, mentor contributors, and help keep releases stable and
secure.
- >-
Focus on test coverage, CI modernization, and tighter integration with
upstream OpenStack projects.
- period: 2020
role: 'SIG Contributor — Cloud, NFV, and Hyperscale'
org: The CentOS Project
location: Remote (Volunteer)
url: 'https://www.centos.org'
highlights:
- >-
**Project Team Lead (PTL)** for RDO's transition into the CentOS Cloud
SIG, steering where RDO's OpenStack packaging lives and how it ships.
- >-
Maintain Open vSwitch (OVS) and Open Virtual Network (OVN) packages and
their dependencies for the NFV SIG — keeping working versions available
for EL9+ users, including Rocky Linux and AlmaLinux, insulated from
churn in CentOS Stream.
- Contribute packaging and maintenance effort to the Hyperscale SIG.
- Long-time CentOS user and community member since 2013.
- period: 2020
role: Contributor and Packager
org: The Fedora Project
location: Remote (Volunteer)
url: 'https://fedoraproject.org'
highlights:
- Maintain and test packages in EPEL and Fedora branches.
- >-
Contributed to the Fedora RISC-V SIG, supporting alt-arch enablement and
early ecosystem work.
- >-
Participated in Fedora CommOps, helping shape community engagement
strategies and project visibility.
- >-
Advocate for upstream-first workflows and maintain contributor-friendly
build/test automation.
- period: 20212025
role: Senior Infrastructure Engineer
org: Ctrl IQ (CIQ)
location: Remote
highlights:
- >-
Performed formally-sponsored contributions to Rocky Linux and RESF
infrastructure, bridging company goals and upstream project
sustainability.
- >-
Led cross-functional efforts to scale CIQ's build and release
infrastructure for RHEL-derivative products based on Rocky.
- >-
Designed and maintained hybrid CI/CD pipelines for open and
closed-source systems, integrating GitLab, Koji, and internal build/test
runners.
- >-
Championed upstream-first development practices, ensuring internal
engineering work could be contributed back cleanly to RESF when
appropriate.
- >-
Provided technical leadership and emergency response across CIQ's
internal infrastructure stack — networking, packaging, on-call rotation.
- >-
Advised CIQ leadership on open source community dynamics,
trust-building, and governance best practices.
- period: 20202026
role: 'Director, Infrastructure'
org: Rocky Enterprise Software Foundation (RESF) — Rocky Linux
location: Remote (Volunteer)
highlights:
- >-
Co-founded and led infrastructure for the open source rebuild of CentOS
Linux (Rocky Linux), coordinating a global team of more than thirty
volunteers.
- >-
Designed and operated hybrid infrastructure spanning public cloud and
physical data centers for global mirror distribution, CI/CD pipelines,
and release engineering workflows.
- >-
Built and maintained scalable public-facing services — Koji builders,
HTTP/RSYNC pools, mirrorlist, and signing infrastructure — supporting
**3 billion+ requests/month** and **1+ PB/month** of traffic.
- >-
Developed and maintained **Peridot**, the open source build system
powering Rocky Linux and CIQ's RHEL-derivative product lifecycle, for
scalable, reproducible, secure builds.
- >-
Built and maintained a cryptographic enclave for UEFI Secure Boot
signing, enabling trusted boot for Rocky Linux and downstream
derivatives.
- >-
Architected secure-by-default systems and enforced best practices in
CI/CD, credential management, artifact signing, and endpoint access
control.
- >-
Led incident response and coordinated recovery for critical
infrastructure outages across multiple time zones and providers.
- >-
Managed infrastructure sponsorships and partner relationships for
long-term project sustainability and cost-effective operations.
- period: 20202026
role: Founding Board Member
org: Rocky Enterprise Software Foundation (RESF)
location: Remote (Volunteer)
highlights:
- >-
Established the RESF to support community-driven governance,
infrastructure, and lifecycle planning for Rocky Linux and other open
source projects.
- >-
Represented technical community perspectives on the RESF Advisory Board
and helped shape project direction and policy.
- >-
Advised on growth, sustainability, and alignment between corporate
partners and community goals.
- period: 20152021
role: 'DevOps Engineer, Senior Network Engineer'
org: KAYAK Software Corporation
location: 'Concord, MA'
highlights:
- >-
Managed production and corporate networks across two data centers and
14+ global offices.
- >-
Re-architected core switching to a modern spine/leaf fabric using
BigSwitch Cloud Fabric (OpenDaylight).
- >-
Replaced aging Juniper gear and unstable edge routers; improved
interconnects with private links and a Boston POP for AWS Direct
Connect.
- >-
Designed and implemented a PII "bubble" to segment sensitive workloads
from general production traffic.
- >-
Led CDN modernization using Fastly + Varnish; built traffic shaping and
A/B testing config.
- >-
Maintained RADIUS and SAML-based MFA proxies for secure access to
network gear.
- >-
Owned alerting/on-call infrastructure; migrated incident workflows from
PagerDuty to OpsGenie.
- >-
Introduced Apache Airflow for analytics engineering, improving
billing/reporting pipelines.
education:
- period: 20132017
role: B.S. in Computer Science
org: Wentworth Institute of Technology
detail: Minor in Computer Networking
location: 'Boston, MA'
highlights:
- >-
Coursework in algorithms, compiler design, networking, security,
software architecture, and systems programming.
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash -ex
export GIT_AUTHOR_NAME="${GIT_AUTHOR_NAME}"
export GIT_AUTHOR_EMAIL="${GIT_AUTHOR_EMAIL}"
export GIT_SSH_COMMAND="ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
REMOTE="${PUSH_REMOTE:-git@git.shrug.pw:neil/neilhanlon.me.git}"
rm -fr pages.git
mkdir pages.git
( cd pages.git && git init -b pages )
rsync -av public/* pages.git
cat << EOF > pages.git/.domains
neilhanlon.me
neilhanlon.com
hanlon.ninja
thepotato.tech
EOF
cd pages.git
set -x
git config user.name "$GIT_AUTHOR_NAME"
git config user.email "$GIT_AUTHOR_EMAIL"
git add -A
git commit -m "Deployment at $(date -u -Is)"
git remote add origin $REMOTE
git push -f origin pages
-66
View File
@@ -1,66 +0,0 @@
baseURL = "https://thepotato.tech/"
languageCode = "en-US"
title = "Neil Hanlon"
enableRobotsTXT = true
[permalinks]
post = "/:year/:month/:day/:slug"
[params]
author = "Neil Hanlon"
description = "Neil Hanlon — Senior Network/DevOps/Infrastructure Engineer"
hero_eyebrow = "neil@potato:~$ whoami"
# "What's running" cards on the homepage
[[params.now]]
title = "Consulting"
body = "Infrastructure for growing tech companies via <span class=\"k\">shrugpw.com</span> — compliance, migrations, networks done right."
[[params.now]]
title = "The homelab"
body = "FreeIPA, FreeRADIUS, an SSID with a potato in the name, and far too many little agents writing to a database."
[[params.now]]
title = "Fedora / RISC-V"
body = "Packaging, build systems, and a standing campaign to get more of Fedora booting on RISC-V hardware that mostly doesn't want to. Open source is the good part of this whole thing."
[params.social]
Github = "NeilHanlon"
Email = "neil@shrug.pw"
Bluesky = "@shrug.pw"
LinkedIn = "hanlonneil"
Signal = "hScowCEn-H1GKkCrare0m_xOEPqTWxt51pW9SI2bU5TlXrMW9QFD6qHBLqG1jDq9"
Keybase = "nhanlon"
Instagram = "noellathekitty"
[params.volunteer]
Rocky = "https://rockylinux.org"
[markup]
[markup.goldmark.renderer]
unsafe = true
[markup.highlight]
noClasses = false
guessSyntax = true
[outputs]
home = ["HTML", "RSS"]
[taxonomies]
tag = "tags"
category = "categories"
## Main Menu
[[menu.main]]
name = "posts"
weight = 100
identifier = "posts"
url = "/posts/"
[[menu.main]]
name = "projects"
identifier = "projects"
weight = 200
url = "/projects/"
[[menu.main]]
name = "resume"
identifier = "resume"
weight = 300
url = "/resume/"
-17
View File
@@ -1,17 +0,0 @@
<!doctype html>
<html lang="{{ .Site.LanguageCode | default "en" }}">
<head>
{{- partial "head.html" . }}
</head>
<body class="{{ block "body-class" . }}{{ end }}">
<a href="#main-content" class="skip-link">Skip to content</a>
{{- partial "navbar.html" . }}
<main class="site-main" id="main-content">
{{- block "main" . }}{{- end }}
</main>
{{- partial "footer.html" . }}
</body>
</html>
-33
View File
@@ -1,33 +0,0 @@
{{ define "main" }}
<div class="page-hero">
<div class="container">
<p class="page-hero__eyebrow eyebrow">
<span class="dot dot--pulse" aria-hidden="true"></span>
{{ with .Params.eyebrow }}{{ . }}{{ else }}~/{{ .Section | default "posts" }}{{ end }}
</p>
<h1 class="page-hero__title">{{ .Title }}</h1>
{{- with .Description }}<p class="page-hero__subtitle">{{ . }}</p>{{ end }}
</div>
</div>
<div class="section">
<div class="container">
{{- with .Content }}<div class="prose" style="margin-bottom:var(--s10);">{{ . }}</div>{{ end }}
<div class="posts">
{{- range .Pages.ByDate.Reverse }}
<a href="{{ .RelPermalink }}" class="post-row">
<div class="post-row__meta">
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
{{- if .Draft }}<span class="chip chip--draft">draft</span>
{{- else }}{{ with .Params.tags }}<span class="chip chip--tag">{{ index . 0 }}</span>{{ end }}{{ end }}
</div>
<div>
<div class="post-row__title">{{ .Title }}</div>
<p class="post-row__dek">{{ .Description | default .Summary | truncate 180 }}</p>
</div>
</a>
{{- end }}
</div>
</div>
</div>
{{ end }}
-45
View File
@@ -1,45 +0,0 @@
{{ define "main" }}
<div class="page-hero">
<div class="container">
<p class="page-hero__eyebrow eyebrow">
<span class="dot dot--pulse" aria-hidden="true"></span> ~/projects
</p>
<h1 class="page-hero__title">{{ .Title }}</h1>
<p class="page-hero__subtitle">Open source, governance, and the businesses I run — current work and where I've been.</p>
</div>
</div>
<div class="section">
<div class="container">
{{- with .Content }}<div class="prose" style="margin-bottom:var(--s10);">{{ . }}</div>{{ end }}
{{- $current := where .Site.Data.projects.source "status" "!=" "past" }}
{{- $past := where .Site.Data.projects.source "status" "past" }}
<span class="section-label">// currently</span>
<div class="card-grid" style="margin-bottom:var(--s16);">
{{- range $current }}
{{- partial "project-card.html" . }}
{{- end }}
{{/* Soft consulting CTA — delete this <a> block to remove */}}
<a class="card card--cta" href="https://shrugpw.com" rel="noopener">
<p class="card__role">// open slot</p>
<h3 class="card__title">Your project here?</h3>
<div class="card__body">
<p>I take on a couple of engagements at a time — a migration, a network done right, infrastructure that needs untangling.</p>
<p class="card__link">let's talk →</p>
</div>
</a>
</div>
{{- with $past }}
<span class="section-label">// previously</span>
<div class="card-grid">
{{- range . }}
{{- partial "project-card.html" . }}
{{- end }}
</div>
{{- end }}
</div>
</div>
{{ end }}
-79
View File
@@ -1,79 +0,0 @@
{{ define "body-class" }}page-resume{{ end }}
{{ define "main" }}
{{ $r := .Site.Data.resume }}
<div class="page-hero">
<div class="container">
<p class="page-hero__eyebrow eyebrow">
<span class="dot dot--pulse" aria-hidden="true"></span> ~/resume
</p>
<h1 class="page-hero__title">{{ .Title }}</h1>
{{- with $r.summary }}<p class="page-hero__subtitle">{{ . }}</p>{{ end }}
<div class="actions">
{{- with $r.pdf }}<a class="btn btn--primary" href="{{ . }}" rel="noopener">Download PDF →</a>{{ end }}
<a class="btn btn--ghost" href="https://shrugpw.com" rel="noopener">Hire me →</a>
</div>
</div>
</div>
<div class="section">
<div class="container">
<div class="resume__grid">
<aside class="resume__aside">
{{- with $r.contact }}
<div class="resume__block">
<span class="section-label">// contact</span>
<ul class="resume__list">
{{- with .email }}<li><a href="mailto:{{ . }}">{{ . }}</a></li>{{ end }}
{{- with .location }}<li>{{ . }}</li>{{ end }}
{{- with .phone }}<li><a href="tel:{{ replace . " " "" }}">{{ . }}</a></li>{{ end }}
</ul>
</div>
{{- end }}
{{- range $r.skills }}
<div class="resume__block">
<span class="section-label">// {{ .group | lower }}</span>
<ul class="resume__list resume__list--skills">
{{- range .items }}<li>{{ . }}</li>{{ end }}
</ul>
</div>
{{- end }}
</aside>
<div class="resume__main">
<span class="section-label resume__heading">// experience</span>
<div class="resume__entries">
{{- range $r.experience }}{{ template "resume-entry" . }}{{ end }}
</div>
<span class="section-label resume__heading">// education</span>
<div class="resume__entries">
{{- range $r.education }}{{ template "resume-entry" . }}{{ end }}
</div>
</div>
</div>
</div>
</div>
{{ end }}
{{ define "resume-entry" }}
<article class="resume-entry">
<div class="resume-entry__head">
<h3 class="resume-entry__role">{{ .role }}</h3>
<span class="resume-entry__period">{{ .period }}</span>
</div>
<p class="resume-entry__org">
{{- if .url }}<a href="{{ .url }}" rel="noopener">{{ .org }}</a>{{ else }}{{ .org }}{{ end }}
{{- with .location }} <span class="resume-entry__loc">· {{ . }}</span>{{ end }}
{{- with .detail }}<br><span class="resume-entry__detail">{{ . }}</span>{{ end }}
</p>
{{- with .highlights }}
<ul class="resume-entry__highlights">
{{- range . }}<li>{{ . | markdownify }}</li>{{ end }}
</ul>
{{- end }}
</article>
{{ end }}
-43
View File
@@ -1,43 +0,0 @@
{{ define "main" }}
<article>
<div class="page-hero">
<div class="container">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Home</a>
<span aria-hidden="true"></span>
{{- if .CurrentSection.Title }}
<a href="{{ .CurrentSection.RelPermalink }}">{{ .CurrentSection.Title }}</a>
<span aria-hidden="true"></span>
{{- end }}
<span aria-current="page">{{ .Title }}</span>
</nav>
<p class="page-hero__eyebrow eyebrow">
<span class="dot dot--pulse" aria-hidden="true"></span>
{{ with .Params.eyebrow }}{{ . }}{{ else }}~/{{ .Section }}{{ end }}
</p>
<h1 class="page-hero__title">{{ .Title }}</h1>
{{- with .Description }}<p class="page-hero__subtitle">{{ . }}</p>{{ end }}
<div class="page-hero__meta">
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "January 2, 2006" }}</time>
<span aria-hidden="true">·</span>
<span>{{ .ReadingTime }} min read</span>
{{- if .Draft }}<span aria-hidden="true">·</span><span class="chip chip--draft">draft</span>{{ end }}
</div>
</div>
</div>
<div class="section">
<div class="container">
<div class="prose">
{{ .Content }}
</div>
<div class="post-foot">
<span>
{{- with .Params.tags }}Filed under {{ delimit . " · " }}{{ else }}{{ .Site.Params.author }}{{ end -}}
</span>
<a href="{{ .CurrentSection.RelPermalink }}">← All posts</a>
</div>
</div>
</div>
</article>
{{ end }}
-77
View File
@@ -1,77 +0,0 @@
{{ define "body-class" }}page-home{{ end }}
{{ define "main" }}
<header class="hero">
<div class="container">
<p class="hero__eyebrow eyebrow">
<span class="dot dot--pulse" aria-hidden="true"></span>
{{ .Site.Params.hero_eyebrow | default "neil@potato:~$ whoami" }}
</p>
<h1 class="hero__title">
{{ .Site.Params.author }} —<br>
infrastructure, networks, systems<br>
&amp; the occasional <span class="grad">potato</span>.
</h1>
<div class="hero__lede">
{{ .Content }}
</div>
<div class="actions">
<a class="btn btn--primary" href="/posts/">Read the blog →</a>
<a class="btn btn--ghost" href="/projects/">Projects</a>
<a class="btn btn--ghost" href="https://shrugpw.com" rel="noopener">Hire me →</a>
</div>
<div class="social-inline">
{{- with .Site.Params.social.github }}<a href="https://github.com/{{ . }}" rel="me noopener"><span class="dot" aria-hidden="true"></span> github/{{ . }}</a>{{ end }}
{{- with .Site.Params.social.bluesky }}<a href="https://bsky.app/profile/{{ replace . "@" "" }}" rel="me noopener">bsky/{{ . }}</a>{{ end }}
{{- with .Site.Params.social.instagram }}<a href="https://instagram.com/{{ . }}" rel="noopener">the cat's instagram</a>{{ end }}
{{- with .Site.Params.social.email }}<a href="mailto:{{ . }}">{{ . }}</a>{{ end }}
</div>
</div>
</header>
<section class="section section--surface" aria-labelledby="writing-heading">
<div class="container">
<div class="section-head">
<span class="section-label">// writing</span>
<h2 id="writing-heading">Latest posts</h2>
<p>Homelab, infrastructure, and the occasional life event that turns into a systems-design problem.</p>
</div>
<div class="posts">
{{- range first 8 (where .Site.RegularPages "Section" "posts").ByDate.Reverse }}
<a href="{{ .RelPermalink }}" class="post-row">
<div class="post-row__meta">
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
{{- if .Draft }}<span class="chip chip--draft">draft</span>
{{- else }}{{ with .Params.tags }}<span class="chip chip--tag">{{ index . 0 }}</span>{{ end }}{{ end }}
</div>
<div>
<div class="post-row__title">{{ .Title }}</div>
<p class="post-row__dek">{{ .Description | default .Summary | truncate 180 }}</p>
</div>
</a>
{{- end }}
</div>
</div>
</section>
{{- with .Site.Params.now }}
<section class="section soil" aria-labelledby="now-heading">
<div class="container">
<div class="section-head">
<span class="section-label">// now</span>
<h2 id="now-heading">What's running</h2>
</div>
<div class="now-grid">
{{- range . }}
<div class="now-card">
<h3><span class="dot" aria-hidden="true"></span> {{ .title }}</h3>
<p>{{ .body | safeHTML }}</p>
</div>
{{- end }}
</div>
</div>
</section>
{{- end }}
{{ end }}
-16
View File
@@ -1,16 +0,0 @@
<footer class="footer" aria-label="Site footer">
<div class="footer__inner">
<p class="footer__note">
Built with Hugo. No trackers, no popups, occasionally a typo left in on purpose.
<span aria-hidden="true">¯\_(ツ)_/¯</span><br>
&copy; {{ now.Year }} {{ .Site.Params.author }}.
</p>
<div class="footer__links">
{{- with .Site.Params.social.github }}<a href="https://github.com/{{ . }}" rel="me noopener">github</a>{{ end }}
{{- with .Site.Params.social.bluesky }}<a href="https://bsky.app/profile/{{ replace . "@" "" }}" rel="me noopener">bluesky</a>{{ end }}
{{- with .OutputFormats.Get "rss" }}<a href="{{ .RelPermalink }}">rss</a>{{ end }}
<a href="https://shrugpw.com" rel="noopener">consulting</a>
<a href="https://ko-fi.com/kneel" rel="noopener">ko-fi</a>
</div>
</div>
</footer>
-45
View File
@@ -1,45 +0,0 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{ if .IsHome }}{{ .Site.Title }} — {{ .Site.Params.description }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>
<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
<meta name="author" content="{{ .Site.Params.author }}">
{{- partial "opengraph.html" . }}
{{- partial "twitter_cards.html" . }}
{{- partial "schema.html" . }}
<link rel="canonical" href="{{ .Permalink }}">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
{{- with .OutputFormats.Get "rss" }}
<link rel="alternate" type="application/rss+xml" title="{{ $.Site.Title }}" href="{{ .Permalink }}">
{{- end }}
<!-- Preload critical fonts -->
<link rel="preload" href="/fonts/CommitMono-700-Regular.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/SourceSerif4.woff2" as="font" type="font/woff2" crossorigin>
<!-- Styles -->
{{- $tokens := resources.Get "css/tokens.css" }}
{{- $base := resources.Get "css/base.css" }}
{{- $components := resources.Get "css/components.css" }}
{{- $layouts := resources.Get "css/layouts.css" }}
{{- $css := slice $tokens $base $components $layouts | resources.Concat "css/main.css" }}
{{- if hugo.IsProduction }}
{{- $css = $css | resources.Minify | resources.Fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
{{- else }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}">
{{- end }}
<!-- Theme: read preference before render to avoid flash -->
<script>
(function(){
var t = localStorage.getItem('potato-theme');
if (t) { document.documentElement.setAttribute('data-theme', t); }
})();
</script>
-58
View File
@@ -1,58 +0,0 @@
<nav class="navbar" aria-label="Main navigation">
<div class="navbar__inner">
<a href="/" class="navbar__logo" aria-label="{{ .Site.Title }} home">
<img class="spud" src="/favicon.svg" alt="" aria-hidden="true" width="24" height="24">
<span>thepotato.tech</span>
<span class="shrug" aria-hidden="true">¯\_(ツ)_/¯</span>
</a>
<button class="navbar__hamburger" aria-label="Toggle navigation" aria-expanded="false" id="nav-hamburger">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
<line x1="2" y1="5" x2="18" y2="5"/><line x1="2" y1="10" x2="18" y2="10"/><line x1="2" y1="15" x2="18" y2="15"/>
</svg>
</button>
<ul class="navbar__links" id="nav-links" role="list">
{{- $cur := . }}
{{- range .Site.Menus.main }}
<li><a href="{{ .URL }}"{{ if $cur.IsMenuCurrent "main" . }} aria-current="page"{{ end }}>{{ .Name }}</a></li>
{{- end }}
<li>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark mode">
<svg class="icon-sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="icon-moon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<span class="sr-only">Toggle theme</span>
</button>
</li>
</ul>
</div>
</nav>
<script>
(function(){
var btn = document.getElementById('theme-toggle');
if (btn) btn.addEventListener('click', function(){
var root = document.documentElement;
var cur = root.getAttribute('data-theme');
if (!cur) cur = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
var next = cur === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
localStorage.setItem('potato-theme', next);
});
var burger = document.getElementById('nav-hamburger');
var links = document.getElementById('nav-links');
if (burger && links) burger.addEventListener('click', function(){
var open = links.classList.toggle('is-open');
burger.setAttribute('aria-expanded', open ? 'true' : 'false');
});
})();
</script>
-16
View File
@@ -1,16 +0,0 @@
{{/* Open Graph meta tags */}}
{{- $title := cond .IsHome .Site.Title .Title -}}
{{- $desc := .Description | default .Site.Params.description -}}
{{- $type := cond .IsHome "website" "article" -}}
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:site_name" content="{{ .Site.Title }}">
<meta property="og:title" content="{{ $title }}">
<meta property="og:description" content="{{ $desc }}">
<meta property="og:locale" content="{{ .Site.Language.Lang }}">
<meta property="og:type" content="{{ $type }}">
{{- with .Params.image }}<meta property="og:image" content="{{ . | absURL }}">{{- end }}
{{- if not .IsHome }}
{{- with .Date }}<meta property="article:published_time" content="{{ .Format "2006-01-02T15:04:05Z07:00" }}">{{- end }}
{{- with .Lastmod }}<meta property="article:modified_time" content="{{ .Format "2006-01-02T15:04:05Z07:00" }}">{{- end }}
{{- range .Params.tags }}<meta property="article:tag" content="{{ . }}">{{- end }}
{{- end }}
-12
View File
@@ -1,12 +0,0 @@
{{- $past := eq .status "past" }}
<div class="card{{ if $past }} card--past{{ end }}">
<h3 class="card__title"><span class="dot" aria-hidden="true"></span> {{ .name }}</h3>
<p class="card__role">
{{- with .role }}{{ . }}{{ end }}
{{- with .when }}<span class="card__when"> · {{ . }}</span>{{ end }}
</p>
<div class="card__body">
<p>{{ .description }}</p>
{{- with .url }}<p class="card__link"><a href="{{ . }}" rel="noopener">{{ . | replaceRE "^https?://" "" | replaceRE "/$" "" }} →</a></p>{{ end }}
</div>
</div>
-33
View File
@@ -1,33 +0,0 @@
{{/* Schema.org — Person on home, BlogPosting on articles */}}
{{- if .IsHome }}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "{{ .Site.Params.author }}",
"url": "{{ .Site.BaseURL }}",
"jobTitle": "Infrastructure, Network & Systems Engineer",
"description": "{{ .Site.Params.description }}",
"sameAs": [
{{- $links := slice -}}
{{- with .Site.Params.social.github }}{{ $links = $links | append (printf "https://github.com/%s" .) }}{{ end -}}
{{- with .Site.Params.social.bluesky }}{{ $links = $links | append (printf "https://bsky.app/profile/%s" (replace . "@" "")) }}{{ end -}}
{{- with .Site.Params.social.linkedin }}{{ $links = $links | append (printf "https://linkedin.com/in/%s" .) }}{{ end -}}
{{ delimit (apply $links "printf" "%q" ".") ", " }}
]
}
</script>
{{- else if .IsPage }}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": {{ .Title | jsonify }},
"description": {{ (.Description | default .Summary) | jsonify }},
"url": "{{ .Permalink }}",
"datePublished": "{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}",
"dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}",
"author": { "@type": "Person", "name": "{{ .Site.Params.author }}", "url": "{{ .Site.BaseURL }}" }
}
</script>
{{- end }}
-7
View File
@@ -1,7 +0,0 @@
{{/* Twitter/X card meta */}}
{{- $title := cond .IsHome .Site.Title .Title -}}
{{- $desc := .Description | default .Site.Params.description -}}
<meta name="twitter:card" content="{{ cond (isset .Params "image") "summary_large_image" "summary" }}">
<meta name="twitter:title" content="{{ $title }}">
<meta name="twitter:description" content="{{ $desc }}">
{{- with .Params.image }}<meta name="twitter:image" content="{{ . | absURL }}">{{- end }}
-10
View File
@@ -1,10 +0,0 @@
{{- /*
Local replacement for Hugo's built-in `gist` shortcode, which was
deprecated in v0.143.0. Same call signature:
{{< gist USERNAME GIST_ID [FILE] >}}
Embeds a GitHub gist via its JS include. The optional third argument
scopes the embed to a single file within the gist.
*/ -}}
<script type="application/javascript" src="https://gist.github.com/{{ index .Params 0 }}/{{ index .Params 1 }}.js{{ with .Get 2 }}?file={{ . }}{{ end }}"></script>
-11
View File
@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="12" fill="#efe7d6"/>
<path d="M20 22c-5 4-8 12-4 22 3 7 12 11 22 8 10-3 16-13 12-24-3-8-11-12-19-11-4 .5-8 2-11 5z" fill="#8a5622"/>
<path d="M22 25c-3 3-5 9-2 16 2 5 8 8 15 6 7-2 11-9 8-16-2-6-8-9-14-8-3 .3-5 1-7 2z" fill="#a06a30" opacity="0.55"/>
<circle cx="26" cy="30" r="2.1" fill="#241d12" opacity="0.7"/>
<circle cx="38" cy="26" r="1.7" fill="#241d12" opacity="0.7"/>
<circle cx="42" cy="40" r="2" fill="#241d12" opacity="0.7"/>
<circle cx="30" cy="44" r="1.6" fill="#241d12" opacity="0.7"/>
<path d="M38 25c1-5 4-8 8-8" fill="none" stroke="#697a2b" stroke-width="3" stroke-linecap="round"/>
<circle cx="46" cy="16" r="3" fill="#7d9134"/>
</svg>

Before

Width:  |  Height:  |  Size: 789 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-225
View File
@@ -1,225 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json",
"basics": {
"name": "Neil Hanlon",
"label": "Infrastructure Consultant",
"email": "neil@shrug.pw",
"url": "https://thepotato.tech",
"summary": "Infrastructure consultant specializing in Linux systems, automation, and reliable platform delivery. I help organizations build, migrate, and operate production environments — and contribute upstream to the tools that power them.",
"location": {
"region": "Massachusetts",
"countryCode": "US"
},
"profiles": [
{
"network": "GitHub",
"username": "NeilHanlon",
"url": "https://github.com/NeilHanlon"
},
{
"network": "Bluesky",
"username": "shrug.pw",
"url": "https://bsky.app/profile/shrug.pw"
}
]
},
"work": [
{
"name": "Shrug PW Consulting",
"position": "Owner, Principal Consultant",
"url": "https://shrugpw.com",
"location": "Bedford, MA",
"startDate": "2025",
"highlights": [
"Architect and deploy bare metal, containerized, and cloud infrastructure for clients across defense, education, and enterprise sectors.",
"Design and build standardized Linux system images (Ubuntu, RHEL, Rocky) and deployment pipelines using Ansible, Puppet, and custom automation tooling.",
"Lead complex infrastructure migrations and disaster recovery efforts, including crisis response and system restoration for time-critical environments.",
"Build custom monitoring, alerting, and observability solutions delivering actionable insights while reducing alert fatigue.",
"Design and deploy production network infrastructure including routing, firewalls, VLANs, wireless, and fiber connectivity for multi-site organizations.",
"Develop provisioning documentation, runbooks, and support procedures for client handoff and knowledge transfer.",
"Provide open source strategy consulting and technical governance for organizations adopting or contributing to FOSS ecosystems."
]
},
{
"name": "Ctrl IQ (CIQ)",
"position": "Senior Infrastructure Engineer",
"location": "Remote",
"startDate": "2021",
"endDate": "2025",
"highlights": [
"Performed formally-sponsored contributions to Rocky Linux and RESF infrastructure, bridging company goals and upstream project sustainability.",
"Led cross-functional efforts to scale CIQ's build and release infrastructure for RHEL-derivative products based on Rocky.",
"Designed and maintained hybrid CI/CD pipelines for open and closed-source systems, integrating GitLab, Koji, and internal build/test runners.",
"Championed upstream-first development practices, ensuring internal engineering work could be contributed back cleanly to RESF when appropriate.",
"Provided technical leadership and emergency response across CIQ's internal infrastructure stack — networking, packaging, on-call rotation.",
"Advised CIQ leadership on open source community dynamics, trust-building, and governance best practices."
]
},
{
"name": "KAYAK Software Corporation",
"position": "DevOps Engineer, Senior Network Engineer",
"location": "Concord, MA",
"startDate": "2015",
"endDate": "2021",
"highlights": [
"Managed production and corporate networks across two data centers and 14+ global offices.",
"Re-architected core switching to a modern spine/leaf fabric using BigSwitch Cloud Fabric (OpenDaylight).",
"Replaced aging Juniper gear and unstable edge routers; improved interconnects with private links and a Boston POP for AWS Direct Connect.",
"Designed and implemented a PII \"bubble\" to segment sensitive workloads from general production traffic.",
"Led CDN modernization using Fastly + Varnish; built traffic shaping and A/B testing config.",
"Maintained RADIUS and SAML-based MFA proxies for secure access to network gear.",
"Owned alerting/on-call infrastructure; migrated incident workflows from PagerDuty to OpsGenie.",
"Introduced Apache Airflow for analytics engineering, improving billing/reporting pipelines."
]
},
{
"name": "ResolveX Inc.",
"position": "Advisory Board Member & Technical Advisor",
"url": "https://resolvex.com",
"location": "Remote",
"startDate": "2025",
"highlights": [
"Advise on infrastructure architecture and operational resilience for advanced call-center software serving crisis helplines, including the 988 Suicide & Crisis Lifeline.",
"Provide scaling and reliability guidance for a team building mission-critical systems for mental-health and crisis lines."
]
}
],
"volunteer": [
{
"organization": "Rocky Enterprise Software Foundation (RESF) — Rocky Linux",
"position": "Director, Infrastructure",
"location": "Remote (Volunteer)",
"startDate": "2020",
"endDate": "2026",
"highlights": [
"Co-founded and led infrastructure for the open source rebuild of CentOS Linux (Rocky Linux), coordinating a global team of more than thirty volunteers.",
"Designed and operated hybrid infrastructure spanning public cloud and physical data centers for global mirror distribution, CI/CD pipelines, and release engineering workflows.",
"Built and maintained scalable public-facing services — Koji builders, HTTP/RSYNC pools, mirrorlist, and signing infrastructure — supporting 3 billion+ requests/month and 1+ PB/month of traffic.",
"Developed and maintained Peridot, the open source build system powering Rocky Linux and CIQ's RHEL-derivative product lifecycle, for scalable, reproducible, secure builds.",
"Built and maintained a cryptographic enclave for UEFI Secure Boot signing, enabling trusted boot for Rocky Linux and downstream derivatives.",
"Architected secure-by-default systems and enforced best practices in CI/CD, credential management, artifact signing, and endpoint access control.",
"Led incident response and coordinated recovery for critical infrastructure outages across multiple time zones and providers.",
"Managed infrastructure sponsorships and partner relationships for long-term project sustainability and cost-effective operations."
]
},
{
"organization": "Rocky Enterprise Software Foundation (RESF)",
"position": "Founding Board Member",
"location": "Remote (Volunteer)",
"startDate": "2020",
"endDate": "2026",
"highlights": [
"Established the RESF to support community-driven governance, infrastructure, and lifecycle planning for Rocky Linux and other open source projects.",
"Represented technical community perspectives on the RESF Advisory Board and helped shape project direction and policy.",
"Advised on growth, sustainability, and alignment between corporate partners and community goals."
]
},
{
"organization": "The Fedora Project",
"position": "Contributor and Packager",
"url": "https://fedoraproject.org",
"location": "Remote (Volunteer)",
"startDate": "2020",
"highlights": [
"Maintain and test packages in EPEL and Fedora branches.",
"Contributed to the Fedora RISC-V SIG, supporting alt-arch enablement and early ecosystem work.",
"Participated in Fedora CommOps, helping shape community engagement strategies and project visibility.",
"Advocate for upstream-first workflows and maintain contributor-friendly build/test automation."
]
},
{
"organization": "OpenStack",
"position": "OpenStack-Ansible Core Reviewer",
"url": "https://opendev.org/openstack/openstack-ansible",
"location": "Remote (Volunteer)",
"startDate": "2022",
"highlights": [
"Core reviewer for OpenStack-Ansible, maintaining and improving the deployment tooling and automation for running OpenStack on enterprise Linux.",
"Review patches, mentor contributors, and help keep releases stable and secure.",
"Focus on test coverage, CI modernization, and tighter integration with upstream OpenStack projects."
]
},
{
"organization": "The CentOS Project",
"position": "SIG Contributor — Cloud, NFV, and Hyperscale",
"url": "https://www.centos.org",
"location": "Remote (Volunteer)",
"startDate": "2020",
"highlights": [
"Project Team Lead (PTL) for RDO's transition into the CentOS Cloud SIG, steering where RDO's OpenStack packaging lives and how it ships.",
"Maintain Open vSwitch (OVS) and Open Virtual Network (OVN) packages and their dependencies for the NFV SIG — keeping working versions available for EL9+ users, including Rocky Linux and AlmaLinux, insulated from churn in CentOS Stream.",
"Contribute packaging and maintenance effort to the Hyperscale SIG.",
"Long-time CentOS user and community member since 2013."
]
}
],
"education": [
{
"institution": "Wentworth Institute of Technology",
"area": "Computer Science",
"studyType": "B.S.",
"startDate": "2013",
"endDate": "2017",
"courses": [
"Coursework in algorithms, compiler design, networking, security, software architecture, and systems programming."
]
}
],
"skills": [
{
"name": "Open Source",
"keywords": [
"Rocky Linux Infra Lead",
"Fedora / EPEL Contributor",
"Community Governance",
"Upstream-first Development",
"Release Engineering",
"OpenELA TSC"
]
},
{
"name": "Systems Engineering",
"keywords": [
"Secure Infra Architecture",
"CDN + Edge Services",
"Cloud: AWS, Azure, GCP",
"Infrastructure-as-Code",
"Containers / OCI / K8s"
]
},
{
"name": "Networking",
"keywords": [
"BGP, OSPF, Juniper",
"Linux Routing / Firewalling",
"Cisco ASA / R+S",
"Arista EOS, Mikrotik",
"IPv6 Transition",
"SD-WAN",
"Load Balancing / ADC"
]
},
{
"name": "Programming",
"keywords": [
"Python, Bash, Golang",
"Ansible, Puppet, Terraform",
"Pulumi, Lua, Ruby, PHP"
]
},
{
"name": "Tooling",
"keywords": [
"Git / GitOps, CI/CD",
"Prometheus, Grafana",
"rpm / mock / koji",
"GNU/Linux, Ubuntu",
"Red Hat Enterprise Linux"
]
}
],
"meta": {
"canonical": "https://thepotato.tech/resume.json",
"version": "1"
}
}
Submodule themes/hugo-sustain added at 3f1d648b03