deploy without node
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled

This commit is contained in:
2026-02-10 02:52:14 -05:00
parent 03df2b3a6c
commit 3b0b97f139
25 changed files with 312 additions and 51 deletions

View File

@@ -7,3 +7,7 @@
**/dist **/dist
**/.DS_Store **/.DS_Store
# Local secrets
**/.env
**/.env.*
!**/.env.example

68
.github/workflows/publish-image.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: publish-image
on:
push:
branches: ["main"]
workflow_dispatch:
schedule:
# Rebuild periodically so content sources can be refreshed even without code changes.
- cron: "0 9 * * *"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: site/package-lock.json
- name: Install + Fetch Content + Build Site
working-directory: site
env:
YOUTUBE_CHANNEL_ID: ${{ secrets.YOUTUBE_CHANNEL_ID }}
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
PODCAST_RSS_URL: ${{ secrets.PODCAST_RSS_URL }}
WORDPRESS_BASE_URL: ${{ secrets.WORDPRESS_BASE_URL }}
WORDPRESS_USERNAME: ${{ secrets.WORDPRESS_USERNAME }}
WORDPRESS_APP_PASSWORD: ${{ secrets.WORDPRESS_APP_PASSWORD }}
REDIS_URL: ${{ secrets.REDIS_URL }}
run: |
npm ci
npm run fetch-content
npm run build
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest
type=sha,format=short,prefix=sha-
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
BUILD_SHA=${{ github.sha }}
BUILD_DATE=${{ github.run_started_at }}
BUILD_REF=${{ github.server_url }}/${{ github.repository }}

View File

@@ -3,15 +3,31 @@ FROM node:24-alpine AS builder
WORKDIR /app/site WORKDIR /app/site
COPY site/package.json site/package-lock.json ./ COPY site/package.json site/package-lock.json ./
RUN npm ci RUN npm ci --no-audit --no-fund
COPY site/ ./ COPY site/ ./
# Content is fetched before build (typically in CI) and committed into the build context at
# `site/content/cache/content.json`. If env vars aren't configured, the fetch step gracefully
# skips sources and/or uses last-known-good cache.
RUN npm run build RUN npm run build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
ARG BUILD_SHA=unknown
ARG BUILD_DATE=unknown
ARG BUILD_REF=unknown
LABEL org.opencontainers.image.title="fast-website"
LABEL org.opencontainers.image.description="Lightweight, SEO-first static site packaged as an nginx image."
LABEL org.opencontainers.image.revision=$BUILD_SHA
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.source=$BUILD_REF
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/site/dist/ /usr/share/nginx/html/ COPY --from=builder /app/site/dist/ /usr/share/nginx/html/
EXPOSE 80 # Operator-friendly version visibility.
RUN printf '{\n "sha": "%s",\n "builtAt": "%s",\n "ref": "%s"\n}\n' "$BUILD_SHA" "$BUILD_DATE" "$BUILD_REF" \
> /usr/share/nginx/html/build.json
EXPOSE 80

View File

@@ -86,7 +86,9 @@ Instrumentation checklist:
## Deployment (Linode + Docker) ## Deployment (Linode + Docker)
Build and run: The production host is intentionally minimal and only needs Docker (no Node.js on the server).
### Local Docker
```bash ```bash
docker compose build docker compose build
@@ -95,24 +97,29 @@ docker compose up -d
The container serves the static output on port `8080` (map or proxy as needed). The container serves the static output on port `8080` (map or proxy as needed).
### Refreshing Content (Manual) ### Production (Docker-Only Host)
Content is fetched at build time into `site/content/cache/content.json`. In production, CI builds and publishes a Docker image (nginx serving the static output). The server updates by pulling that image and restarting the service.
On the Linode host: Runbook: `deploy/runbook.md`.
### Refreshing Content (Manual, Docker-Only)
Content is fetched at build time into `site/content/cache/content.json` (typically in CI), then packaged into the image.
On the server host:
```bash ```bash
./scripts/refresh.sh ./scripts/refresh.sh
``` ```
This: This:
1. Runs `npm run fetch-content` in `site/` 1. Pulls the latest published image
2. Rebuilds the Docker image 2. Restarts the service (no build on the host)
3. Restarts the container
### Refreshing Content (Scheduled) ### Refreshing Content (Scheduled)
Install a daily cron using `deploy/cron.example` as a starting point. Install a daily cron using `deploy/cron.example` as a starting point.
Rollback: Rollback:
- Re-run `docker compose up -d` with a previously built image/tag, or restore the last known-good repo state and rerun `scripts/refresh.sh`. - Re-deploy a known-good image tag/digest (see `deploy/runbook.md`).

View File

@@ -0,0 +1,6 @@
services:
web:
image: ${WEB_IMAGE:?Set WEB_IMAGE to the published image tag or digest}
ports:
- "8080:80"

84
deploy/runbook.md Normal file
View File

@@ -0,0 +1,84 @@
## Deploy Runbook (Docker-Only Host)
This runbook is for a minimal production host where **Docker is installed** and **Node.js is not**.
The deployment model is:
- CI builds and publishes a Docker image containing the built static site
- the server updates by pulling that image and restarting the service
### Prerequisites
- Docker + Docker Compose plugin available on the host
- Registry access (e.g., logged in to GHCR if the image is private)
- A `WEB_IMAGE` value pointing at the image to deploy (tag or digest)
Example:
```bash
export WEB_IMAGE=ghcr.io/<owner>/<repo>:latest
```
### First-Time Start
```bash
docker compose -f deploy/docker-compose.prod.yml up -d
```
### Refresh (Pull + Restart)
Pull first (safe; does not affect the running container):
```bash
docker compose -f deploy/docker-compose.prod.yml pull
```
Then restart the service on the newly pulled image:
```bash
docker compose -f deploy/docker-compose.prod.yml up -d --no-build
```
### Verify Deployed Version
1. Check the container's image reference (tag/digest):
```bash
docker compose -f deploy/docker-compose.prod.yml ps
docker inspect --format '{{.Image}} {{.Config.Image}}' <container-id>
```
2. Check build metadata served by the site:
```bash
curl -fsS http://localhost:8080/build.json
```
### Rollback
Re-deploy a known-good version by pinning a previous tag or digest:
```bash
export WEB_IMAGE=ghcr.io/<owner>/<repo>:<known-good-tag>
docker compose -f deploy/docker-compose.prod.yml up -d --no-build
```
Recommended: record the image digest for each release (`docker inspect <image> --format '{{.Id}}'`), and use a digest pin for true immutability.
### Failure Mode Validation (Pull Failure)
If `docker compose pull` fails, **do not run** the restart step. The running site will continue serving the existing container.
To simulate a pull failure safely:
```bash
export WEB_IMAGE=ghcr.io/<owner>/<repo>:this-tag-does-not-exist
docker compose -f deploy/docker-compose.prod.yml pull
```
The pull should fail, but the current service should still be running:
```bash
docker compose -f deploy/docker-compose.prod.yml ps
curl -fsS http://localhost:8080/ > /dev/null
```

View File

@@ -1,5 +1,6 @@
services: services:
web: web:
image: ${WEB_IMAGE:-fast-website:local}
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile

View File

@@ -7,11 +7,10 @@ The site MUST provide a blog index page at `/blog` that lists WordPress posts as
- excerpt/summary - excerpt/summary
- publish date - publish date
The card MUST render a meta row with: The card MUST render a footer bar that includes:
- publish date on the left - publish date on the left
- views on the right when available (if views are not provided by the dataset, the card MUST omit views without breaking layout) - views on the right when available (if views are not provided by the dataset, the card MUST omit views without breaking layout)
- a content source label (e.g., `blog`)
The card MUST render a footer row showing the content source label (e.g., `blog`).
The listing MUST be ordered by publish date descending (newest first). The listing MUST be ordered by publish date descending (newest first).
@@ -31,4 +30,4 @@ Each post card MUST be instrumented with Umami Track Events data attributes and
#### Scenario: Blog post card layout is standardized #### Scenario: Blog post card layout is standardized
- **WHEN** `/blog` renders a blog post card - **WHEN** `/blog` renders a blog post card
- **THEN** the card shows featured image (when available), title, trimmed excerpt, meta row (date + optional views), and a footer source label - **THEN** the card shows featured image (when available), title, trimmed excerpt, and a footer bar containing date, optional views, and a source label

View File

@@ -7,10 +7,10 @@ The standard card layout MUST be:
- featured image displayed prominently at the top (when available) - featured image displayed prominently at the top (when available)
- title - title
- summary/excerpt text, trimmed to a fixed maximum length - summary/excerpt text, trimmed to a fixed maximum length
- meta row showing: - footer bar showing:
- publish date on the left - publish date on the left
- views on the right (when available) - views when available (if omitted, the footer MUST still render cleanly)
- footer row showing the content source (e.g., `youtube`, `podcast`, `blog`) - the content source label (e.g., `youtube`, `podcast`, `blog`)
If a field is not available (for example, views for some sources), the card MUST still render cleanly with that field omitted. If a field is not available (for example, views for some sources), the card MUST still render cleanly with that field omitted.
@@ -20,9 +20,8 @@ If a field is not available (for example, views for some sources), the card MUST
#### Scenario: Card renders without views #### Scenario: Card renders without views
- **WHEN** a content item has no views data - **WHEN** a content item has no views data
- **THEN** the card renders the meta row with date and omits views without breaking the layout - **THEN** the card renders the footer bar with date + source and omits views without breaking the layout
#### Scenario: Card renders without featured image #### Scenario: Card renders without featured image
- **WHEN** a content item has no featured image - **WHEN** a content item has no featured image
- **THEN** the card renders a placeholder media area and still renders the remaining fields - **THEN** the card renders a placeholder media area and still renders the remaining fields

View File

@@ -0,0 +1,25 @@
## 1. Discovery And Current State
- [x] 1.1 Identify current deploy target and mechanism (compose/swarm/k8s, image vs files) and document constraints in `README` or `ops/` docs
- [x] 1.2 Identify the content source(s) that define "latest content" (e.g., WordPress/API) and how builds currently fetch content
- [x] 1.3 Confirm current build output (static assets) and runtime server (e.g., nginx) requirements
## 2. Build And Publish A Deployable Artifact
- [x] 2.1 Ensure the repo can produce a deterministic production build inside CI (no host dependencies)
- [x] 2.2 Create or update a Dockerfile to build the site and package the built output into a runtime image
- [x] 2.3 Add build metadata to the image (tagging convention and/or embedded version file)
- [x] 2.4 Configure CI to build and publish the image to a registry accessible by the server
## 3. Server-Side Docker-Only Refresh Workflow
- [x] 3.1 Add or update the server Docker Compose/service definition to run the published image
- [x] 3.2 Add documented operator commands to refresh to the latest image (pull + restart)
- [x] 3.3 Add a verification command/procedure to show the currently deployed version (tag/digest/build metadata)
- [x] 3.4 Define rollback procedure to re-deploy a previous known-good tag/digest
## 4. Validation
- [x] 4.1 Validate a refresh on a test/staging server: pull latest image, restart, confirm content changes are visible
- [x] 4.2 Validate failure mode: simulate pull failure and confirm the existing site remains serving
- [x] 4.3 Update docs with a minimal "runbook" for operators (refresh, verify, rollback)

View File

@@ -1,25 +0,0 @@
## 1. Discovery And Current State
- [ ] 1.1 Identify current deploy target and mechanism (compose/swarm/k8s, image vs files) and document constraints in `README` or `ops/` docs
- [ ] 1.2 Identify the content source(s) that define "latest content" (e.g., WordPress/API) and how builds currently fetch content
- [ ] 1.3 Confirm current build output (static assets) and runtime server (e.g., nginx) requirements
## 2. Build And Publish A Deployable Artifact
- [ ] 2.1 Ensure the repo can produce a deterministic production build inside CI (no host dependencies)
- [ ] 2.2 Create or update a Dockerfile to build the site and package the built output into a runtime image
- [ ] 2.3 Add build metadata to the image (tagging convention and/or embedded version file)
- [ ] 2.4 Configure CI to build and publish the image to a registry accessible by the server
## 3. Server-Side Docker-Only Refresh Workflow
- [ ] 3.1 Add or update the server Docker Compose/service definition to run the published image
- [ ] 3.2 Add documented operator commands to refresh to the latest image (pull + restart)
- [ ] 3.3 Add a verification command/procedure to show the currently deployed version (tag/digest/build metadata)
- [ ] 3.4 Define rollback procedure to re-deploy a previous known-good tag/digest
## 4. Validation
- [ ] 4.1 Validate a refresh on a test/staging server: pull latest image, restart, confirm content changes are visible
- [ ] 4.2 Validate failure mode: simulate pull failure and confirm the existing site remains serving
- [ ] 4.3 Update docs with a minimal "runbook" for operators (refresh, verify, rollback)

View File

@@ -16,9 +16,15 @@ The site MUST provide a blog index page at `/blog` that lists WordPress posts as
- featured image (when available) - featured image (when available)
- title - title
- excerpt/summary - excerpt/summary
- publish date
The listing MUST be ordered by publish date descending (newest first). The listing MUST be ordered by publish date descending (newest first).
The card MUST render a footer row that includes:
- publish date on the left
- views on the right when available (if views are not provided by the dataset, the card MUST omit views without breaking layout)
- a content source label (e.g., `blog`)
Each post card MUST be instrumented with Umami Track Events data attributes and MUST include at minimum: Each post card MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
- `data-umami-event` - `data-umami-event`
- `data-umami-event-target_id` - `data-umami-event-target_id`
@@ -33,6 +39,10 @@ Each post card MUST be instrumented with Umami Track Events data attributes and
- **WHEN** a user clicks a blog post card on `/blog` - **WHEN** a user clicks a blog post card on `/blog`
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url` - **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
#### Scenario: Blog post card layout is standardized
- **WHEN** `/blog` renders a blog post card
- **THEN** the card shows featured image (when available), title, trimmed excerpt, and a footer bar containing date, optional views, and a source label
### Requirement: Blog post detail ### Requirement: Blog post detail
The site MUST provide a blog post detail page for each WordPress post that renders: The site MUST provide a blog post detail page for each WordPress post that renders:
- title - title
@@ -83,4 +93,3 @@ If there are no WordPress posts available, the blog index MUST render a non-brok
#### Scenario: No posts available #### Scenario: No posts available
- **WHEN** the cached WordPress dataset contains no posts - **WHEN** the cached WordPress dataset contains no posts
- **THEN** `/blog` renders a helpful empty state - **THEN** `/blog` renders a helpful empty state

View File

@@ -0,0 +1,32 @@
## Purpose
Define a standardized card layout so content cards across surfaces look consistent.
## Requirements
### Requirement: Standard card information architecture
All content cards rendered by the site MUST use a standardized layout so cards across different surfaces look consistent.
The standard card layout MUST be:
- featured image displayed prominently at the top (when available)
- title
- summary/excerpt text, trimmed to a fixed maximum length
- footer row showing:
- publish date on the left
- views when available (if omitted, the footer MUST still render cleanly)
- the content source label (e.g., `youtube`, `podcast`, `blog`)
If a field is not available (for example, views for some sources), the card MUST still render cleanly with that field omitted.
#### Scenario: Card renders with all fields
- **WHEN** a content item has an image, title, summary, publish date, views, and source
- **THEN** the card renders those fields in the standard card layout order
#### Scenario: Card renders without views
- **WHEN** a content item has no views data
- **THEN** the card renders the footer bar with date + source and omits views without breaking the layout
#### Scenario: Card renders without featured image
- **WHEN** a content item has no featured image
- **THEN** the card renders a placeholder media area and still renders the remaining fields

View File

@@ -0,0 +1,31 @@
## Purpose
Enable operators to refresh the deployed site to the latest content on a Docker-only host (no Node.js installed on the server).
## Requirements
### Requirement: Host update does not require Node.js
The system MUST provide an operator workflow to update the deployed site to the latest content without installing Node.js on the server host. Any build or content-fetch steps MUST run in containers and/or CI, not via host-installed Node.js.
#### Scenario: Operator updates without host Node.js
- **WHEN** the server host has Docker available but does not have Node.js installed
- **THEN** the operator can complete the update procedure using Docker commands only
### Requirement: Image-based content refresh is supported
The system MUST support refreshing the deployed site to the latest content by pulling a newly built deployable artifact (for example, a Docker image) and restarting the running service.
#### Scenario: Successful refresh to latest image
- **WHEN** the operator runs the documented refresh command
- **THEN** the server pulls the latest published image and restarts the service using that image
#### Scenario: Refresh failure does not break running site
- **WHEN** the operator runs the documented refresh command and the pull fails
- **THEN** the site remains running on the previously deployed image
### Requirement: Refresh is repeatable and auditable
The system MUST document the refresh procedure and provide a way to verify which version is deployed (for example, image tag/digest or build metadata).
#### Scenario: Operator verifies deployed version
- **WHEN** the operator runs the documented verification command
- **THEN** the system reports the currently deployed version identifier

View File

@@ -11,6 +11,9 @@ The normalized item MUST include at minimum:
- `publishedAt` (ISO-8601) - `publishedAt` (ISO-8601)
- `thumbnailUrl` (optional) - `thumbnailUrl` (optional)
The system MUST support an optional summary field on normalized items when available from the source:
- `summary` (optional, short human-readable excerpt suitable for cards)
#### Scenario: Normalizing a YouTube video #### Scenario: Normalizing a YouTube video
- **WHEN** the system ingests a YouTube video item - **WHEN** the system ingests a YouTube video item
- **THEN** it produces a normalized item containing `id`, `source: youtube`, `url`, `title`, and `publishedAt` - **THEN** it produces a normalized item containing `id`, `source: youtube`, `url`, `title`, and `publishedAt`
@@ -19,6 +22,10 @@ The normalized item MUST include at minimum:
- **WHEN** the system ingests a podcast RSS episode - **WHEN** the system ingests a podcast RSS episode
- **THEN** it produces a normalized item containing `id`, `source: podcast`, `url`, `title`, and `publishedAt` - **THEN** it produces a normalized item containing `id`, `source: podcast`, `url`, `title`, and `publishedAt`
#### Scenario: Summary available
- **WHEN** an ingested item provides summary/description content
- **THEN** the normalized item includes a `summary` suitable for rendering in cards
### Requirement: YouTube ingestion with stats when available ### Requirement: YouTube ingestion with stats when available
The system MUST support ingesting YouTube videos for channel `youtube.com/santhoshj`. The system MUST support ingesting YouTube videos for channel `youtube.com/santhoshj`.

View File

@@ -3,12 +3,10 @@ set -eu
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
echo "[refresh] fetching content" echo "[refresh] pulling latest image"
(cd site && npm ci && npm run fetch-content) docker compose -f deploy/docker-compose.prod.yml pull
echo "[refresh] building + restarting container" echo "[refresh] restarting service (no build)"
docker compose build web docker compose -f deploy/docker-compose.prod.yml up -d --no-build
docker compose up -d --force-recreate web
echo "[refresh] done" echo "[refresh] done"