diff --git a/.dockerignore b/.dockerignore index 5a4a091..e91e383 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,7 @@ **/dist **/.DS_Store +# Local secrets +**/.env +**/.env.* +!**/.env.example diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..6371a2a --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -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 }} diff --git a/Dockerfile b/Dockerfile index 6d28a40..95f962f 100644 --- a/Dockerfile +++ b/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 diff --git a/README.md b/README.md index 60d27f2..970fd1f 100644 --- a/README.md +++ b/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`). diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..aa4d540 --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,6 @@ +services: + web: + image: ${WEB_IMAGE:?Set WEB_IMAGE to the published image tag or digest} + ports: + - "8080:80" + diff --git a/deploy/runbook.md b/deploy/runbook.md new file mode 100644 index 0000000..a9f8c2c --- /dev/null +++ b/deploy/runbook.md @@ -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//: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}}' +``` + +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//: +docker compose -f deploy/docker-compose.prod.yml up -d --no-build +``` + +Recommended: record the image digest for each release (`docker inspect --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//: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 +``` + diff --git a/docker-compose.yml b/docker-compose.yml index 1e72aa6..5fdf0b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: web: + image: ${WEB_IMAGE:-fast-website:local} build: context: . dockerfile: Dockerfile diff --git a/openspec/changes/card-layout/.openspec.yaml b/openspec/changes/archive/2026-02-10-card-layout/.openspec.yaml similarity index 100% rename from openspec/changes/card-layout/.openspec.yaml rename to openspec/changes/archive/2026-02-10-card-layout/.openspec.yaml diff --git a/openspec/changes/card-layout/design.md b/openspec/changes/archive/2026-02-10-card-layout/design.md similarity index 100% rename from openspec/changes/card-layout/design.md rename to openspec/changes/archive/2026-02-10-card-layout/design.md diff --git a/openspec/changes/card-layout/proposal.md b/openspec/changes/archive/2026-02-10-card-layout/proposal.md similarity index 100% rename from openspec/changes/card-layout/proposal.md rename to openspec/changes/archive/2026-02-10-card-layout/proposal.md diff --git a/openspec/changes/card-layout/specs/blog-section-surface/spec.md b/openspec/changes/archive/2026-02-10-card-layout/specs/blog-section-surface/spec.md similarity index 85% rename from openspec/changes/card-layout/specs/blog-section-surface/spec.md rename to openspec/changes/archive/2026-02-10-card-layout/specs/blog-section-surface/spec.md index d177602..bdde71a 100644 --- a/openspec/changes/card-layout/specs/blog-section-surface/spec.md +++ b/openspec/changes/archive/2026-02-10-card-layout/specs/blog-section-surface/spec.md @@ -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 diff --git a/openspec/changes/card-layout/specs/card-layout-system/spec.md b/openspec/changes/archive/2026-02-10-card-layout/specs/card-layout-system/spec.md similarity index 78% rename from openspec/changes/card-layout/specs/card-layout-system/spec.md rename to openspec/changes/archive/2026-02-10-card-layout/specs/card-layout-system/spec.md index 415dbce..d7962d5 100644 --- a/openspec/changes/card-layout/specs/card-layout-system/spec.md +++ b/openspec/changes/archive/2026-02-10-card-layout/specs/card-layout-system/spec.md @@ -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 - diff --git a/openspec/changes/card-layout/specs/social-content-aggregation/spec.md b/openspec/changes/archive/2026-02-10-card-layout/specs/social-content-aggregation/spec.md similarity index 100% rename from openspec/changes/card-layout/specs/social-content-aggregation/spec.md rename to openspec/changes/archive/2026-02-10-card-layout/specs/social-content-aggregation/spec.md diff --git a/openspec/changes/card-layout/tasks.md b/openspec/changes/archive/2026-02-10-card-layout/tasks.md similarity index 100% rename from openspec/changes/card-layout/tasks.md rename to openspec/changes/archive/2026-02-10-card-layout/tasks.md diff --git a/openspec/changes/deploy-without-node/.openspec.yaml b/openspec/changes/archive/2026-02-10-deploy-without-node/.openspec.yaml similarity index 100% rename from openspec/changes/deploy-without-node/.openspec.yaml rename to openspec/changes/archive/2026-02-10-deploy-without-node/.openspec.yaml diff --git a/openspec/changes/deploy-without-node/design.md b/openspec/changes/archive/2026-02-10-deploy-without-node/design.md similarity index 100% rename from openspec/changes/deploy-without-node/design.md rename to openspec/changes/archive/2026-02-10-deploy-without-node/design.md diff --git a/openspec/changes/deploy-without-node/proposal.md b/openspec/changes/archive/2026-02-10-deploy-without-node/proposal.md similarity index 100% rename from openspec/changes/deploy-without-node/proposal.md rename to openspec/changes/archive/2026-02-10-deploy-without-node/proposal.md diff --git a/openspec/changes/deploy-without-node/specs/docker-content-refresh/spec.md b/openspec/changes/archive/2026-02-10-deploy-without-node/specs/docker-content-refresh/spec.md similarity index 100% rename from openspec/changes/deploy-without-node/specs/docker-content-refresh/spec.md rename to openspec/changes/archive/2026-02-10-deploy-without-node/specs/docker-content-refresh/spec.md diff --git a/openspec/changes/archive/2026-02-10-deploy-without-node/tasks.md b/openspec/changes/archive/2026-02-10-deploy-without-node/tasks.md new file mode 100644 index 0000000..aebeee9 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-deploy-without-node/tasks.md @@ -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) diff --git a/openspec/changes/deploy-without-node/tasks.md b/openspec/changes/deploy-without-node/tasks.md deleted file mode 100644 index 2dede6a..0000000 --- a/openspec/changes/deploy-without-node/tasks.md +++ /dev/null @@ -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) \ No newline at end of file diff --git a/openspec/specs/blog-section-surface/spec.md b/openspec/specs/blog-section-surface/spec.md index 88ae3bb..0195fcf 100644 --- a/openspec/specs/blog-section-surface/spec.md +++ b/openspec/specs/blog-section-surface/spec.md @@ -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 - diff --git a/openspec/specs/card-layout-system/spec.md b/openspec/specs/card-layout-system/spec.md new file mode 100644 index 0000000..caa1fb3 --- /dev/null +++ b/openspec/specs/card-layout-system/spec.md @@ -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 + diff --git a/openspec/specs/docker-content-refresh/spec.md b/openspec/specs/docker-content-refresh/spec.md new file mode 100644 index 0000000..6aa75ea --- /dev/null +++ b/openspec/specs/docker-content-refresh/spec.md @@ -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 + diff --git a/openspec/specs/social-content-aggregation/spec.md b/openspec/specs/social-content-aggregation/spec.md index a3efe2f..60420b7 100644 --- a/openspec/specs/social-content-aggregation/spec.md +++ b/openspec/specs/social-content-aggregation/spec.md @@ -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`. diff --git a/scripts/refresh.sh b/scripts/refresh.sh index 37fa038..45f12ae 100644 --- a/scripts/refresh.sh +++ b/scripts/refresh.sh @@ -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" -