Skip to content

fix: make event ingestion idempotent by canonical log identity #354

fix: make event ingestion idempotent by canonical log identity

fix: make event ingestion idempotent by canonical log identity #354

Workflow file for this run

name: Container Smoke Build
# Companion to the `Container Vulnerability Scan` job in ci.yml. That job asks
# "is the shipped image vulnerable?". This workflow asks the reproducibility
# questions instead: does the image build from a clean checkout with no local
# cache, does the build actually consume the committed lockfile, and does the
# resulting image carry what it needs and nothing it must not.
#
# The two workflows deliberately build separately. Keeping them apart means a
# scan result is never attributed to an image this workflow did not build, and
# vice versa. The cost is one extra image build per run.
#
# Runner-stage hardening (non-root USER, HEALTHCHECK, .dockerignore) is owned by
# issue #498 and is intentionally NOT asserted as a pass/fail gate here.
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
container-smoke:
name: Container Smoke Build
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Build from a pristine export of the committed tree, with the base image
# re-pulled and the layer cache disabled. This is the "clean checkout, no
# local cache" requirement: no node_modules, no previous build output and
# no uncommitted local file can influence the result.
- name: Build the image from a clean checkout
run: |
set -euo pipefail
context="$(mktemp -d)"
git archive --format=tar HEAD | tar -x -C "$context"
echo "context=$context"
ls -1 "$context"
docker build \
--pull \
--no-cache \
--tag truthbounty-api:smoke \
--file "$context/Dockerfile" \
"$context"
- name: Assert the builder stage installs from the committed lockfile
run: |
set -euo pipefail
context="$(mktemp -d)"
git archive --format=tar HEAD | tar -x -C "$context"
# Introduce drift in package.json only, leaving package-lock.json
# untouched. A builder stage that honours the lockfile must refuse to
# install; a builder stage that re-resolves (`npm install`) will happily
# build, which is exactly the drift this gate exists to catch.
node -e '
const fs = require("fs");
const path = process.argv[1];
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.dependencies["socket.io"] = "4.8.0";
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
' "$context/package.json"
output_file="$(mktemp)"
if docker build --pull --no-cache --file "$context/Dockerfile" "$context" >"$output_file" 2>&1; then
echo "::error::The container build succeeded even though package.json and package-lock.json disagree. The builder stage is not consuming the committed lockfile."
exit 1
fi
cat "$output_file"
if ! grep -qiE 'out of sync|not in sync|does not satisfy|Invalid: lock file' "$output_file"; then
echo "::error::The build failed, but not for a lockfile-drift reason. This step cannot distinguish drift detection from an unrelated build failure; treat it as an unresolved failure rather than a pass."
exit 1
fi
echo "Dependency drift is rejected by the container build, as intended."
- name: Assert the runtime image carries the expected artifacts
run: |
set -euo pipefail
if ! output=$(docker run --rm --entrypoint sh truthbounty-api:smoke -c '
for path in /app/dist/main.js /app/node_modules /app/package.json /app/package-lock.json /app/src/generated; do
[ -e "$path" ] || { echo "missing expected runtime artifact: $path"; exit 1; }
done
echo "all expected runtime artifacts present"
' 2>&1); then
echo "::error::The shipped image is missing at least one expected runtime artifact."
printf '%s\n' "$output"
exit 1
fi
printf '%s\n' "$output"
- name: Assert dev-only dependencies and credentials are absent from the shipped image
run: |
set -euo pipefail
# `npm prune --production` in the builder stage must have removed
# devDependencies, including the Prisma CLI.
if ! output=$(docker run --rm --entrypoint sh truthbounty-api:smoke -c '
for pkg in typescript @nestjs/cli jest prisma; do
[ ! -e "/app/node_modules/$pkg" ] || { echo "dev-only dependency present in the production image: $pkg"; exit 1; }
done
for pkg in typeorm @nestjs/core @prisma/client; do
[ -e "/app/node_modules/$pkg" ] || { echo "runtime dependency missing from the production image: $pkg"; exit 1; }
done
# .dockerignore keeps .env out of the build context, and the runner
# stage copies only package*.json, node_modules, dist and
# src/generated, so no environment file can reach the shipped image.
found=$(find /app -maxdepth 1 -name ".env*" -print)
[ -z "$found" ] || { echo "environment file baked into the shipped image: $found"; exit 1; }
echo "no dev-only dependencies or environment files in the shipped image"
' 2>&1); then
echo "::error::The shipped image carries dev-only dependencies or environment files."
printf '%s\n' "$output"
exit 1
fi
printf '%s\n' "$output"
# Known gap, reported rather than failed: `COPY . .` in the builder
# stage still pulls tracked env templates such as .env.docker into an
# intermediate layer. They are not in the shipped image, but they are
# in the builder's layer history. Fixing it means editing .dockerignore
# or the builder COPY, both of which issue #498 owns. Reported here so
# it is not mistaken for a clean bill of health.
docker build --target builder --tag truthbounty-api:builder-smoke .
builder_env=$(docker run --rm --entrypoint sh truthbounty-api:builder-smoke -c \
'ls -1a /app | grep -E "^\.env" | paste -sd " " -')
if [ -n "$builder_env" ]; then
echo "::warning::The builder stage context contains ${builder_env}. These are not in the shipped image, but they are in the builder layer history. Removing them is owned by issue #498."
else
echo "The builder stage context contains no .env* files."
fi
{
echo "### Builder-stage environment templates"
echo
echo "\`${builder_env:-none}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Report the image's declared health check
run: |
set -euo pipefail
declared=$(docker inspect --format '{{json .Config.Healthcheck}}' truthbounty-api:smoke)
cmd=$(docker inspect --format '{{json .Config.Cmd}}' truthbounty-api:smoke)
user=$(docker inspect --format '{{json .Config.User}}' truthbounty-api:smoke)
echo "Healthcheck: $declared"
echo "Cmd: $cmd"
echo "User: $user"
{
echo "### Container runtime declaration"
echo
echo "| Field | Value |"
echo "| --- | --- |"
echo "| \`Healthcheck\` | \`$declared\` |"
echo "| \`Cmd\` | \`$cmd\` |"
echo "| \`User\` | \`$user\` |"
} >> "$GITHUB_STEP_SUMMARY"
if [ "$declared" = "null" ]; then
echo "::warning::The runner stage declares no HEALTHCHECK. Adding one (and the non-root USER) is owned by issue #498, so this workflow reports the gap instead of gating on it."
fi
# The liveness route the eventual HEALTHCHECK should target must exist
# in the source. GET /health/live is served by HealthController, which
# is @Public() and requires no dependency, so it is the correct probe.
if ! grep -q "@Get('live')" src/health/health.controller.ts; then
echo "::error::The documented liveness probe route is missing from src/health/health.controller.ts; a container HEALTHCHECK would have no target."
exit 1
fi
if ! grep -q "@Controller('health')" src/health/health.controller.ts; then
echo "::error::The documented liveness probe is not mounted at /health."
exit 1
fi
echo "Documented liveness probe GET /health/live is present in the source."