Compare commits
2
Commits
main
..
neil-patch-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74fd433dde | ||
|
|
4287c88bf2 |
@@ -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,4 +1,3 @@
|
||||
public/
|
||||
resources/
|
||||
pages.git/
|
||||
.hugo_build.lock
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "themes/hugo-sustain"]
|
||||
path = themes/hugo-sustain
|
||||
url = https://git.shrug.pw/neil/hugo-sustain
|
||||
@@ -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: pull_close
|
||||
|
||||
publish:
|
||||
image: git.shrug.pw/neil/containers/woodpecker-ci:latest
|
||||
secrets: [ssh_key, GITLAB_DEPLOY_TOKEN]
|
||||
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]
|
||||
|
||||
@@ -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}`
|
||||
});
|
||||
@@ -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;}
|
||||
}
|
||||
@@ -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;}
|
||||
}
|
||||
@@ -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;}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
$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;
|
||||
}
|
||||
|
||||
figure.inline {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
figure.inline-40 {
|
||||
display: inline-flex;
|
||||
height: auto;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
figure.inline-60 {
|
||||
display: inline-flex;
|
||||
height: auto;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
figure.inline-80 {
|
||||
display: inline-flex;
|
||||
height: auto;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
figure.right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
figure.left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
figure > img {
|
||||
display: inline-flex;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.clearfix {
|
||||
overflow: auto;
|
||||
}
|
||||
.clearfix::after {
|
||||
content: "";
|
||||
clear: both;
|
||||
display: table;
|
||||
}
|
||||
Executable
+52
@@ -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
@@ -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].
|
||||
|
||||
For more check out my academic & professional [resume].
|
||||
|
||||
I'm also available for [consulting].
|
||||
|
||||
Contact me at [@shrug.pw] or by yelling really really loud.
|
||||
Contact me at [@NeilHanlon] or by yelling really really loud.
|
||||
|
||||
|
||||
|
||||
[projects]: /projects
|
||||
[resume]: https://shrug.pw/resume.pdf
|
||||
[@shrug.pw]: https://bsky.app/profile/shrug.pw
|
||||
[@NeilHanlon]: https://twitter.com/NeilHanlon
|
||||
[instagram]: https://instagram.com/noellathekitty
|
||||
[consulting]: https://shrugpw.com
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -32,16 +32,6 @@ using an [ESP32](https://www.espressif.com/en/products/socs/esp32) and
|
||||
visualize your soldering statistics, to automatically turn on an exhaust fan
|
||||
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](https://www.pine64.org/pinecil/) is an open-source soldering iron
|
||||
@@ -67,8 +57,6 @@ a genuine Pinecil, and how to avoid fakes.
|
||||
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
|
||||
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
|
||||
|
||||
@@ -104,40 +92,25 @@ instructions in the repository.
|
||||
|
||||
1. Find the latest sucessful actions run on the ironos repo
|
||||
[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' (-)
|
||||
button. The screen should **not** turn on. If on Linux, `dmesg` should report
|
||||
seeing the BL706 as a serial device.
|
||||
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
|
||||
|
||||
NOTE: You may ignore the .dfu and .hex files provided in the .zip file.
|
||||
### Pineflash
|
||||
|
||||
##### Pinecilv2 vs Pinecilv2_multi-lang
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
[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
|
||||
Pinecil, similar to the Pine64 updater utility for the v1 Pinecil. You can find
|
||||
more information on the PineFlash
|
||||
[GitHub repo](https://github.com/Spagett1/PineFlash). Feel free to check it out
|
||||
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 (BLE) is a wireless communication protocol that is designed
|
||||
@@ -145,9 +118,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
|
||||
options for how to get your Pinecil talking to your computer.
|
||||
|
||||
## Interacting with your Pinecil over Bluetooth
|
||||
|
||||
### PineSAM by [Builder555](https://github.com/builder555)
|
||||
## PineSAM and Joric's UI
|
||||
|
||||
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
|
||||
@@ -155,33 +126,10 @@ 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
|
||||
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
|
||||
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" >}}
|
||||
|
||||
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.
|
||||
Uniquely, it allows users to set temperature presets for one-click changes
|
||||
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.
|
||||
|
||||
{{< 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
|
||||
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
|
||||
retrieving BLE data from the Pinecil.
|
||||
|
||||
#### Setting up PineSAM
|
||||
|
||||
See the
|
||||
[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
|
||||
in the #pinecil channel on Pine64's Discord or Telegram chat.
|
||||
|
||||
### [Joric](https://github.com/joric/)'s BLE API
|
||||
|
||||
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
|
||||
@@ -192,7 +140,26 @@ device. You can also find the source code for Joric UI on
|
||||
|
||||
{{< figure src="joric-ui.png" alt="Joric's UI" class="inline-60 right" >}}
|
||||
|
||||
#### Setting up Joric's UI
|
||||
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.
|
||||
Uniquely, it allows users to set temperature presets for one-click changes
|
||||
between temperatures--for example to switch between leaded and unleaded solder.
|
||||
|
||||
{{< 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
|
||||
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
|
||||
retrieving BLE data from the Pinecil.
|
||||
|
||||
### Setting up PineSAM
|
||||
|
||||
See the
|
||||
[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
|
||||
in the #pinecil channel on Pine64's Discord or Telegram chat.
|
||||
|
||||
### Setting up Joric's UI
|
||||
|
||||
No setup needed! Just browse to
|
||||
[https://joric.github.io/pinecil/](https://joric.github.io/pinecil/) in a
|
||||
@@ -218,7 +185,7 @@ 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
|
||||
[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!
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -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.
|
||||
@@ -3,5 +3,4 @@ categories: ["projects"]
|
||||
date: "2016-10-02T22:55:05-04:00"
|
||||
tags: ["projects"]
|
||||
title: "Projects"
|
||||
layout: "projects"
|
||||
---
|
||||
|
||||
@@ -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
@@ -1,76 +1,6 @@
|
||||
name: Projects
|
||||
source:
|
||||
# --- Currently ---
|
||||
- name: Shrug PW Consulting
|
||||
role: Founder & Principal Consultant
|
||||
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.
|
||||
- icon: fa fa-github
|
||||
name: Rocky Linux
|
||||
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.
|
||||
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
@@ -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: 2021–2025
|
||||
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: 2020–2026
|
||||
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: 2020–2026
|
||||
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: 2015–2021
|
||||
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: 2013–2017
|
||||
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.
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/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
|
||||
|
||||
curl -X POST --fail \
|
||||
-F token=$GITLAB_DEPLOY_TOKEN \
|
||||
-F ref=main \
|
||||
https://gitlab.com/api/v4/projects/29559707/trigger/pipeline
|
||||
@@ -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/"
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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>
|
||||
& 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 }}
|
||||
@@ -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>
|
||||
© {{ 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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>
|
||||
@@ -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.
@@ -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
+1
Submodule themes/hugo-sustain added at 3f1d648b03
Reference in New Issue
Block a user