diff --git a/.github/workflows/weekly-develop-to-master.yml b/.github/workflows/weekly-develop-to-master.yml
new file mode 100644
index 0000000..539d038
--- /dev/null
+++ b/.github/workflows/weekly-develop-to-master.yml
@@ -0,0 +1,140 @@
+name: Weekly Develop to Master
+
+on:
+ schedule:
+ # Match F5's Monday schedule, away from the start-of-hour hot spot.
+ - cron: '17 12 * * 1'
+ workflow_dispatch:
+
+permissions:
+ actions: read
+ contents: write
+ pull-requests: write
+
+concurrency:
+ group: weekly-develop-to-master
+ cancel-in-progress: false
+
+jobs:
+ promote:
+ name: Promote Develop and Verify Publishing
+ if: github.ref == 'refs/heads/master'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ GH_REPO: ${{ github.repository }}
+ steps:
+ - name: Detect Unreleased Changes
+ id: changes
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ count="$(gh api "repos/$GH_REPO/compare/master...develop" --jq '.files | length')"
+ echo "has_changes=$([ "$count" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
+
+ - name: Open and Merge Release PR
+ if: steps.changes.outputs.has_changes == 'true'
+ id: release
+ env:
+ # As in F5, this token lets PR creation and merging trigger normal checks.
+ GH_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
+ run: |
+ if [ -z "$GH_TOKEN" ]; then
+ echo "::error::PERSONAL_TOKEN is required for release PR workflow events."
+ exit 1
+ fi
+ # Keep strict branch protection without writing directly to develop.
+ behind="$(gh api "repos/$GH_REPO/compare/master...develop" --jq .behind_by)"
+ if [ "$behind" -gt 0 ]; then
+ sync_branch="feature/weekly-sync-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ develop_sha="$(gh api "repos/$GH_REPO/commits/develop" --jq .sha)"
+ gh api --method POST "repos/$GH_REPO/git/refs" \
+ -f "ref=refs/heads/$sync_branch" -f "sha=$develop_sha" >/dev/null
+ sync_sha="$(gh api --method POST "repos/$GH_REPO/merges" \
+ -f "base=$sync_branch" -f head=master --jq .sha)"
+ sync_url="$(gh pr create --base develop --head "$sync_branch" \
+ --title 'Sync Develop With Production' \
+ --body '## Summary
+
+ - Bring the production history into develop before its next release.')"
+ required='["Test (Node 26)","Lint and audit","Helm lint and render","Build container","Open Source and Container","Analyze JavaScript"]'
+ ready=false
+ for attempt in {1..60}; do
+ runs="$(gh api "repos/$GH_REPO/commits/$sync_sha/check-runs?per_page=100")"
+ ready="$(jq --argjson required "$required" '
+ .check_runs as $runs | all($required[]; . as $name |
+ any($runs[]; .name == $name and .app.slug == "github-actions"))
+ ' <<< "$runs")"
+ if [ "$ready" = true ]; then break; fi
+ sleep 10
+ done
+ if [ "$ready" != true ]; then
+ echo "Required sync checks did not start: $sync_url" >&2
+ exit 1
+ fi
+ gh pr checks "$sync_url" --required --watch --fail-fast --interval 10
+ gh pr merge "$sync_url" --merge --match-head-commit "$sync_sha"
+ fi
+ pr_url="$(gh pr list --base master --head develop --state open --json url --jq '.[0].url')"
+ if [ -z "$pr_url" ]; then
+ pr_url="$(gh pr create --base master --head develop \
+ --title 'Promote Develop to Production' \
+ --body '## Summary
+
+ - Promote develop to production after every required check passes.')"
+ fi
+ head_sha="$(gh pr view "$pr_url" --json headRefOid --jq .headRefOid)"
+ # Refresh the vulnerability data even when reusing an older release PR.
+ check_after="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ for workflow in ci.yaml snyk.yml codeql.yml; do
+ gh workflow run "$workflow" --ref develop
+ done
+ required='["Test (Node 26)","Lint and audit","Helm lint and render","Build container","Open Source and Container","Analyze JavaScript"]'
+ missing=checks
+ for attempt in {1..60}; do
+ runs="$(gh api --paginate --slurp "repos/$GH_REPO/commits/$head_sha/check-runs?per_page=100")"
+ missing="$(jq -r --argjson required "$required" --arg after "$check_after" '
+ [.[].check_runs[] | select(.app.slug == "github-actions")] as $runs
+ | $required[] as $name
+ | ([$runs[] | select(.name == $name)] | max_by(.id)) as $latest
+ | select($latest == null or $latest.status != "completed"
+ or $latest.conclusion != "success" or $latest.started_at < $after)
+ | $name
+ ' <<< "$runs")"
+ if [ -z "$missing" ]; then break; fi
+ echo "Waiting for fresh checks on $head_sha: $missing"
+ sleep 10
+ done
+ if [ -n "$missing" ]; then
+ echo "Release blocked by missing or unsuccessful fresh checks: $missing" >&2
+ exit 1
+ fi
+ gh pr merge "$pr_url" --merge --auto --match-head-commit "$head_sha"
+ for attempt in {1..30}; do
+ if [ "$(gh pr view "$pr_url" --json state --jq .state)" = MERGED ]; then
+ echo "sha=$(gh pr view "$pr_url" --json mergeCommit --jq .mergeCommit.oid)" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ sleep 10
+ done
+ echo "Release PR has not merged: $pr_url" >&2
+ exit 1
+
+ - name: Verify Gated Publishing
+ if: steps.changes.outputs.has_changes == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_SHA: ${{ steps.release.outputs.sha }}
+ run: |
+ for attempt in {1..60}; do
+ run_id="$(gh run list --workflow publish.yaml --branch master --event workflow_run \
+ --limit 30 --json databaseId,headSha \
+ --jq ".[] | select(.headSha == \"$RELEASE_SHA\") | .databaseId" | head -n 1)"
+ if [ -n "$run_id" ]; then
+ gh run watch "$run_id" --exit-status --interval 10
+ exit 0
+ fi
+ sleep 10
+ done
+ echo "No Publish run found for $RELEASE_SHA." >&2
+ exit 1
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..f6bbe80
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,32 @@
+# Repository Guidelines
+
+## Project Structure & Module Organization
+
+This is a Kubernetes HPA validation app: sustained request traffic should scale its deployment, and the UI must clearly identify the pod handling each request. The Express application lives in `app.js`; `index.js` starts the server and owns graceful shutdown. The auto-refreshing hostname display is in `views/index.ejs`; static assets are in `public/`; tests are in `__tests__/`. Container configuration is in `Dockerfile` and `docker-compose.yaml`; the Helm chart is under `chart/`.
+
+## Build, Test, and Development Commands
+
+Use Node 26 and npm 11 (see `package.json`). Install the locked dependency set with `npm ci`.
+
+- `npm run dev` starts the app with file watching and the Node inspector on port 9229.
+- `npm start` runs the production-style server locally.
+- `npm run lint` checks all JavaScript with ESLint; `npm run lint:fix` applies safe fixes.
+- `npm test` runs the built-in Node test suite; `npm run test:watch` reruns it during development.
+- `docker compose up --build` starts the development image with source mounts.
+- `helm lint chart` validates the bundled Helm chart.
+
+## Coding Style & Naming Conventions
+
+Use CommonJS (`require`/`module.exports`), two-space indentation, semicolons, and single quotes, matching the existing source. Keep route handlers and middleware in `app.js`; expose startup or lifecycle helpers from `index.js`. Use descriptive camelCase JavaScript names and lowercase, hyphenated Kubernetes/Helm resource names. Run ESLint before committing rather than hand-formatting around its rules.
+
+## Testing Guidelines
+
+Write tests with `node:test`, `node:assert/strict`, and Supertest where HTTP behavior is involved. Place files in `__tests__/` with a `.test.js` suffix, and name tests as observable outcomes (for example, `Health check should return app status`). Preserve coverage for pod hostname headers/UI output, health checks, and shutdown behavior. Run `npm run lint && npm test` before opening a pull request.
+
+## Commit & Pull Request Guidelines
+
+Recent history uses short, imperative subjects such as `Upgrade runtime to Node 26`, `Fix Helm chart non-root runtime`, and `Harden container runtime and CI validation`; keep that style. Keep changes focused, explain the behavioral or deployment impact in the PR description, and include screenshots for UI-visible changes. Do not commit generated dependencies, credentials, or local environment files.
+
+## Security & Configuration
+
+Treat `package-lock.json` as authoritative and use `npm ci`; do not bypass engine checks. Preserve the non-root production image and `tini` signal handling. HPA is disabled by default; test scaling with an explicit Helm values override and keep its CPU request/target settings meaningful.
diff --git a/public/css/index.css b/public/css/index.css
index e2fc40a..49a2a0a 100644
--- a/public/css/index.css
+++ b/public/css/index.css
@@ -1,90 +1,253 @@
-#body {
- background-color: #DEF1F9;
- font-family: 'Lato', sans-serif;
+:root {
+ --ink: #f1f8fc;
+ --muted: #9eb2c1;
+ --line: rgba(221, 240, 250, 0.16);
+ --blue: #2496ed;
+ --navy: #071b2a;
}
-#container {
- max-width: 500px;
- margin: auto;
- padding-top: 20px;
+* {
+ box-sizing: border-box;
}
-#logo {
- display:block;
- margin:auto;
- padding-bottom: 5px;
+html {
+ background: var(--navy);
}
-#content {
- display:block;
- margin:auto;
- text-align: center;
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ color: var(--ink);
+ background: var(--navy);
+ font-family: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
-table {
- border-collapse: inherit;
- border-spacing: 0 28px;
- text-align: center;
+.shell {
+ width: min(100%, 1120px);
+ min-height: 100vh;
+ margin: 0 auto;
+ padding: 0 48px;
}
-span {
- font-size: 17px;
+.topbar,
+.service-status,
+.request-details {
+ border-bottom: 1px solid var(--line);
}
-sub {
- vertical-align: sub;
- font-size: 12px;
- font-weight: 900;
- display: inline-block;
- padding-top: 5px;
+.topbar {
+ display: flex;
+ min-height: 76px;
+ align-items: center;
+ justify-content: space-between;
}
-.spinner {
- margin: 2px auto;
- width: 20px;
- height: 20px;
- position: relative;
+.brand,
+.repo-link {
+ display: inline-flex;
+ align-items: center;
+ color: var(--ink);
+ text-decoration: none;
}
-.cube1, .cube2 {
- background-color: #333;
- width: 7px;
- height: 7px;
- position: absolute;
- top: 0;
- left: 0;
+.brand {
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+
+.brand-mark {
+ display: grid;
+ width: 27px;
+ height: 27px;
+ place-items: center;
+ margin-right: 10px;
+ overflow: hidden;
+ border-radius: 7px;
+ background: #fff;
+}
+
+.brand-mark img {
+ width: 20px;
+ height: 20px;
+}
+
+.repo-link {
+ gap: 8px;
+ color: var(--muted);
+ font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
+ font-size: 12px;
+}
+
+.repo-link:hover {
+ color: var(--blue);
+}
+
+.status-dot,
+.pulse {
+ display: inline-block;
+ border-radius: 50%;
+ background: var(--blue);
+ box-shadow: 0 0 0 0 rgba(36, 150, 237, 0.5);
+ animation: ping 2s infinite;
+}
+
+.status-dot {
+ width: 7px;
+ height: 7px;
+}
+
+.service-status {
+ display: flex;
+ min-height: 220px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 40px;
+ padding: 50px 0;
+}
+
+.section-label {
+ margin: 0;
+ color: var(--blue);
+ font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+h1,
+p {
+ margin-top: 0;
+}
+
+h1 {
+ margin: 10px 0;
+ font-size: clamp(2rem, 4.5vw, 3.6rem);
+ font-weight: 700;
+ letter-spacing: -0.065em;
+ line-height: 1;
+}
+
+.health-state {
+ display: flex;
+ min-width: 185px;
+ align-items: center;
+ gap: 11px;
+ padding: 18px 0 18px 18px;
+ border-left: 1px solid var(--line);
+}
+
+.pulse {
+ width: 10px;
+ height: 10px;
+ background: #32b96a;
+ box-shadow: 0 0 0 0 rgba(50, 185, 106, 0.5);
+ animation-name: health-ping;
+}
+
+.health-state strong,
+.health-state span {
+ display: block;
+}
- -webkit-animation: sk-cubemove 1.8s infinite ease-in-out;
- animation: sk-cubemove 1.8s infinite ease-in-out;
+.health-state strong {
+ font-size: 14px;
}
-.cube2 {
- -webkit-animation-delay: -0.9s;
- animation-delay: -0.9s;
+.health-state span {
+ margin-top: 4px;
+ color: var(--muted);
+ font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
+ font-size: 10px;
}
-@-webkit-keyframes sk-cubemove {
- 25% { -webkit-transform: translateX(21px) rotate(-90deg) scale(0.5) }
- 50% { -webkit-transform: translateX(21px) translateY(21px) rotate(-180deg) }
- 75% { -webkit-transform: translateX(0px) translateY(21px) rotate(-270deg) scale(0.5) }
- 100% { -webkit-transform: rotate(-360deg) }
+.request-details {
+ display: grid;
+ grid-template-columns: 210px 1fr;
+ gap: 55px;
+ padding: 34px 0;
}
-@keyframes sk-cubemove {
- 25% {
- transform: translateX(21px) rotate(-90deg) scale(0.5);
- -webkit-transform: translateX(21px) rotate(-90deg) scale(0.5);
- } 50% {
- transform: translateX(21px) translateY(21px) rotate(-179deg);
- -webkit-transform: translateX(21px) translateY(21px) rotate(-179deg);
- } 50.1% {
- transform: translateX(21px) translateY(21px) rotate(-180deg);
- -webkit-transform: translateX(21px) translateY(21px) rotate(-180deg);
- } 75% {
- transform: translateX(0px) translateY(21px) rotate(-270deg) scale(0.5);
- -webkit-transform: translateX(0px) translateY(21px) rotate(-270deg) scale(0.5);
- } 100% {
- transform: rotate(-360deg);
- -webkit-transform: rotate(-360deg);
- }
-}
\ No newline at end of file
+dl {
+ margin: -10px 0;
+}
+
+.detail-row {
+ display: grid;
+ grid-template-columns: 145px 1fr;
+ gap: 22px;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.detail-row:last-child {
+ border-bottom: 0;
+}
+
+dt,
+dd,
+footer {
+ font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
+ font-size: 12px;
+}
+
+dt {
+ color: var(--muted);
+}
+
+dd {
+ min-width: 0;
+ margin: 0;
+ overflow-wrap: anywhere;
+ line-height: 1.45;
+}
+
+footer {
+ padding: 22px 0 28px;
+ color: var(--muted);
+}
+
+#refresh-countdown {
+ display: inline-block;
+ min-width: 1ch;
+ text-align: right;
+}
+
+.refresh-indicator {
+ display: inline-block;
+ width: 13px;
+ height: 13px;
+ margin: 0 8px -3px 0;
+ border: 1px solid var(--blue);
+ border-left-color: transparent;
+ border-radius: 50%;
+ animation: turn 1.8s linear infinite;
+}
+
+@keyframes turn {
+ to { transform: rotate(360deg); }
+}
+
+@keyframes ping {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(36, 150, 237, 0.45); }
+ 45% { box-shadow: 0 0 0 7px rgba(36, 150, 237, 0); }
+}
+
+@keyframes health-ping {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(50, 185, 106, 0.45); }
+ 45% { box-shadow: 0 0 0 7px rgba(50, 185, 106, 0); }
+}
+
+@media (max-width: 680px) {
+ .shell { padding: 0 22px; }
+ .topbar { min-height: 70px; }
+ .service-status { min-height: 0; align-items: flex-start; flex-direction: column; gap: 28px; padding: 42px 0; }
+ .health-state { padding: 0; border-left: 0; }
+ .request-details { grid-template-columns: 1fr; gap: 30px; padding: 32px 0; }
+ .detail-row { grid-template-columns: 1fr; gap: 7px; padding: 13px 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
+}
diff --git a/views/index.ejs b/views/index.ejs
index 4ccca20..0f7a6f4 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -1,77 +1,67 @@
-
+
-
-
+
+
+
+
+
+
+
+
-
docker-node-app
-
-
-
-
-
-
-
-
- |
- <% if (hostname) { %>
- Hello from <%= hostname %>!
- <% } %>
- |
-
-
-
- <% if (ip) { %>
- <%= ip %>
-
- Request IP
- <% } %>
- |
-
-
-
- <% if (user_agent) { %>
- <%= user_agent %>
-
- User Agent
- <% } %>
- |
-
-
-
- <% if (timestamp) { %>
- <%= timestamp %>
-
- Timestamp
- <% } %>
- |
-
-
- |
- This page will automatically reload and connect to new
- hosts...
- |
-
-
-
-
-
+
+
+
+
+
+
+
Hostname
+ <% if (hostname) { %>
+
<%= hostname %>
+ <% } %>
+
+
+
+
HealthyGET /healthz ยท 200 OK
+
+
+
+
+
+
+ <% if (ip) { %>- Client Address
- <%= ip %>
<% } %>
+ <% if (user_agent) { %>- User Agent
- <%= user_agent %>
<% } %>
+ <% if (timestamp) { %>- Rendered At
- <%= timestamp %>
<% } %>
+
+
+
+
+
+