Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Build artifacts and deps, regenerated inside the image
node_modules
dist
.astro
.netlify
.vercel

# Git and GitHub
.git
.github
18 changes: 11 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,25 +125,29 @@ jobs:

- name: 🐳 Build Docker Image
run: docker build -t web-check:test .
timeout-minutes: 10
timeout-minutes: 25

- name: 🧪 Verify Container Starts
timeout-minutes: 5
run: |
docker run -d --name wc-test -p 3000:3000 web-check:test
sleep 5
STATUS=$(docker inspect -f '{{.State.Running}}' wc-test)
if [ "$STATUS" != "true" ]; then
echo "❌ Container failed to start"
for _ in $(seq 60); do
STATUS=$(docker inspect -f '{{.State.Health.Status}}' wc-test)
[ "$STATUS" = "starting" ] || break
sleep 2
done
if [ "$STATUS" != "healthy" ]; then
echo "❌ Container never became healthy (status: $STATUS)"
docker logs wc-test
exit 1
fi
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/check || true)
if [ "$HTTP_CODE" != "200" ]; then
echo "❌ Health check failed (HTTP $HTTP_CODE)"
echo "❌ GUI not serving (HTTP $HTTP_CODE)"
docker logs wc-test
exit 1
fi
echo "✅ Container running and responding"
echo "✅ Container healthy and serving"
docker stop wc-test && docker rm wc-test

# security:
Expand Down
61 changes: 31 additions & 30 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# - Signs provenance (index) and per-arch SBOMs (per-arch manifest) via Sigstore
# - Writes a pretty job summary with tags, digest and attestation status

name: 🐳 Docker Publish
name: 🐳 Build + Publish Docker Image

on:
workflow_dispatch:
Expand All @@ -24,12 +24,13 @@ on:
required: false
default: ''
push:
# Trigger on new tags (which are created after each merge)
# Loose on purpose, so bad tags fail loudly in prepare instead of silently not running
tags: ['*.*.*']
schedule:
- cron: '0 4 * * 0'

concurrency:
# Per-ref, so back-to-back releases each still get published
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.tag }}
cancel-in-progress: false

Expand All @@ -56,7 +57,7 @@ jobs:
with:
fetch-depth: 0

- name: 🔢 Resolve & validate version
- name: 🔢 Resolve and validate version
id: resolve
env:
INPUT_TAG: ${{ inputs.tag }}
Expand All @@ -68,7 +69,7 @@ jobs:
SEMVER='^[0-9]+\.[0-9]+\.[0-9]+$'

if [ -n "$INPUT_TAG" ]; then
# Manual rebuild of a specific release - validate before doing any work
# Manual rebuild of a specific release, never moves :latest
if ! echo "$INPUT_TAG" | grep -qE "$SEMVER"; then
echo "::error::Invalid tag '${INPUT_TAG}'. Must be semver (e.g. 2.2.0)."
exit 1
Expand All @@ -77,21 +78,18 @@ jobs:
echo "::error::Tag '${INPUT_TAG}' does not exist in this repository."
exit 1
fi
# Rebuilding an older release must never move :latest
ref="refs/tags/${INPUT_TAG}"; version="$INPUT_TAG"; semver=true; latest=false

elif [ "$REF_TYPE" = "tag" ]; then
# A release tag was pushed (by 🔖 Auto Version & Tag, or by hand)
# Release tag pushed
if ! echo "$REF_NAME" | grep -qE "$SEMVER"; then
echo "::error::Tag '${REF_NAME}' is not semver; refusing to publish."
exit 1
fi
ref="refs/tags/${REF_NAME}"; version="$REF_NAME"; semver=true; latest=true

elif [ "$EVENT" = "schedule" ]; then
# Weekly refresh. Rebuild the newest *release* rather than master, so
# :latest picks up base image patches without drifting onto unreleased
# code. semver=false keeps already-published version tags immutable
# Weekly refresh of :latest from the newest release, version tags stay immutable
newest=$(git tag --list --sort=-v:refname | grep -E "$SEMVER" | head -n1 || true)
if [ -z "$newest" ]; then
echo "::error::No semver tag found to rebuild."
Expand Down Expand Up @@ -159,8 +157,7 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

# Attestations can't go through the docker exporter, so this scan-only
# build sets provenance: false. The push below re-adds them.
# The docker exporter can't carry attestations, so the push step re-adds them
- name: 🔨 Build image (load for scan)
uses: docker/build-push-action@v7
with:
Expand All @@ -171,8 +168,7 @@ jobs:
tags: web-check-scan:${{ matrix.arch }}
provenance: false

# Only the weekly cron treats CVEs as fatal. Everywhere else the scan is
# advisory, so a Trivy or DB outage can never block a release
# Advisory everywhere except cron, so a Trivy outage can't block a release
- name: 🛡️ Trivy vulnerability scan
id: scan
uses: aquasecurity/trivy-action@v0.36.0
Expand All @@ -190,7 +186,6 @@ jobs:
output: 'trivy-${{ matrix.arch }}.sarif'
timeout: '10m'

# If CVEs blocked the build, print them so they're readable in the log
- name: 📋 List blocking CVEs (on scan failure)
if: always() && steps.scan.outcome == 'failure'
continue-on-error: true
Expand Down Expand Up @@ -283,8 +278,7 @@ jobs:
username: ${{ vars.DOCKER_USERNAME || 'lissy93' }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}

# Builds race: a newer release tagged while this one was building must not
# be clobbered by an older build finishing second
# An older build finishing second must not clobber a newer release
- name: 🕓 Guard against :latest regression
id: guard
env:
Expand Down Expand Up @@ -320,8 +314,9 @@ jobs:
flavor: |
latest=false

# GHCR PUSH
- name: 🧩 Create & push manifest (GHCR)
# Registries are pushed separately, so Docker Hub can never block GHCR.
# imagetools (unlike docker manifest) keeps the BuildKit attestation manifests
- name: 🧩 Create and push manifest (GHCR)
id: manifest
working-directory: ${{ runner.temp }}/digests
run: |
Expand All @@ -345,7 +340,7 @@ jobs:
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
echo "Published ${#TAGS[@]} tag(s) to GHCR at ${DIGEST}"

- name: 🧩 Create & push manifest (Docker Hub)
- name: 🧩 Create and push manifest (Docker Hub)
id: dh_manifest
if: steps.dh_login.outcome == 'success'
continue-on-error: true
Expand All @@ -370,19 +365,18 @@ jobs:
ARGS=(); for t in "${TAGS[@]}"; do ARGS+=(-t "$t"); done
docker buildx imagetools create "${ARGS[@]}" "${SOURCES[@]}"
DIGEST=$(docker buildx imagetools inspect "${TAGS[0]}" --format '{{.Manifest.Digest}}')
# Same source descriptors must yield the same index; if not, the
# attestations below would be signing the wrong thing
if [ "$DIGEST" != "$GHCR_DIGEST" ]; then
echo "::error::Docker Hub digest ${DIGEST} != GHCR ${GHCR_DIGEST}; skipping its attestations"
exit 1
fi
echo "Published ${#TAGS[@]} tag(s) to Docker Hub at ${DIGEST}"

# BuildKit writes a each SBOM per architecture
- name: 🧾 Extract per-arch SBOMs & subjects
# BuildKit writes one SBOM per architecture, against the per-arch manifest
- name: 🧾 Extract per-arch SBOMs and subjects
id: sbom
env:
DIGEST: ${{ steps.manifest.outputs.digest }}
MAX_BYTES: '12582912'
run: |
set -euo pipefail
RAW=$(docker buildx imagetools inspect "${GH_IMAGE}@${DIGEST}" --raw)
Expand All @@ -392,13 +386,20 @@ jobs:
<<< "$RAW")
fmt='{{ json (index .SBOM "linux/'"$arch"'").SPDX }}'
docker buildx imagetools inspect "${GH_IMAGE}@${DIGEST}" \
--format "$fmt" > "sbom.$arch.json" 2>/dev/null || true
if [ -n "$subject" ] && jq -e 'type == "object" and has("packages")' "sbom.$arch.json" >/dev/null 2>&1; then
--format "$fmt" > "full.$arch.json" 2>/dev/null || true
jq -c 'del(.files)
| .relationships |= map(select(
(.spdxElementId | startswith("SPDXRef-File") | not) and
(.relatedSpdxElement | startswith("SPDXRef-File") | not)))' \
"full.$arch.json" > "sbom.$arch.json" 2>/dev/null || true
size=$(stat -c%s "sbom.$arch.json" 2>/dev/null || echo 0)
if [ -n "$subject" ] && [ "$size" -le "$MAX_BYTES" ] &&
jq -e 'type == "object" and has("packages")' "sbom.$arch.json" >/dev/null 2>&1; then
echo "${arch}=true" >> "$GITHUB_OUTPUT"
echo "${arch}_subject=$subject" >> "$GITHUB_OUTPUT"
echo "linux/${arch}: $(jq '.packages | length' "sbom.$arch.json") packages -> ${subject}"
echo "linux/${arch}: $(jq '.packages | length' "sbom.$arch.json") packages, ${size} bytes, subject ${subject}"
else
echo "::warning::No SBOM or subject for linux/${arch}; skipping its attestation"
echo "::warning::Unusable SBOM for linux/${arch} (${size} bytes); skipping its attestation"
echo "${arch}=false" >> "$GITHUB_OUTPUT"
fi
done
Expand Down Expand Up @@ -503,7 +504,7 @@ jobs:
echo "**Manifest:** \`${DIGEST:-unknown}\`"
if [ "${DH_MANIFEST:-skipped}" = "failure" ]; then
echo
echo "> ⚠️ Docker Hub publish failed GHCR was published successfully."
echo "> ⚠️ Docker Hub publish failed. GHCR was published successfully."
fi
echo
echo "The following tags have been updated and published:"
Expand All @@ -516,7 +517,7 @@ jobs:
echo
while IFS='=' read -r name outcome; do
if [ -n "$name" ]; then
echo "- $(icon "${outcome:-skipped}") ${name} ${outcome:-skipped}"
echo "- $(icon "${outcome:-skipped}") ${name}: ${outcome:-skipped}"
fi
done <<< "${RESULTS:-}"
echo
Expand All @@ -527,7 +528,7 @@ jobs:
echo "gh attestation verify oci://${GH_IMAGE}@${DIGEST:-} --repo ${GITHUB_REPOSITORY}"
echo '```'
echo
echo "Verify an SBOM (subject is the per-arch manifest, as BuildKit does):"
echo "Verify an SBOM (subject is the per-arch manifest):"
echo '```bash'
echo "gh attestation verify oci://${GH_IMAGE}@${AMD64_SUBJECT:-<amd64-digest>} --repo ${GITHUB_REPOSITORY} # amd64"
echo "gh attestation verify oci://${GH_IMAGE}@${ARM64_SUBJECT:-<arm64-digest>} --repo ${GITHUB_REPOSITORY} # arm64"
Expand Down
77 changes: 42 additions & 35 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,59 +1,62 @@
# Specify the Node.js version to use

# Node and Debian versions
ARG NODE_VERSION=22
ARG DEBIAN_VERSION=bookworm

# Specify the Debian version to use, the default is "bullseye"
ARG DEBIAN_VERSION=bullseye
FROM node:${NODE_VERSION}-${DEBIAN_VERSION} AS deps

# Use Node.js Docker image as the base image, with specific Node and Debian versions
FROM node:${NODE_VERSION}-${DEBIAN_VERSION} AS build
WORKDIR /app

# Set the container's default shell to Bash and enable some options
SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
# Skip Chromium until the final stage
ENV PUPPETEER_SKIP_DOWNLOAD='true' \
NODE_CHROMIUM_SKIP_INSTALL='true'

COPY package.json yarn.lock ./

# Install deps, without changing lockfile
RUN npm pkg delete devDependencies && \
yarn install --pure-lockfile --network-timeout 100000 && \
rm -rf /app/node_modules/.cache

# Install Chromium browser and Download and verify Google Chrome's signing key
RUN apt-get update -qq --fix-missing && \
apt-get -qqy install --allow-unauthenticated gnupg wget && \
wget --quiet --output-document=- https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor > /etc/apt/trusted.gpg.d/google-archive.gpg && \
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list && \
apt-get update -qq && \
apt-get -qqy --no-install-recommends install chromium traceroute python make g++ && \
rm -rf /var/lib/apt/lists/*
# Build stage, using the full image because we need the full toolchain
FROM node:${NODE_VERSION}-${DEBIAN_VERSION} AS build

# Run the Chromium browser's version command and redirect its output to the /etc/chromium-version file
RUN /usr/bin/chromium --no-sandbox --version > /etc/chromium-version
SHELL ["/bin/bash", "-euo", "pipefail", "-c"]

# Set the working directory to /app
WORKDIR /app

# Copy package.json and yarn.lock to the working directory
# The build needs no browser either
ENV PUPPETEER_SKIP_DOWNLOAD='true' NODE_CHROMIUM_SKIP_INSTALL='true'

COPY package.json yarn.lock ./

# Run yarn install to install dependencies and clear yarn cache
RUN apt-get update && \
yarn install --frozen-lockfile --network-timeout 100000 && \
RUN yarn install --frozen-lockfile --network-timeout 100000 && \
rm -rf /app/node_modules/.cache

# Copy all files to working directory
COPY . .

# Run yarn build to build the application
RUN yarn build --production

# Final stage
FROM node:${NODE_VERSION}-${DEBIAN_VERSION} AS final
# Slim's fine from here, there's nothing left to compile
FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS final

WORKDIR /app

COPY package.json yarn.lock ./
COPY --from=build /app .
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/api ./api
COPY --from=build /app/public ./public
COPY --from=build /app/server.js /app/healthcheck.js /app/package.json /app/yarn.lock ./

RUN apt-get update && \
apt-get install -y --no-install-recommends chromium traceroute && \
chmod 755 /usr/bin/chromium && \
rm -rf /var/lib/apt/lists/* /app/node_modules/.cache
apt-get install -y --no-install-recommends chromium traceroute tini && \
rm -rf /var/lib/apt/lists/*

# Fail the build here if the runtime tree can't load the compiled server
RUN node --input-type=module -e "await import('/app/dist/server/entry.mjs');"

# Exposed container port, the default is 3000, which can be modified through the environment variable PORT
EXPOSE ${PORT:-3000}
# Metadata only, so it can't follow PORT
EXPOSE 3000

# Point Chromium-using libs at the system binary, skip puppeteer's bundled download
ENV CHROME_PATH='/usr/bin/chromium' \
Expand All @@ -67,5 +70,9 @@ LABEL org.opencontainers.image.title="Web-Check" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.vendor="Alicia Sykes"

# Define the command executed when the container starts and start the server.js of the Node.js application
CMD ["yarn", "start"]
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD ["node", "healthcheck.js"]

ENTRYPOINT ["/usr/bin/tini", "--"]

CMD ["node", "server.js"]
2 changes: 2 additions & 0 deletions api/screenshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const directChromiumScreenshot = async (url) => {
'--headless',
'--disable-gpu',
'--no-sandbox',
// Headless picks its own size otherwise, this keeps it matching the puppeteer fallback
'--window-size=800,600',
`--screenshot=${screenshotPath}`,
url,
];
Expand Down
10 changes: 10 additions & 0 deletions healthcheck.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Probe for Docker's HEALTHCHECK, since the slim image has no curl or wget to hit /healthz with
const port = process.env.PORT || 3000;

try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
const { ready } = await response.json();
process.exit(response.ok && ready ? 0 : 1);
} catch {
process.exit(1);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"dependencies": {
"@astrojs/check": "^0.9.10",
"@astrojs/node": "^11.0.3",
"@astrojs/react": "^6.0.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
Expand Down Expand Up @@ -72,7 +73,6 @@
},
"devDependencies": {
"@astrojs/netlify": "^8.1.3",
"@astrojs/node": "^11.0.3",
"@astrojs/sitemap": "^3.7.3",
"@astrojs/svelte": "^9.0.1",
"@astrojs/ts-plugin": "^1.10.10",
Expand Down
Loading