deploy without node
This commit is contained in:
@@ -7,3 +7,7 @@
|
||||
**/dist
|
||||
**/.DS_Store
|
||||
|
||||
# Local secrets
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
|
||||
68
.github/workflows/publish-image.yml
vendored
Normal file
68
.github/workflows/publish-image.yml
vendored
Normal 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 }}
|
||||
20
Dockerfile
20
Dockerfile
@@ -3,15 +3,31 @@ FROM node:24-alpine AS builder
|
||||
WORKDIR /app/site
|
||||
|
||||
COPY site/package.json site/package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
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
|
||||
|
||||
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 --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
|
||||
|
||||
23
README.md
23
README.md
@@ -86,7 +86,9 @@ Instrumentation checklist:
|
||||
|
||||
## 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
|
||||
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).
|
||||
|
||||
### 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
|
||||
./scripts/refresh.sh
|
||||
```
|
||||
|
||||
This:
|
||||
1. Runs `npm run fetch-content` in `site/`
|
||||
2. Rebuilds the Docker image
|
||||
3. Restarts the container
|
||||
1. Pulls the latest published image
|
||||
2. Restarts the service (no build on the host)
|
||||
|
||||
### Refreshing Content (Scheduled)
|
||||
|
||||
Install a daily cron using `deploy/cron.example` as a starting point.
|
||||
|
||||
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`).
|
||||
|
||||
6
deploy/docker-compose.prod.yml
Normal file
6
deploy/docker-compose.prod.yml
Normal 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
84
deploy/runbook.md
Normal 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
|
||||
```
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
image: ${WEB_IMAGE:-fast-website:local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -7,11 +7,10 @@ The site MUST provide a blog index page at `/blog` that lists WordPress posts as
|
||||
- excerpt/summary
|
||||
- publish date
|
||||
|
||||
The card MUST render a meta row with:
|
||||
The card MUST render a footer bar 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)
|
||||
|
||||
The card MUST render a footer row showing the content source label (e.g., `blog`).
|
||||
- a content source label (e.g., `blog`)
|
||||
|
||||
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
|
||||
- **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
|
||||
@@ -7,10 +7,10 @@ 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
|
||||
- meta row showing:
|
||||
- footer bar showing:
|
||||
- publish date on the left
|
||||
- views on the right (when available)
|
||||
- footer row showing the content source (e.g., `youtube`, `podcast`, `blog`)
|
||||
- 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.
|
||||
|
||||
@@ -20,9 +20,8 @@ If a field is not available (for example, views for some sources), the card MUST
|
||||
|
||||
#### Scenario: Card renders without views
|
||||
- **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
|
||||
- **WHEN** a content item has no featured image
|
||||
- **THEN** the card renders a placeholder media area and still renders the remaining fields
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -16,9 +16,15 @@ The site MUST provide a blog index page at `/blog` that lists WordPress posts as
|
||||
- featured image (when available)
|
||||
- title
|
||||
- excerpt/summary
|
||||
- publish date
|
||||
|
||||
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:
|
||||
- `data-umami-event`
|
||||
- `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`
|
||||
- **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
|
||||
The site MUST provide a blog post detail page for each WordPress post that renders:
|
||||
- title
|
||||
@@ -83,4 +93,3 @@ If there are no WordPress posts available, the blog index MUST render a non-brok
|
||||
#### Scenario: No posts available
|
||||
- **WHEN** the cached WordPress dataset contains no posts
|
||||
- **THEN** `/blog` renders a helpful empty state
|
||||
|
||||
|
||||
32
openspec/specs/card-layout-system/spec.md
Normal file
32
openspec/specs/card-layout-system/spec.md
Normal 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
|
||||
|
||||
31
openspec/specs/docker-content-refresh/spec.md
Normal file
31
openspec/specs/docker-content-refresh/spec.md
Normal 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
|
||||
|
||||
@@ -11,6 +11,9 @@ The normalized item MUST include at minimum:
|
||||
- `publishedAt` (ISO-8601)
|
||||
- `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
|
||||
- **WHEN** the system ingests a YouTube video item
|
||||
- **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
|
||||
- **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
|
||||
The system MUST support ingesting YouTube videos for channel `youtube.com/santhoshj`.
|
||||
|
||||
|
||||
@@ -3,12 +3,10 @@ set -eu
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "[refresh] fetching content"
|
||||
(cd site && npm ci && npm run fetch-content)
|
||||
echo "[refresh] pulling latest image"
|
||||
docker compose -f deploy/docker-compose.prod.yml pull
|
||||
|
||||
echo "[refresh] building + restarting container"
|
||||
docker compose build web
|
||||
docker compose up -d --force-recreate web
|
||||
echo "[refresh] restarting service (no build)"
|
||||
docker compose -f deploy/docker-compose.prod.yml up -d --no-build
|
||||
|
||||
echo "[refresh] done"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user