Skip to content
Open
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
197 changes: 197 additions & 0 deletions .github/workflows/parse-server-mongo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# parse-server-mongo sample CI — keploy-independent end-to-end smoke +
# coverage gate.
#
# Triggers ONLY on changes under parse-server-mongo/ (or this workflow
# file). Other samples in this repo have their own orthogonal CI;
# gating the whole repo on every parse-server change would slow them
# all down for no benefit.
#
# What it gates:
# * `release-coverage` — checks out the PR's base branch (main)
# and runs the sample end-to-end: docker compose up, bootstrap
# the fixed user + session, drive flow.sh record-traffic with
# the per-call audit log enabled, capture the route-coverage
# percentage from `flow.sh coverage`. This is the baseline.
# * `build-coverage` — same end-to-end against the PR's HEAD ref.
# * `coverage-gate` — fails the PR if `build`'s coverage drops
# more than COVERAGE_THRESHOLD percentage points below
# `release`. Default threshold is 1.0pp; override via repo
# variable `PARSE_COVERAGE_THRESHOLD` for a tighter or
# looser bar.
#
# On push to main, only `build-coverage` runs (no baseline to
# compare against — main IS the baseline).
#
# Standards-aligned choices:
# * `paths:` filter on both push and pull_request triggers — the
# canonical GH Actions way to scope a workflow to one
# subdirectory.
# * Job outputs (steps.<id>.outputs.coverage → needs.<job>.outputs)
# to thread the captured percentage between jobs.
# * `concurrency:` cancel-in-progress on the same ref so a stale
# run doesn't waste runner minutes.
# * actions/upload-artifact for the human-readable
# coverage_report.txt — reviewers can inspect missing routes
# directly from the PR's "checks" tab.
# * marocchino/sticky-pull-request-comment for the PR-side diff
# comment. Pinned-by-header so successive runs update the same
# comment instead of fanning out.
# * The compare step is plain bash + python3 (no external
# coverage service). The sample's coverage is a single
# route-based percentage, so the gate is a 3-line subtraction.
#
# Sample is genuinely keploy-independent here: the workflow uses
# flow.sh's $PARSE_FIRED_ROUTES_FILE per-call audit log as its
# numerator source, not a keploy recording. The lane scripts in
# keploy/integrations and keploy/enterprise consume the same
# flow.sh, but use the keploy/test-set-*/tests/*.yaml tree as
# their numerator (authoritative — only calls keploy actually
# CAPTURED count). Both modes are wired into
# `flow.sh::parse_collect_recorded_routes`.
name: parse-server-mongo sample

on:
pull_request:
paths:
- 'parse-server-mongo/**'
- '.github/workflows/parse-server-mongo.yml'
- '.github/workflows/scripts/run-and-measure-parse-server.sh'
push:
branches: [main]
paths:
- 'parse-server-mongo/**'
- '.github/workflows/parse-server-mongo.yml'
- '.github/workflows/scripts/run-and-measure-parse-server.sh'
workflow_dispatch: {}

concurrency:
group: parse-server-mongo-${{ github.ref }}
cancel-in-progress: true

env:
COVERAGE_THRESHOLD: ${{ vars.PARSE_COVERAGE_THRESHOLD || '1.0' }}

jobs:
build-coverage:
name: build (current ref) coverage
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
coverage: ${{ steps.measure.outputs.coverage }}
steps:
- uses: actions/checkout@v4
- id: measure
name: Run sample end-to-end + measure coverage
working-directory: parse-server-mongo
env:
PARSE_FIRED_ROUTES_FILE: ${{ runner.temp }}/fired-routes-build.log
PARSE_PHASE: ci-build
run: ../.github/workflows/scripts/run-and-measure-parse-server.sh

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-build
path: parse-server-mongo/coverage_report.txt
if-no-files-found: warn

release-coverage:
if: github.event_name == 'pull_request'
name: release (base ref) coverage
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
coverage: ${{ steps.measure.outputs.coverage || steps.empty-baseline.outputs.coverage }}
sample-existed: ${{ steps.detect.outputs.sample-existed }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}

# First-PR bootstrap escape hatch: the very PR that
# introduces the parse-server-mongo/ sample has no baseline
# (parse-server-mongo/ doesn't exist on the base ref). Detect
# that and short-circuit to coverage=0; the gate then
# treats build's coverage as the new baseline and trivially
# passes for any percentage > 0. After the introducing PR
# merges, every subsequent PR has a real baseline to diff
# against.
- id: detect
name: Detect baseline presence
run: |
if [ -d parse-server-mongo ] && [ -x parse-server-mongo/flow.sh ] && [ -f .github/workflows/scripts/run-and-measure-parse-server.sh ]; then
echo "sample-existed=true" >>"$GITHUB_OUTPUT"
echo "Sample exists on base ref — running full measurement."
else
echo "sample-existed=false" >>"$GITHUB_OUTPUT"
echo "No parse-server-mongo/ on base ref — first-PR bootstrap; baseline coverage treated as 0%."
fi

- id: measure
name: Run sample end-to-end + measure coverage
if: steps.detect.outputs.sample-existed == 'true'
working-directory: parse-server-mongo
env:
PARSE_FIRED_ROUTES_FILE: ${{ runner.temp }}/fired-routes-release.log
PARSE_PHASE: ci-release
run: ../.github/workflows/scripts/run-and-measure-parse-server.sh

- id: empty-baseline
name: Emit zero baseline (first-PR bootstrap)
if: steps.detect.outputs.sample-existed != 'true'
run: echo "coverage=0.0" >>"$GITHUB_OUTPUT"

- name: Upload coverage report
if: always() && steps.detect.outputs.sample-existed == 'true'
uses: actions/upload-artifact@v4
with:
name: coverage-release
path: parse-server-mongo/coverage_report.txt
if-no-files-found: warn

coverage-gate:
if: github.event_name == 'pull_request'
name: coverage gate
needs: [build-coverage, release-coverage]
runs-on: ubuntu-latest
steps:
- name: Compare build vs release
env:
BUILD: ${{ needs.build-coverage.outputs.coverage }}
RELEASE: ${{ needs.release-coverage.outputs.coverage }}
THRESHOLD: ${{ env.COVERAGE_THRESHOLD }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -Eeuo pipefail
if [ -z "${BUILD:-}" ] || [ -z "${RELEASE:-}" ]; then
echo "::error::missing coverage outputs — build='${BUILD:-}' release='${RELEASE:-}'"
exit 1
fi
drop=$(python3 -c "print(round(${RELEASE} - ${BUILD}, 2))")
echo "Release (${BASE_REF}): ${RELEASE}%"
echo "Build (this PR): ${BUILD}%"
echo "Drop: ${drop}pp (threshold ${THRESHOLD}pp)"
if python3 -c "import sys; sys.exit(0 if (${RELEASE} - ${BUILD}) > ${THRESHOLD} else 1)"; then
echo "::error::parse-server-mongo coverage dropped from ${RELEASE}% → ${BUILD}% (-${drop}pp), exceeding the ${THRESHOLD}pp threshold."
echo "Suggested actions:"
echo " * Add curl(s) to flow.sh::parse_record_traffic that exercise the routes you changed/touched."
echo " * If the route(s) was intentionally retired, drop it from parse-server-mongo/flow.sh::parse_list_routes too so it's removed from the denominator."
exit 1
fi
echo "OK — coverage delta within ${THRESHOLD}pp threshold."

- name: Sticky PR comment
if: ${{ !cancelled() }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: parse-server-mongo-coverage
message: |
### parse-server-mongo sample coverage

| ref | coverage |
|---|---|
| base (`${{ github.event.pull_request.base.ref }}`) | **${{ needs.release-coverage.outputs.coverage }}%** |
| this PR | **${{ needs.build-coverage.outputs.coverage }}%** |

Threshold: PR may not drop coverage by more than **${{ env.COVERAGE_THRESHOLD }}pp**. Override per-repo via the `PARSE_COVERAGE_THRESHOLD` actions variable.
86 changes: 86 additions & 0 deletions .github/workflows/scripts/run-and-measure-parse-server.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# run-and-measure-parse-server.sh — bring parse-server + mongo up
# under the coverage overlay, run flow.sh bootstrap + record-traffic,
# stop parse-server cleanly so V8 flushes NODE_V8_COVERAGE, run
# flow.sh coverage, and emit `coverage=PCT` onto $GITHUB_OUTPUT
# for the downstream coverage-gate job.
#
# Coverage isolation contract:
# * Base `Dockerfile` and `docker-compose.yml` are untouched.
# * The overlay `Dockerfile.coverage` + `docker-compose.coverage.yml`
# installs the V8 coverage entrypoint shim and sets
# NODE_V8_COVERAGE. ONLY this script applies the overlay; the
# keploy/integrations and keploy/enterprise CI lanes consume
# the base compose and pay zero coverage-instrumentation cost.
#
# Inputs (from the workflow env):
# PARSE_PHASE — label spliced into flow.sh's token-file slot
# so build vs. release runs don't collide.
# GITHUB_OUTPUT — standard GH Actions sink for step outputs.
set -Eeuo pipefail

export PARSE_APP_CONTAINER="${PARSE_APP_CONTAINER:-parse-server-mongo-app}"
export PARSE_MONGO_CONTAINER="${PARSE_MONGO_CONTAINER:-parse-server-mongo-mongo}"
export PARSE_HOST_PORT="${PARSE_HOST_PORT:-6100}"
export PARSE_CONTAINER_PORT="${PARSE_CONTAINER_PORT:-6100}"
export PARSE_APP_ID="${PARSE_APP_ID:-keploy-parse-app}"
export PARSE_MASTER_KEY="${PARSE_MASTER_KEY:-keploy-parse-master}"
export PARSE_MOUNT_PATH="${PARSE_MOUNT_PATH:-/parse}"
export APP_PORT="${APP_PORT:-${PARSE_HOST_PORT}}"

mkdir -p coverage
chmod 777 coverage # node UID inside container differs from runner UID
sudo rm -rf coverage/coverage-* coverage/coverage_report.txt coverage/coverage-summary.json 2>/dev/null \
|| rm -rf coverage/coverage-* coverage/coverage_report.txt coverage/coverage-summary.json 2>/dev/null \
|| true

COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.coverage.yml)

# Bring up parse-server + mongo under the coverage overlay. The
# Dockerfile.coverage layer wraps node so SIGTERM produces a clean
# `process.exit(0)` (otherwise express's app.listen pins the loop
# and signal-kills bypass V8's coverage flush).
"${COMPOSE[@]}" up -d --build

# Wait for /parse/health to return 200.
for i in $(seq 1 120); do
code=$(curl -sS -o /dev/null -w '%{http_code}' \
-H "X-Parse-Application-Id: ${PARSE_APP_ID}" \
"http://127.0.0.1:${PARSE_HOST_PORT}${PARSE_MOUNT_PATH}/health" 2>/dev/null || echo "")
if [ "$code" = "200" ]; then break; fi
sleep 2
done

# Idempotent signup + session-token persistence under
# /tmp/parse-token-${PARSE_PHASE}.
bash flow.sh bootstrap 240

# Exercise the REST + GraphQL surface.
bash flow.sh record-traffic

# Stop parse-server cleanly so the SIGTERM handler's process.exit(0)
# fires and V8 flushes NODE_V8_COVERAGE.
"${COMPOSE[@]}" stop -t 30 parse-server

# Generate the coverage report from the V8 dumps. flow.sh::parse_coverage
# launches a one-off container against the same coverage volume.
bash flow.sh coverage

if [ ! -f coverage_report.txt ]; then
echo "::error::flow.sh coverage produced no coverage_report.txt"
exit 1
fi

# Parse `Covered N/M (XX.X%)` — anchored on the parenthesised form
# so a future report-prose change doesn't break the parse.
pct=$(grep -oE '\([0-9]+\.[0-9]+%\)' coverage_report.txt | head -1 | tr -d '()%')
if [ -z "$pct" ]; then
echo "::error::Could not parse coverage percentage from coverage_report.txt"
cat coverage_report.txt || true
exit 1
fi
echo "coverage=${pct}" >>"$GITHUB_OUTPUT"
echo "coverage: ${pct}% (JS line coverage via NODE_V8_COVERAGE + custom report)"

"${COMPOSE[@]}" down -v --remove-orphans
2 changes: 2 additions & 0 deletions parse-server-mongo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
coverage/
coverage_report.txt
45 changes: 45 additions & 0 deletions parse-server-mongo/Dockerfile.coverage
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Coverage overlay image for parse-server-mongo.
#
# Extends the base sample image build chain (node:20-bookworm-slim +
# parse-server deps + index.js) with c8 (for `c8 report`) and a
# tiny JavaScript entrypoint shim that registers SIGTERM/SIGINT
# handlers calling process.exit(0) — without that, parse-server's
# express server pins the event loop and signal-driven kills
# bypass V8's coverage flush, leaving NODE_V8_COVERAGE empty.
#
# IMPORTANT: this image is only consumed by docker-compose.coverage.yml.
# The base Dockerfile and docker-compose.yml stay uninstrumented so
# enterprise's keploy compat lane pays no coverage-instrumentation
# cost.
FROM node:20-bookworm-slim

RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl dumb-init && \
rm -rf /var/lib/apt/lists/*

WORKDIR /usr/src/app

COPY package*.json ./
RUN npm install --omit=dev

COPY index.js ./

# c8 is the report generator (we use NODE_V8_COVERAGE for raw data
# collection at runtime, then `c8 report` post-hoc to produce
# json-summary / lcov). Installing globally keeps the app's own
# node_modules byte-identical to the base image.
RUN npm install -g c8@10.1.2

# Graceful-shutdown shim: parse-server's app.listen() pins the
# event loop, so a `compose stop` (SIGTERM) would kill node by
# signal — exit code 143 — before V8's NODE_V8_COVERAGE writer
# runs. Calling process.exit(0) from a SIGTERM handler turns the
# kill into a clean exit, so V8 dumps coverage to NODE_V8_COVERAGE
# before the process terminates.
RUN printf "process.on('SIGTERM', () => process.exit(0));\nprocess.on('SIGINT', () => process.exit(0));\nrequire('/usr/src/app/index.js');\n" \
> /usr/src/app/coverage-entrypoint.js

EXPOSE 1337

ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "/usr/src/app/coverage-entrypoint.js"]
Loading
Loading