feat(deploy): container, compose examples, smoke test and 2021 archive
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
No files matched your search
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
.tmp
|
||||
.astro
|
||||
.superpowers
|
||||
.gitea
|
||||
node_modules
|
||||
dist
|
||||
test-results
|
||||
playwright-report
|
||||
e2e
|
||||
test
|
||||
fixtures
|
||||
docs
|
||||
scripts
|
||||
deploy
|
||||
archive
|
||||
mockups
|
||||
@@ -26,8 +26,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: docker build --build-arg APP_VERSION=${{ github.sha }} -t "$IMAGE:${{ github.sha }}" .
|
||||
# Task 14 adds the image smoke step:
|
||||
# - run: scripts/smoke-test.sh "$IMAGE:${{ github.sha }}"
|
||||
- run: scripts/smoke-test.sh "$IMAGE:${{ github.sha }}"
|
||||
- if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.lucaswinther.info -u "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# portfolio-next: polls the content repo, builds the static site with Astro, serves it.
|
||||
FROM oven/bun:1.3.14-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git openssh-client ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=bun:bun package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile --production && chown -R bun:bun /app
|
||||
COPY --chown=bun:bun astro.config.mjs tsconfig.json ./
|
||||
COPY --chown=bun:bun public ./public
|
||||
COPY --chown=bun:bun src ./src
|
||||
|
||||
RUN printf '#!/bin/sh\nexec bun /app/src/cli.ts "$@"\n' > /usr/local/bin/site \
|
||||
&& chmod +x /usr/local/bin/site \
|
||||
&& mkdir /data && chown bun:bun /data
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=$APP_VERSION \
|
||||
DATA_DIR=/data \
|
||||
PORT=8080 \
|
||||
POLL_INTERVAL=1h \
|
||||
SOURCES_INTERVAL=6h \
|
||||
CONTENT_BRANCH=main
|
||||
|
||||
USER bun
|
||||
VOLUME /data
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["site"]
|
||||
CMD ["serve"]
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 267 KiB |
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="A page containing the various projects of Lucas Winther"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>Lucas Winther Portfolio</title><link href="/static/css/main.3c14c85a.chunk.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><noscript><img src="https://analytics.lucaswinther.info/ingress/a95f48ac-7e9f-4650-a9ae-43a67de924e7/pixel.gif"></noscript><script defer="defer" src="https://analytics.lucaswinther.info/ingress/a95f48ac-7e9f-4650-a9ae-43a67de924e7/script.js"></script><script>!function(e){function r(r){for(var n,a,i=r[0],c=r[1],l=r[2],p=0,s=[];p<i.length;p++)a=i[p],Object.prototype.hasOwnProperty.call(o,a)&&o[a]&&s.push(o[a][0]),o[a]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(f&&f(r);s.length;)s.shift()();return u.push.apply(u,l||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,i=1;i<t.length;i++){var c=t[i];0!==o[c]&&(n=!1)}n&&(u.splice(r--,1),e=a(a.s=t[0]))}return e}var n={},o={1:0},u=[];function a(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,a),t.l=!0,t.exports}a.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var u,i=document.createElement("script");i.charset="utf-8",i.timeout=120,a.nc&&i.setAttribute("nonce",a.nc),i.src=function(e){return a.p+"static/js/"+({}[e]||e)+"."+{3:"68f8dbb6"}[e]+".chunk.js"}(e);var c=new Error;u=function(r){i.onerror=i.onload=null,clearTimeout(l);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),u=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+u+")",c.name="ChunkLoadError",c.type=n,c.request=u,t[1](c)}o[e]=void 0}};var l=setTimeout((function(){u({type:"timeout",target:i})}),12e4);i.onerror=i.onload=u,document.head.appendChild(i)}return Promise.all(r)},a.m=e,a.c=n,a.d=function(e,r,t){a.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},a.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,r){if(1&r&&(e=a(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(a.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)a.d(t,n,function(r){return e[r]}.bind(null,n));return t},a.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return a.d(r,"a",r),r},a.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},a.p="/",a.oe=function(e){throw console.error(e),e};var i=this["webpackJsonpprojects-page"]=this["webpackJsonpprojects-page"]||[],c=i.push.bind(i);i.push=r,i=i.slice();for(var l=0;l<i.length;l++)r(i[l]);var f=c;t()}([])</script><script src="/static/js/2.8de6a419.chunk.js"></script><script src="/static/js/main.b2a5bb11.chunk.js"></script></body></html>
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"gacha-event-tracker": {
|
||||
"renderOrder": 1,
|
||||
"title": "Event Tracker for Gacha Games",
|
||||
"description": "Used to easily figure out which live service games events to prioritize.",
|
||||
"tags": ["scraping", "webapp", "javascript", "made_with_ai"],
|
||||
"github": "https://github.com/StereotypicalCat/gacha-event-tracker",
|
||||
"livedemo": "https://gacha-event-tracker.lucaswinther.info/"
|
||||
},
|
||||
"config": {
|
||||
"renderOrder": 2,
|
||||
"title": "Config",
|
||||
"description": "A repository to store, sync and manage docker-compose based deployment on servers",
|
||||
"tags": ["ansible", "configuration management", "infrastructure-as-code", "made_with_ai"],
|
||||
"github": "https://github.com/StereotypicalCat/config"
|
||||
},
|
||||
"bachelor-report": {
|
||||
"renderOrder": 3,
|
||||
"title": "Bachelor Report",
|
||||
"description": "My bachelor thesis on using reputation systems in peer-to-peer archival",
|
||||
"tags": ["schoolwork", "node.js", "javascript", "peer2peer"],
|
||||
"github": "https://github.com/StereotypicalCat/HyperCache",
|
||||
"academics": "https://lucaswinther.info/documents/bachelorproject-report.pdf"
|
||||
},
|
||||
"student-dashboard": {
|
||||
"renderOrder": 4,
|
||||
"title": "Student Dashboard",
|
||||
"description": "A concept student dashboard web-app, built upon conducted user studies.",
|
||||
"tags": ["react", "schoolwork", "webapp", "dashboard"],
|
||||
"livedemo": "https://student-dashboard.lucaswinther.info/",
|
||||
"academics": "https://lucaswinther.info/documents/student-dashboard-report.pdf"
|
||||
},
|
||||
"chess-swap": {
|
||||
"renderOrder": 5,
|
||||
"title": "Chessswap",
|
||||
"description": "A game of chess, where the board swaps every few turns",
|
||||
"tags": ["react", "webapp"],
|
||||
"github": "https://github.com/StereotypicalCat/chess-swap-frontend",
|
||||
"livedemo": "https://chessswap.lucaswinther.info"
|
||||
},
|
||||
"budget": {
|
||||
"renderOrder": 6,
|
||||
"title": "Monthly Expenses Tracker",
|
||||
"description": "A table program to add monthly expenses, and keep an overview of their monthly costs in dkk.",
|
||||
"tags": ["javascript", "webapp"],
|
||||
"github": "https://github.com/StereotypicalCat/monthly-expenses-tracker",
|
||||
"livedemo": "https://budget.lucaswinther.info/"
|
||||
},
|
||||
"mr-rowing":{
|
||||
"renderOrder": 7,
|
||||
"title": "Mixed-Reality Rowing Game",
|
||||
"description": "A fun rowing game for two people, that uses an arduino and two real oar's to be played",
|
||||
"tags": ["unity", "arduino", "C#"],
|
||||
"github": "https://github.com/StereotypicalCat/XR-Rowing-Game"
|
||||
},
|
||||
"vr-classroom": {
|
||||
"renderOrder": 8,
|
||||
"title": "VR-Classroom",
|
||||
"description": "A prototype website where you can register yourself, and then create + sign up for classes",
|
||||
"tags": ["javascript", "node.js"],
|
||||
"github": "https://github.com/StereotypicalCat/vr-classroom-website-api",
|
||||
"academics": "https://lucaswinther.info/documents/Vr-Classroom-Report.pdf",
|
||||
"livedemo": "https://www.youtube.com/watch?v=sV9yc5q_ARE"
|
||||
},
|
||||
"lol-winrate-discord-bot": {
|
||||
"renderOrder": 9,
|
||||
"title": "LoL Winrate Discord Bot",
|
||||
"description": "A discord bot that interfaces with a custom built winrate api regarding the game League of Legends",
|
||||
"tags": ["node.js", "javascript", "bot", "discord"],
|
||||
"github": "https://github.com/StereotypicalCat/lol-winrate-discord-bot"
|
||||
},
|
||||
"censor-simulator": {
|
||||
"renderOrder": 10,
|
||||
"title": "Censor Simulator",
|
||||
"description": "A Virtual Reality experience, where you have to grade a hopeful student. Made as highschool examination project",
|
||||
"tags": ["unity", "VR", "C#"],
|
||||
"livedemo": "https://censor-simulator.lucaswinther.info"
|
||||
},
|
||||
"breakout-python": {
|
||||
"renderOrder": 11,
|
||||
"title": "BreakoutPython",
|
||||
"description": "A version of breakout coded in python as my highschool programming examination project",
|
||||
"tags": ["python", "game"],
|
||||
"github": "https://github.com/StereotypicalCat/StereotypicalBreakout"
|
||||
},
|
||||
"tribute-to-bourlaug": {
|
||||
"renderOrder": 12,
|
||||
"title": "Tribute to Norman Borlaugh",
|
||||
"description": "A tribute website, which was one of the first websites I ever built, based on user stories from freecodecamp.org.",
|
||||
"tags": ["html", "css", "javascript", "website"],
|
||||
"github": "https://github.com/StereotypicalCat/tribute-to-borlaugh",
|
||||
"livedemo": "https://tributetoborlaugh.lucaswinther.info/"
|
||||
},
|
||||
"portfolio": {
|
||||
"renderOrder": 13,
|
||||
"title": "Portfolio",
|
||||
"description": "The portfolio you are viewing right now. Features data driven approach to loading projects",
|
||||
"tags": ["react", "webapp", "website"],
|
||||
"github": "https://github.com/StereotypicalCat/portfolio"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
body{height:100vh;margin:0;font-family:"Source Sans Pro",sans-serif;scroll-behavior:smooth}#headerContainer{background-size:auto,150%;background:linear-gradient(rgba(0,0,0,.3),rgba(0,0,0,.3)) no-repeat;background-image:linear-gradient(#3e4d56,#71675b 45%,#231d1a)}header{text-align:center;padding:20% 10%;color:#bab5ab;line-height:1.8em;font-size:1.8em;font-weight:500}header img{height:128px;width:128px;border-radius:50%}header strong{color:#fff}header a{color:#d3d3d3;transition:color .25s;margin-left:20px}header a:hover{transition:color .25s;color:#40e0d0}#appContainer{background:#181a1b;margin:0;min-height:100vh}@media only screen and (min-width:600px){#headerContainer{position:fixed;top:0;left:0;width:inherit;height:100vh}header{text-align:right}}#appContainer{color:#d5d1cb;padding-left:2em}#appContainer h1{display:inline-block;margin-right:1em;margin-top:0;margin-bottom:0}#search{margin-top:0;padding-top:4em;margin-right:2em}#projectsContainer{margin:20px}#appContainer img{margin-left:0;margin-right:0;max-width:100%}#appContainer .MuiCard-root{box-shadow:0 .5em 1em -.125em rgba(10,10,10,.4),0 0 0 1px rgba(10,10,10,.08)}#appContainer a:visited{color:#43adff}@media only screen and (max-width:600px){#projectsContainer{margin:10px 5px 30px}}
|
||||
/*# sourceMappingURL=main.3c14c85a.chunk.css.map */
|
||||
@@ -0,0 +1,3 @@
|
||||
/*! For license information please see 2.8de6a419.chunk.js.LICENSE.txt */
|
||||
(this["webpackJsonpprojects-page"]=this["webpackJsonpprojects-page"]||[]).push([[2],[function(e,t,n){"use strict";e.exports=n(66)},function(e,t,n){"use strict";function r(){return(r=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e}).apply(this,arguments)}n.d(t,"a",(function(){return r}))},function(e,t,n){e.exports=n(74)()},function(e,t,n){"use strict";n.d(t,"a",(function(){return o}));var r=n(16);function o(e,t){if(null==e)return{};var n,o,i=Object(r.a)(e,t);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(e);for(o=0;o<a.length;o++)n=a[o],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(i[n]=e[n])}return i}},function(e,t,n){"use strict";function r(e){var t,n,o="";if("string"===typeof e||"number"===typeof e)o+=e;else if("object"===typeof e)if(Array.isArray(e))for(t=0;t<e.length;t++)e[t]&&(n=r(e[t]))&&(o&&(o+=" "),o+=n);else for(t in e)e[t]&&(o&&(o+=" "),o+=t);return o}t.a=function(){for(var e,t,n=0,o="";n<arguments.length;)(e=arguments[n++])&&(t=r(e))&&(o&&(o+=" "),o+=t);return o}},function(e,t,n){"use strict";var r=n(1),o=n(3),i=n(0),a=n.n(i),l=(n(2),n(40)),u=n.n(l),s=n(117),c=n(108),f=n(80),d=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return function(n){var i=t.defaultTheme,l=t.withTheme,d=void 0!==l&&l,p=t.name,h=Object(o.a)(t,["defaultTheme","withTheme","name"]);var m=p,v=Object(s.a)(e,Object(r.a)({defaultTheme:i,Component:n,name:p||n.displayName,classNamePrefix:m},h)),y=a.a.forwardRef((function(e,t){e.classes;var l,u=e.innerRef,s=Object(o.a)(e,["classes","innerRef"]),h=v(Object(r.a)({},n.defaultProps,e)),m=s;return("string"===typeof p||d)&&(l=Object(f.a)()||i,p&&(m=Object(c.a)({theme:l,name:p,props:s})),d&&!m.theme&&(m.theme=l)),a.a.createElement(n,Object(r.a)({ref:u||t,classes:h},m))}));return u()(y,n),y}},p=n(21);t.a=function(e,t){return d(e,Object(r.a)({defaultTheme:p.a},t))}},function(e,t,n){"use strict";e.exports=n(65)},function(e,t,n){"use strict";n.d(t,"a",(function(){return o}));var r=n(54);function o(e){if("string"!==typeof e)throw new Error(Object(r.a)(7));return e.charAt(0).toUpperCase()+e.slice(1)}},,function(e,t,n){"use strict";!function e(){if("undefined"!==typeof __REACT_DEVTOOLS_GLOBAL_HOOK__&&"function"===typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE)try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}(),e.exports=n(67)},function(e,t,n){"use strict";n.d(t,"a",(function(){return i}));var r=n(0),o=n(18);function i(e,t){return r.useMemo((function(){return null==e&&null==t?null:function(n){Object(o.a)(e,n),Object(o.a)(t,n)}}),[e,t])}},function(e,t,n){"use strict";n.d(t,"c",(function(){return l})),n.d(t,"b",(function(){return s})),n.d(t,"a",(function(){return c})),n.d(t,"d",(function(){return f}));var r=n(54);function o(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:1;return Math.min(Math.max(t,e),n)}function i(e){if(e.type)return e;if("#"===e.charAt(0))return i(function(e){e=e.substr(1);var t=new RegExp(".{1,".concat(e.length>=6?2:1,"}"),"g"),n=e.match(t);return n&&1===n[0].length&&(n=n.map((function(e){return e+e}))),n?"rgb".concat(4===n.length?"a":"","(").concat(n.map((function(e,t){return t<3?parseInt(e,16):Math.round(parseInt(e,16)/255*1e3)/1e3})).join(", "),")"):""}(e));var t=e.indexOf("("),n=e.substring(0,t);if(-1===["rgb","rgba","hsl","hsla"].indexOf(n))throw new Error(Object(r.a)(3,e));var o=e.substring(t+1,e.length-1).split(",");return{type:n,values:o=o.map((function(e){return parseFloat(e)}))}}function a(e){var t=e.type,n=e.values;return-1!==t.indexOf("rgb")?n=n.map((function(e,t){return t<3?parseInt(e,10):e})):-1!==t.indexOf("hsl")&&(n[1]="".concat(n[1],"%"),n[2]="".concat(n[2],"%")),"".concat(t,"(").concat(n.join(", "),")")}function l(e,t){var n=u(e),r=u(t);return(Math.max(n,r)+.05)/(Math.min(n,r)+.05)}function u(e){var t="hsl"===(e=i(e)).type?i(function(e){var t=(e=i(e)).values,n=t[0],r=t[1]/100,o=t[2]/100,l=r*Math.min(o,1-o),u=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:(e+n/30)%12;return o-l*Math.max(Math.min(t-3,9-t,1),-1)},s="rgb",c=[Math.round(255*u(0)),Math.round(255*u(8)),Math.round(255*u(4))];return"hsla"===e.type&&(s+="a",c.push(t[3])),a({type:s,values:c})}(e)).values:e.values;return t=t.map((function(e){return(e/=255)<=.03928?e/12.92:Math.pow((e+.055)/1.055,2.4)})),Number((.2126*t[0]+.7152*t[1]+.0722*t[2]).toFixed(3))}function s(e,t){return e=i(e),t=o(t),"rgb"!==e.type&&"hsl"!==e.type||(e.type+="a"),e.values[3]=t,a(e)}function c(e,t){if(e=i(e),t=o(t),-1!==e.type.indexOf("hsl"))e.values[2]*=1-t;else if(-1!==e.type.indexOf("rgb"))for(var n=0;n<3;n+=1)e.values[n]*=1-t;return a(e)}function f(e,t){if(e=i(e),t=o(t),-1!==e.type.indexOf("hsl"))e.values[2]+=(100-e.values[2])*t;else if(-1!==e.type.indexOf("rgb"))for(var n=0;n<3;n+=1)e.values[n]+=(255-e.values[n])*t;return
Line truncated
|
||||
//# sourceMappingURL=2.8de6a419.chunk.js.map
|
||||
@@ -0,0 +1,2 @@
|
||||
(this["webpackJsonpprojects-page"]=this["webpackJsonpprojects-page"]||[]).push([[0],{70:function(e,t,n){},76:function(e,t,n){},78:function(e,t,n){"use strict";n.r(t);var c=n(6),i=n(0),a=n.n(i),r=n(9),s=n.n(r),j=function(e){e&&e instanceof Function&&n.e(3).then(n.bind(null,120)).then((function(t){var n=t.getCLS,c=t.getFID,i=t.getFCP,a=t.getLCP,r=t.getTTFB;n(e),c(e),i(e),a(e),r(e)}))},o=(n(70),n(111)),l=n(31),b=n(15),h=n(25),d=n(33),u=n(52),O=(n(76),n(53)),x=n(106),p=n(109),f=n(110),g=n(118),m=n(116),v=n(112),w=n(113),C=n(114),k=n(115),S=n(82);var y=function(){var e=Object(O.a)({palette:{type:"dark",background:{paper:"inherit"}}}),t=Object(i.useState)(!1),n=Object(d.a)(t,2),a=n[0],r=n[1],s=Object(i.useState)([]),j=Object(d.a)(s,2),y=j[0],L=j[1],P=Object(i.useState)(""),F=Object(d.a)(P,2),B=F[0],E=F[1],M=Object(i.useState)([]),I=Object(d.a)(M,2),J=I[0],T=I[1],W={keys:[{name:"title",weight:1},{name:"tags",weight:.7},{name:"description",weight:.3}]},z=function(e){return""===e?(console.log("Nothing was searched"),y):new u.a(y,W).search(e).map((function(e){return e.item}))};Object(i.useEffect)((function(){a||fetch("/info.json").then((function(e){return e.json()})).then((function(e){var t=[];Object.values(e).forEach((function(e){t.push(e)})),r(!0),L(t),T(t),console.log("Loaded data")}))}));var D=function(e){window.open(e,"_blank")},N=function(e){return Object(c.jsxs)(x.a,{disableSpacing:!0,children:[e.github?Object(c.jsx)(p.a,{onClick:function(){return D(e.github)},children:Object(c.jsx)(b.a,{icon:l.a})}):null,e.livedemo?Object(c.jsx)(p.a,{onClick:function(){return D(e.livedemo)},children:Object(c.jsx)(b.a,{icon:h.c})}):null,e.academics?Object(c.jsx)(p.a,{onClick:function(){return D(e.academics)},children:Object(c.jsx)(b.a,{icon:h.d})}):null]})};return a?Object(c.jsx)("div",{id:"appContainer",children:Object(c.jsxs)(f.a,{theme:e,children:[Object(c.jsxs)(g.a,{id:"search",children:[Object(c.jsx)("h1",{children:"My Projects"}),Object(c.jsx)(m.a,{id:"standard-basic",label:"about",onChange:function(e){E(e.target.value);var t=z(e.target.value);T(t)},value:B})]}),Object(c.jsx)(o.a,{container:!0,children:J.length>0?J.map((function(e){return Object(c.jsx)(o.a,{xs:5,item:!0,id:"projectsContainer",children:Object(c.jsxs)(v.a,{children:[Object(c.jsx)("img",{src:"/images/"+e.title+".jpg",alt:"Project"}),Object(c.jsxs)(w.a,{children:[Object(c.jsx)(C.a,{variant:"h5",gutterBottom:!0,children:e.title}),Object(c.jsx)(C.a,{children:e.description}),Object(c.jsx)(C.a,{children:Object(c.jsx)(k.a,{size:"small",color:"primary","aria-label":"large outlined primary button group",children:e.tags.map((function(e){return Object(c.jsx)(S.a,{onClick:function(){return T(z(t=e)),void E(t);var t},children:e})}))})}),N(e)]})]})})})):null})]})}):Object(c.jsx)("div",{})},L=n(49);var P=function(){return Object(c.jsxs)("div",{children:[Object(c.jsxs)(L.a,{children:[Object(c.jsx)("meta",{charSet:"utf-8"}),Object(c.jsx)("title",{children:"Lucas Winther Portfolio"})]}),Object(c.jsxs)(o.a,{container:!0,spacing:0,children:[Object(c.jsx)(o.a,{item:!0,xs:12,sm:5,id:"headerContainer",children:Object(c.jsxs)("header",{children:[Object(c.jsx)("img",{alt:"Lucas Winther",src:"images/avatar.png"}),Object(c.jsxs)("p",{children:[Object(c.jsx)("strong",{children:"My name is Lucas"}),", a ",Object(c.jsx)("br",{}),"Computer Scientist with ",Object(c.jsx)("br",{}),"passion for development."]}),Object(c.jsxs)("div",{id:"icons",children:[Object(c.jsx)("a",{href:"https://www.linkedin.com/in/lucas-winther/",children:Object(c.jsx)(b.a,{icon:l.b})}),Object(c.jsx)("a",{href:"mailto:lucas@lucaswinther.info",children:Object(c.jsx)(b.a,{icon:h.b})}),Object(c.jsx)("a",{href:"https://github.com/StereotypicalCat",children:Object(c.jsx)(b.a,{icon:l.a})}),Object(c.jsx)("a",{href:"https://blog.lucaswinther.info",children:Object(c.jsx)(b.a,{icon:h.a})})]})]})}),Object(c.jsx)(o.a,{item:!0,xs:5}),Object(c.jsx)(o.a,{item:!0,xs:12,sm:7,id:"appContainer",children:Object(c.jsx)(y,{})})]})]})};s.a.render(Object(c.jsx)(a.a.StrictMode,{children:Object(c.jsx)(P,{})}),document.getElementById("root")),j()}},[[78,1,2]]]);
|
||||
//# sourceMappingURL=main.b2a5bb11.chunk.js.map
|
||||
@@ -0,0 +1,6 @@
|
||||
# Rendered from OpenBao (kv/stacks/portfolio-next) by the compose_stack role.
|
||||
# Names only; never commit values.
|
||||
CONTENT_REPO_TOKEN=
|
||||
GHOST_CONTENT_KEY=
|
||||
TRAKT_CLIENT_ID=
|
||||
HARDCOVER_TOKEN=
|
||||
@@ -0,0 +1,50 @@
|
||||
# Try the site on any machine, against the synthetic fixture content and fixture sources:
|
||||
# no token, no proxy, no secrets. From the repository root:
|
||||
#
|
||||
# docker compose -f deploy/compose.local.yml up --build
|
||||
# open http://localhost:8080/ (and /status; SITE_PORT=… picks another port)
|
||||
#
|
||||
# `seed` turns fixtures/content into a git repository in a volume, owned by the
|
||||
# app's uid, and the app polls it like it would poll Gitea. SOURCES_FIXTURES makes
|
||||
# Ghost, Trakt and Hardcover read fixtures/sources instead of the network.
|
||||
# `docker compose -f deploy/compose.local.yml down -v` removes everything.
|
||||
services:
|
||||
seed:
|
||||
image: portfolio-next:local
|
||||
build: ..
|
||||
user: root
|
||||
entrypoint: ["sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -e
|
||||
if [ ! -d /content/.git ]; then
|
||||
cp -r /fixtures/. /content/
|
||||
git -C /content init -q -b main
|
||||
git -C /content add -A
|
||||
git -C /content -c user.name=seed -c user.email=seed@example.com commit -q -m "demo content"
|
||||
fi
|
||||
chown -R 1000:1000 /content
|
||||
volumes:
|
||||
- ../fixtures/content:/fixtures:ro
|
||||
- content:/content
|
||||
|
||||
portfolio-next:
|
||||
image: portfolio-next:local
|
||||
build: ..
|
||||
depends_on:
|
||||
seed:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
CONTENT_REPO: /content
|
||||
POLL_INTERVAL: 30s
|
||||
SOURCES_FIXTURES: /sources
|
||||
ports:
|
||||
- "${SITE_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- content:/content:ro
|
||||
- ../fixtures/sources:/sources:ro
|
||||
- data:/data
|
||||
|
||||
volumes:
|
||||
content:
|
||||
data:
|
||||
@@ -0,0 +1,22 @@
|
||||
# The 2021 site, frozen, at old.lucaswinther.info. Example payload for the config repo:
|
||||
# server/homelab/old-site/docker-compose.yml, with archive/2021/ copied next to it as ./site
|
||||
# (the archive is static files; refresh it with scripts/archive-old-site.sh). In the config
|
||||
# repo change the volume to ./site. To look at it locally without proxy-net:
|
||||
# docker run --rm -p 8081:80 -v "$PWD/archive/2021:/usr/share/nginx/html:ro" nginx:alpine
|
||||
services:
|
||||
old-site:
|
||||
container_name: old-site
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- proxy-net
|
||||
environment:
|
||||
VIRTUAL_HOST: old.lucaswinther.info
|
||||
VIRTUAL_PORT: "80"
|
||||
LETSENCRYPT_HOST: old.lucaswinther.info
|
||||
volumes:
|
||||
- ../archive/2021:/usr/share/nginx/html:ro
|
||||
|
||||
networks:
|
||||
proxy-net:
|
||||
external: true
|
||||
@@ -0,0 +1,49 @@
|
||||
# portfolio-next (lucaswinther.info), published from this Gitea's container registry.
|
||||
# Example payload for the config repo: server/homelab/portfolio-next/docker-compose.yml
|
||||
#
|
||||
# nginx-proxy writes the vhost for any container on proxy-net that sets
|
||||
# VIRTUAL_HOST, and acme-companion requests the certificate for LETSENCRYPT_HOST.
|
||||
# proxy-net is owned by nginx-proxy.yml.
|
||||
#
|
||||
# The container polls the content repository over HTTPS with a read-only token, validates
|
||||
# and builds each new commit, and swaps it live. Ghost, Trakt and Hardcover are fetched at
|
||||
# build time (every SOURCES_INTERVAL); an outage falls back to the last cached data and
|
||||
# /status names the failing source. data/ holds builds, caches and state.json, not payload.
|
||||
# The image runs as uid 1000, which owns data/.
|
||||
#
|
||||
# :latest is a moving tag. A normal deploy keeps the image already on the host;
|
||||
# picking up a new build is `-e pull=always`. To roll back, pin a commit tag:
|
||||
# gitea.lucaswinther.info/lucasw89/portfolio-next:<sha>.
|
||||
services:
|
||||
portfolio-next:
|
||||
container_name: portfolio-next
|
||||
image: gitea.lucaswinther.info/lucasw89/portfolio-next:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- proxy-net
|
||||
environment:
|
||||
VIRTUAL_HOST: lucaswinther.info
|
||||
VIRTUAL_PORT: "8080"
|
||||
LETSENCRYPT_HOST: lucaswinther.info
|
||||
CONTENT_REPO: https://gitea.lucaswinther.info/lucasw89/portfolio-next-content.git
|
||||
CONTENT_BRANCH: main
|
||||
POLL_INTERVAL: 1h
|
||||
SOURCES_INTERVAL: 6h
|
||||
CONTENT_REPO_TOKEN: ${CONTENT_REPO_TOKEN:?read-only Gitea token for the content repository required}
|
||||
GHOST_URL: https://blog.lucaswinther.info
|
||||
GHOST_CONTENT_KEY: ${GHOST_CONTENT_KEY:?Ghost Content API key required}
|
||||
TRAKT_CLIENT_ID: ${TRAKT_CLIENT_ID:?Trakt client id required}
|
||||
TRAKT_USER: lucasw89
|
||||
HARDCOVER_TOKEN: ${HARDCOVER_TOKEN:?Hardcover API token required}
|
||||
volumes:
|
||||
- ./data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "bun", "-e", "fetch('http://localhost:8080/status').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
proxy-net:
|
||||
external: true
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
# Example playbook for the config repo: ansible/playbooks/portfolio-next.yml
|
||||
#
|
||||
# portfolio-next (lucaswinther.info) on proxy-net, its builds and state in a bind mount.
|
||||
#
|
||||
# proxy-net is owned by nginx-proxy.yml. stack_networks still names the network
|
||||
# so this playbook runs on its own; creating one that exists is a no-op.
|
||||
#
|
||||
# data/ is handed to uid 1000, the user the image runs as, so it can write its
|
||||
# builds and state.json there. Setting owner is what makes the role escalate, so
|
||||
# homelab has to have been bootstrapped with -e deploy_sudo=true.
|
||||
#
|
||||
# Secrets come from kv/stacks/portfolio-next in OpenBao: the content repo bot's
|
||||
# read-only token, the Ghost Content API key, the Trakt client id, the Hardcover token.
|
||||
- name: Deploy portfolio-next
|
||||
hosts: homelab
|
||||
gather_facts: false
|
||||
tags: [portfolio-next]
|
||||
|
||||
roles:
|
||||
- role: compose_stack
|
||||
vars:
|
||||
stack_name: portfolio-next
|
||||
stack_secrets:
|
||||
CONTENT_REPO_TOKEN:
|
||||
path: stacks/portfolio-next
|
||||
field: content_repo_token
|
||||
GHOST_CONTENT_KEY:
|
||||
path: stacks/portfolio-next
|
||||
field: ghost_content_key
|
||||
TRAKT_CLIENT_ID:
|
||||
path: stacks/portfolio-next
|
||||
field: trakt_client_id
|
||||
HARDCOVER_TOKEN:
|
||||
path: stacks/portfolio-next
|
||||
field: hardcover_token
|
||||
stack_networks:
|
||||
- proxy-net
|
||||
stack_dirs:
|
||||
- path: "{{ stack_dest }}/data"
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
recurse: true
|
||||
@@ -0,0 +1,147 @@
|
||||
# Deploying portfolio-next
|
||||
|
||||
Two containers on the homelab, both behind nginx-proxy: the new site (`lucaswinther.info`) and the
|
||||
frozen 2021 site (`old.lucaswinther.info`). The files in `deploy/` are examples shaped for the
|
||||
homelab config repository (`server/homelab/<stack>/` payload + `ansible/playbooks/<stack>.yml`,
|
||||
secrets from OpenBao through `compose_stack`). **This repo never touches the config repo**: copy
|
||||
the files across yourself.
|
||||
|
||||
| File | Goes to (config repo) |
|
||||
|---|---|
|
||||
| `deploy/compose.yml` | `server/homelab/portfolio-next/docker-compose.yml` |
|
||||
| `deploy/.env.example` | `server/homelab/portfolio-next/.env.example` |
|
||||
| `deploy/portfolio-next.yml` | `ansible/playbooks/portfolio-next.yml` |
|
||||
| `deploy/compose.old-site.yml` | `server/homelab/old-site/docker-compose.yml` (change the volume to `./site`, and copy `archive/2021/` there as `site/`) |
|
||||
| `deploy/compose.local.yml` | stays here: local try-out, see the end |
|
||||
|
||||
For the old site, also add a small playbook like `portfolio-next.yml` (no secrets, no `data/`), and add
|
||||
both stacks to `site.yml` and the payload table in `server/README.md` like the other stacks.
|
||||
|
||||
## What runs
|
||||
|
||||
- Image `gitea.lucaswinther.info/lucasw89/portfolio-next:latest`, pushed by this repo's CI on every
|
||||
`main` commit together with `:<sha>`. Runs as uid 1000, plain HTTP on 8080, entrypoint `site serve`.
|
||||
- It polls `CONTENT_REPO` (`portfolio-next-content`) every `POLL_INTERVAL` (default `1h`). A new
|
||||
commit is validated and built in the background, then swapped live. A broken commit never
|
||||
replaces the live site.
|
||||
- Ghost posts, Trakt and Hardcover are fetched during each build, at most every `SOURCES_INTERVAL`
|
||||
(default `6h`). If a source is down or its token has expired, the build still succeeds with the
|
||||
last cached data and `/status` names the failing source.
|
||||
- `data/` (bind-mounted at `/data`) holds the last builds, `live`, the source caches and
|
||||
`state.json`. State, not payload: losing it costs one rebuild.
|
||||
- `old-site` is `nginx:alpine` serving `archive/2021/`, a frozen copy of the 2021 site.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Value / source |
|
||||
|---|---|
|
||||
| `CONTENT_REPO` | `https://gitea.lucaswinther.info/lucasw89/portfolio-next-content.git` |
|
||||
| `CONTENT_BRANCH` | `main` |
|
||||
| `CONTENT_REPO_TOKEN` | secret `content_repo_token` |
|
||||
| `POLL_INTERVAL` / `SOURCES_INTERVAL` | `1h` / `6h` |
|
||||
| `GHOST_URL` | `https://blog.lucaswinther.info` |
|
||||
| `GHOST_CONTENT_KEY` | secret `ghost_content_key` |
|
||||
| `TRAKT_CLIENT_ID` / `TRAKT_USER` | secret `trakt_client_id` / `lucasw89` |
|
||||
| `HARDCOVER_TOKEN` | secret `hardcover_token` |
|
||||
| `VIRTUAL_HOST`, `LETSENCRYPT_HOST` | `lucaswinther.info` |
|
||||
|
||||
`SOURCES_FIXTURES=<dir>` (used by the smoke test and `compose.local.yml`) reads the fixture JSON in
|
||||
`fixtures/sources` instead of calling the services. Do not set it in production.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Bot user and token**: a Gitea user that is a read-only collaborator on `portfolio-next-content`
|
||||
and nothing else, with an access token scoped `read:repository`.
|
||||
- **Ghost Content API key** (Ghost admin, Integrations), **Trakt client id**, **Hardcover API token**.
|
||||
- **Registry access on the host** for this Gitea's registry.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Store the secrets in OpenBao** at `kv/stacks/portfolio-next`, fields `content_repo_token`,
|
||||
`ghost_content_key`, `trakt_client_id`, `hardcover_token` (config repo `docs/secrets.md`,
|
||||
*Adding a secret*):
|
||||
|
||||
```bash
|
||||
ssh deploy@homelab
|
||||
docker exec -it openbao bao login
|
||||
read -rs A; read -rs B; read -rs C; read -rs D
|
||||
docker exec -i openbao bao kv put kv/stacks/portfolio-next \
|
||||
content_repo_token="$A" ghost_content_key="$B" trakt_client_id="$C" hardcover_token="$D"
|
||||
unset A B C D
|
||||
```
|
||||
|
||||
2. **Add the payload and playbooks** to the config repo using the table above.
|
||||
3. **Deploy the old site first** (`old.lucaswinther.info`), then the new one (see the cutover below),
|
||||
e.g. `ansible-playbook playbooks/portfolio-next.yml` (add `-e pull=always` on the first run).
|
||||
4. **Verify** (first build takes a minute or two; until then pages answer 503 "No build yet"):
|
||||
|
||||
```bash
|
||||
curl -s https://lucaswinther.info/status
|
||||
git ls-remote https://gitea.lucaswinther.info/lucasw89/portfolio-next-content.git refs/heads/main
|
||||
```
|
||||
|
||||
- `liveCommit` equals the commit `ls-remote` prints, and `lastError` is `null`.
|
||||
- `sources` has `ghost`, `trakt` and `hardcover`, each `ok: true`. A failing one shows its error and
|
||||
the time of its last good fetch; the site keeps serving the cached data.
|
||||
- `docker ps` shows `portfolio-next` as `healthy`.
|
||||
|
||||
## DNS cutover checklist
|
||||
|
||||
`lucaswinther.info` currently serves the 2021 site, and the blog lives at `blog.lucaswinther.info`
|
||||
(Ghost). Do these in order and check each step before the next.
|
||||
|
||||
1. [ ] Run `scripts/archive-old-site.sh` if `archive/2021/` is stale; check `git status`, commit.
|
||||
2. [ ] DNS: add `old.lucaswinther.info` pointing at the homelab (same target as the current site).
|
||||
3. [ ] Deploy the `old-site` stack. Open `https://old.lucaswinther.info/`: projects, images and the
|
||||
three report PDFs load.
|
||||
4. [ ] Deploy `portfolio-next` **without** moving the apex yet: temporarily set `VIRTUAL_HOST` to
|
||||
`new.lucaswinther.info` (and add that DNS record), or test with a hosts-file entry. Verify
|
||||
`/status` as above, plus `/`, `/work/`, `/off-the-clock/`.
|
||||
5. [ ] Move `lucaswinther.info` to the new container: stop whatever serves the 2021 site on the apex,
|
||||
set `VIRTUAL_HOST`/`LETSENCRYPT_HOST: lucaswinther.info`, redeploy, wait for the certificate.
|
||||
`curl -I https://lucaswinther.info/` must show 200 from the new site.
|
||||
6. [ ] Confirm the new site's links to the 2021 archive point at `https://old.lucaswinther.info/`.
|
||||
7. [ ] Later, once stable: blog redirects. Old post URLs (e.g. `lucaswinther.info/blog/...` or the
|
||||
former blog paths) get a 301 to the same post on `https://blog.lucaswinther.info/` (nginx-proxy
|
||||
`vhost.d` location files, or Ghost redirects). List the old URLs from the 2021 site and the
|
||||
blog's own history before writing them; this repo does not know them.
|
||||
8. [ ] Rollback at any point: set `VIRTUAL_HOST` back to the old container and redeploy; the archive
|
||||
stays up at `old.lucaswinther.info`.
|
||||
|
||||
## Day to day
|
||||
|
||||
| Task | How |
|
||||
|---|---|
|
||||
| Publish a content change now | `docker exec portfolio-next site refresh` (otherwise within `POLL_INTERVAL`). |
|
||||
| Update the app | Merge to `main`; once CI is green, deploy with `-e pull=always`. A new app version rebuilds the current content. |
|
||||
| Roll back the app | Pin `image:` to `...portfolio-next:<sha>` of a good commit and deploy. |
|
||||
| Rotate a secret | Update it in OpenBao (same `bao kv put`, all four fields), deploy (re-renders `.env`, recreates the container). |
|
||||
| Refresh the 2021 archive | `scripts/archive-old-site.sh [--force]`, commit, copy to the config repo. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Start with `/status` (`lastError`, `sources`), then `docker logs portfolio-next`.
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `could not read Username for 'https://…'` | Content repo token missing, wrong or expired. | Check `content_repo_token`; issue a new one. |
|
||||
| `remote: Repository not found` | The bot cannot see the repo. | Add it as read collaborator on `portfolio-next-content`. |
|
||||
| A source shows `ok: false` | Its token expired or the service is down. | Rotate the token; meanwhile cached data is served. |
|
||||
| Pages answer 503 "No build yet" | No successful build so far. | Read `lastError`. |
|
||||
| `lastError` lists `file → field: message` | The content commit is invalid; the previous build keeps serving. | Fix the content; push. |
|
||||
| `EACCES` under `/data` | `data/` is not owned by uid 1000. | Re-run the playbook or `chown -R 1000:1000 data`. |
|
||||
| Compose refuses to start: `... is missing a value` | `.env` lacks a secret. | Step 1, then deploy again. |
|
||||
|
||||
## Local try-out
|
||||
|
||||
No token, proxy or secrets: the site polls a git repository made from the synthetic fixture content
|
||||
and reads `fixtures/sources` instead of the network.
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.local.yml up --build # SITE_PORT=18080 to use another port
|
||||
# http://localhost:8080/ and /status
|
||||
docker compose -f deploy/compose.local.yml down -v
|
||||
```
|
||||
|
||||
Image check, as CI runs it: `docker build -t portfolio-next:local . && scripts/smoke-test.sh portfolio-next:local`.
|
||||
The frozen 2021 site can be looked at with `bun src/cli.ts preview archive/2021 4380`.
|
||||
|
After Width: | Height: | Size: 377 KiB |
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mirrors the live 2021 site (a Create React App build) into archive/2021/ so it can be served
|
||||
# frozen at old.lucaswinther.info (deploy/compose.old-site.yml). Idempotent: files already
|
||||
# present are skipped; delete archive/2021 (or pass --force) to refetch. Files that 404 are
|
||||
# skipped with a warning; any other failure aborts.
|
||||
# Usage: scripts/archive-old-site.sh [--force]
|
||||
set -euo pipefail
|
||||
base=${OLD_SITE_URL:-https://lucaswinther.info}
|
||||
base=${base%/}
|
||||
dest="$(cd "$(dirname "$0")/.." && pwd)/archive/2021"
|
||||
force=0; [ "${1:-}" = "--force" ] && force=1
|
||||
mkdir -p "$dest"
|
||||
fetched=0 skipped=0 missing=0
|
||||
|
||||
# fetch <url-path> [local-path]: path is as in the URL (already encoded); local-path is decoded.
|
||||
fetch() {
|
||||
local urlpath=$1 local_path=${2:-$(bun -e 'process.stdout.write(decodeURIComponent(process.argv[1]))' "$1")}
|
||||
local out="$dest/${local_path#/}" code
|
||||
if [ -s "$out" ] && [ "$force" = 0 ]; then skipped=$((skipped + 1)); return 0; fi
|
||||
mkdir -p "$(dirname "$out")"
|
||||
code=$(curl -sS --fail --location --output "$out.part" --write-out '%{http_code}' "$base$urlpath" 2>/dev/null) || code=$?
|
||||
if [ -f "$out.part" ] && [ "$code" = 200 ]; then
|
||||
mv "$out.part" "$out"; fetched=$((fetched + 1)); echo "fetched $urlpath"
|
||||
else
|
||||
rm -f "$out.part"
|
||||
# curl --fail exits 22 on HTTP >= 400; the 404 case is the expected one.
|
||||
if [ "$code" = 22 ]; then echo "warning: $urlpath not found (404 or other HTTP error), skipped" >&2; missing=$((missing + 1))
|
||||
else echo "error: fetching $urlpath failed (curl/http: $code)" >&2; exit 1; fi
|
||||
fi
|
||||
}
|
||||
|
||||
for f in index.html info.json favicon.ico logo192.png manifest.json; do
|
||||
[ "$f" = index.html ] && fetch / index.html || fetch "/$f"
|
||||
done
|
||||
|
||||
# Static bundles referenced by index.html, plus assets referenced from those bundles.
|
||||
refs() { grep -ohE '/static/[A-Za-z0-9_./-]+\.(js|css|woff2?|ttf|svg|png|jpe?g|gif|webp|map)' "$@" 2>/dev/null | sort -u || true; }
|
||||
for p in $(refs "$dest/index.html"); do fetch "$p"; done
|
||||
for p in $(refs "$dest"/static/css/*.css "$dest"/static/js/*.js); do fetch "$p"; done
|
||||
|
||||
# Project images: /images/<title>.jpg for every entry in info.json.
|
||||
while IFS= read -r title; do
|
||||
fetch "/images/$(bun -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$title").jpg"
|
||||
done < <(bun -e 'for (const p of Object.values(await Bun.file(process.argv[1]).json())) if (p.title) console.log(p.title)' "$dest/info.json")
|
||||
|
||||
# Reports linked from the academics field.
|
||||
while IFS= read -r url; do
|
||||
fetch "/documents/${url##*/}"
|
||||
done < <(bun -e 'for (const p of Object.values(await Bun.file(process.argv[1]).json())) if (/^https:\/\/lucaswinther\.info\/documents\//.test(p.academics ?? "")) console.log(p.academics)' "$dest/info.json")
|
||||
|
||||
echo "archive: $fetched fetched, $skipped already present, $missing skipped (not found) -> $dest"
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs an image against a git repo made from fixtures/content and checks it builds and serves.
|
||||
# Source credentials are not needed: SOURCES_FIXTURES points at fixtures/sources copied in.
|
||||
# Usage: scripts/smoke-test.sh <image>
|
||||
# Uses only `docker cp` and `docker exec`, no bind mounts or published ports: CI jobs run in a
|
||||
# container talking to the host's daemon, where neither the job's paths nor its localhost exist.
|
||||
set -euo pipefail
|
||||
image=$1
|
||||
work=$(mktemp -d)
|
||||
name="site-smoke-$$"
|
||||
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; rm -r -f "$work"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
cp -r fixtures/content "$work/content"
|
||||
cp -r fixtures/sources "$work/sources"
|
||||
git -C "$work/content" init -q -b main
|
||||
git -C "$work/content" add -A
|
||||
git -C "$work/content" -c user.name=smoke -c user.email=smoke@example.com commit -q -m smoke
|
||||
expected=$(git -C "$work/content" rev-parse HEAD)
|
||||
|
||||
# The repo goes in root-owned, as it would from a CI job running as root. git's local transport
|
||||
# drops -c / GIT_CONFIG_* settings before upload-pack, so safe.directory must come from a config file.
|
||||
printf '[safe]\n\tdirectory = *\n' > "$work/smoke.gitconfig"
|
||||
chmod -R a+rX "$work"
|
||||
docker create --name "$name" -e CONTENT_REPO=/content -e SOURCES_FIXTURES=/sources \
|
||||
-e GIT_CONFIG_GLOBAL=/smoke.gitconfig "$image" >/dev/null
|
||||
tar -C "$work" --owner=0 --group=0 -c content sources smoke.gitconfig | docker cp -q - "$name:/"
|
||||
docker start "$name" >/dev/null
|
||||
|
||||
# GET a path from inside the container; prints the body, fails on a non-2xx status.
|
||||
get() {
|
||||
docker exec "$name" bun -e "const r = await fetch('http://localhost:8080$1'); if (!r.ok) process.exit(1); process.stdout.write(await r.text())"
|
||||
}
|
||||
|
||||
live=''
|
||||
for _ in $(seq 1 180); do
|
||||
live=$(get /status 2>/dev/null | sed -n 's/.*"liveCommit": *"\([0-9a-f]*\)".*/\1/p' || true)
|
||||
[ "$live" = "$expected" ] && break
|
||||
sleep 1
|
||||
done
|
||||
if [ "$live" != "$expected" ]; then
|
||||
echo "smoke: live commit '$live' != '$expected'"; docker logs "$name"; exit 1
|
||||
fi
|
||||
for path in / /work/ /off-the-clock/; do
|
||||
get "$path" >/dev/null || { echo "smoke: $path did not return 200"; docker logs "$name"; exit 1; }
|
||||
done
|
||||
get / | grep -q 'Example Person' || { echo "smoke: / did not render the fixture content"; exit 1; }
|
||||
get /status | grep -q '"sources": *{' || { echo "smoke: /status has no sources object"; get /status; exit 1; }
|
||||
echo "smoke: ok ($expected)"
|
||||