diff --git a/.github/workflows/health-check.yml b/.github/workflows/health-check.yml
index 2b30be1..09080a3 100644
--- a/.github/workflows/health-check.yml
+++ b/.github/workflows/health-check.yml
@@ -9,6 +9,11 @@ on:
workflows: ["Deploy to Production"]
types: [completed]
+ # Scheduled uptime monitoring — catches outages that happen between
+ # deploys (e.g. Pages being disabled), not just broken deploys
+ schedule:
+ - cron: '0 8 * * 1' # Mondays 08:00 UTC
+
# Manual health checks
workflow_dispatch:
@@ -16,7 +21,7 @@ jobs:
health-check:
name: Verify Site Health
runs-on: ubuntu-latest
- if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch'
+ if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
steps:
- name: Checkout
uses: actions/checkout@v4
diff --git a/app/(main)/career-guidance/page.test.tsx b/app/(main)/career-guidance/page.test.tsx
index e25589a..315718f 100644
--- a/app/(main)/career-guidance/page.test.tsx
+++ b/app/(main)/career-guidance/page.test.tsx
@@ -2,109 +2,92 @@ import { axeTest, render, screen } from 'lib/test-utils'
import { describe, expect, it } from 'vitest'
import CareerGuidancePage from './page'
+const LINKEDIN_URL = 'https://www.linkedin.com/in/wintersjordan/'
+
describe('Career Guidance Page', () => {
it('renders the hero section with main heading', () => {
render()
- expect(
- screen.getByRole('heading', { level: 1, name: /transform your career with expert guidance/i })
- ).toBeInTheDocument()
- })
-
- it('displays call-to-action buttons in hero', () => {
- render()
-
- expect(screen.getByText(/schedule free consultation/i)).toBeInTheDocument()
- expect(screen.getByText(/view services/i)).toBeInTheDocument()
+ expect(screen.getByRole('heading', { level: 1, name: /career guidance/i })).toBeInTheDocument()
+ expect(screen.getByText(/career coaching/i)).toBeInTheDocument()
})
- it('includes professional bio and credentials', () => {
+ it('displays the hero call-to-action', () => {
render()
- expect(
- screen.getByRole('heading', { name: /expert career guidance from a seasoned professional/i })
- ).toBeInTheDocument()
- expect(screen.getByText(/10\+/)).toBeInTheDocument()
- expect(screen.getByText(/years experience/i)).toBeInTheDocument()
+ const heroCta = screen.getByRole('link', { name: /get in touch on linkedin/i })
+ expect(heroCta).toHaveAttribute('href', LINKEDIN_URL)
})
it('displays all service offerings', () => {
render()
- expect(screen.getByRole('heading', { name: /1-on-1 career coaching/i })).toBeInTheDocument()
- expect(
- screen.getByRole('heading', { name: /resume & linkedin optimization/i })
- ).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /interview preparation/i })).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /career transition strategy/i })).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /leadership development/i })).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /salary negotiation/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /services offered/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /1-on-1 coaching/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /resume & linkedin review/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /interview prep/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /career transitions/i })).toBeInTheDocument()
})
- it('renders the process section', () => {
+ it('displays service benefits', () => {
render()
- expect(screen.getByRole('heading', { name: /how it works/i })).toBeInTheDocument()
- expect(screen.getByText(/book consultation/i)).toBeInTheDocument()
- expect(screen.getByText(/create strategy/i)).toBeInTheDocument()
- expect(screen.getByText(/take action/i)).toBeInTheDocument()
- expect(screen.getByText(/achieve goals/i)).toBeInTheDocument()
+ expect(screen.getByText(/personalized advice/i)).toBeInTheDocument()
+ expect(screen.getByText(/ats optimization/i)).toBeInTheDocument()
+ expect(screen.getByText(/mock interviews/i)).toBeInTheDocument()
+ expect(screen.getByText(/transition planning/i)).toBeInTheDocument()
})
- it('includes client testimonials', () => {
+ it('displays engagement options with durations', () => {
render()
- expect(screen.getByRole('heading', { name: /client success stories/i })).toBeInTheDocument()
- expect(
- screen.getByText(/working with bidwell consulting transformed my career trajectory/i)
- ).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /ways to work together/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /discovery call/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /^single session$/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /ongoing mentorship/i })).toBeInTheDocument()
+ expect(screen.getByText('30 minutes')).toBeInTheDocument()
+ expect(screen.getByText('60 minutes')).toBeInTheDocument()
+ expect(screen.getByText('3 months')).toBeInTheDocument()
})
- it('displays pricing tiers', () => {
+ it('renders a reach-out link for every engagement option', () => {
render()
- expect(screen.getByRole('heading', { name: /pricing & packages/i })).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /discovery session/i })).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /^single session$/i })).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /career accelerator/i })).toBeInTheDocument()
- expect(screen.getByText(/most popular/i)).toBeInTheDocument()
+ const reachOutLinks = screen.getAllByRole('link', { name: /reach out/i })
+ expect(reachOutLinks).toHaveLength(3)
+ for (const link of reachOutLinks) {
+ expect(link).toHaveAttribute('href', LINKEDIN_URL)
+ }
})
- it('includes FAQ section', () => {
+ it('includes FAQ section with all questions', () => {
render()
expect(screen.getByRole('heading', { name: /frequently asked questions/i })).toBeInTheDocument()
- expect(screen.getByText(/how long are the coaching sessions\?/i)).toBeInTheDocument()
- expect(screen.getByText(/what if i'm not sure which service i need\?/i)).toBeInTheDocument()
+ expect(screen.getByText(/how long are sessions\?/i)).toBeInTheDocument()
+ expect(screen.getByText(/not sure what you need\?/i)).toBeInTheDocument()
+ expect(screen.getByText(/packages available\?/i)).toBeInTheDocument()
+ expect(screen.getByText(/industry focus\?/i)).toBeInTheDocument()
+ expect(screen.getByText(/remote work\?/i)).toBeInTheDocument()
})
it('renders contact section', () => {
render()
- expect(
- screen.getByRole('heading', { name: /ready to transform your career\?/i })
- ).toBeInTheDocument()
- expect(screen.getByText(/multiple ways to connect/i)).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /get in touch/i })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /connect on linkedin/i })).toBeInTheDocument()
})
- it('includes LinkedIn links', () => {
+ it('opens all external links safely in a new tab', () => {
render()
- const linkedinLink = screen.getByRole('link', { name: /connect on linkedin/i })
- expect(linkedinLink).toHaveAttribute('href', 'https://www.linkedin.com/in/wintersjordan/')
- expect(linkedinLink).toHaveAttribute('target', '_blank')
- expect(linkedinLink).toHaveAttribute('rel', 'noopener noreferrer')
- })
-
- it('displays trust building section', () => {
- render()
-
- expect(
- screen.getByRole('heading', { name: /why choose bidwell consulting/i })
- ).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /real-world experience/i })).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /proven methodologies/i })).toBeInTheDocument()
- expect(screen.getByRole('heading', { name: /ongoing support/i })).toBeInTheDocument()
+ const links = screen.getAllByRole('link')
+ expect(links.length).toBeGreaterThan(0)
+ for (const link of links) {
+ expect(link).toHaveAttribute('href', LINKEDIN_URL)
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer')
+ }
})
it('has proper heading hierarchy', () => {
@@ -114,36 +97,16 @@ describe('Career Guidance Page', () => {
const h1Elements = screen.getAllByRole('heading', { level: 1 })
expect(h1Elements).toHaveLength(1)
- // Should have multiple h2 headings for main sections
+ // One h2 per main section: services, engagement, FAQ, contact
const h2Elements = screen.getAllByRole('heading', { level: 2 })
- expect(h2Elements.length).toBeGreaterThan(5)
- })
-
- it('includes anchor links for navigation', () => {
- render()
-
- const servicesLinks = screen.getAllByRole('link', { name: /view services/i })
- expect(servicesLinks[0]).toHaveAttribute('href', '#services')
-
- const contactLinks = screen.getAllByRole('link', { name: /schedule free consultation/i })
- expect(contactLinks[0]).toHaveAttribute('href', '#contact')
- })
-
- it('displays service benefits with checkmarks', () => {
- render()
-
- expect(screen.getByText(/customized career roadmap/i)).toBeInTheDocument()
- expect(screen.getByText(/ats-optimized resume writing/i)).toBeInTheDocument()
- expect(screen.getByText(/mock interview sessions/i)).toBeInTheDocument()
+ expect(h2Elements).toHaveLength(4)
})
- it('shows statistics in about section', () => {
- render()
+ it('exposes anchor targets for deep links', () => {
+ const { container } = render()
- expect(screen.getByText(/100\+/)).toBeInTheDocument()
- expect(screen.getByText(/clients helped/i)).toBeInTheDocument()
- expect(screen.getByText(/95%/)).toBeInTheDocument()
- expect(screen.getByText(/success rate/i)).toBeInTheDocument()
+ expect(container.querySelector('#services')).toBeInTheDocument()
+ expect(container.querySelector('#contact')).toBeInTheDocument()
})
it('is accessible', async () => {
@@ -153,37 +116,9 @@ describe('Career Guidance Page', () => {
it('has proper metadata', async () => {
const { metadata } = await import('./page')
- expect(metadata.title).toBe('Career Guidance & Coaching Services')
- expect(metadata.description).toContain('Professional career coaching')
+ expect(metadata.title).toBe('Career Guidance')
+ expect(metadata.description).toContain('career coaching')
expect(metadata.keywords).toContain('career coach')
expect(metadata.keywords).toContain('career guidance')
})
-
- it('displays all FAQ questions', () => {
- render()
-
- expect(screen.getByText(/how long are the coaching sessions\?/i)).toBeInTheDocument()
- expect(screen.getByText(/what if i'm not sure which service i need\?/i)).toBeInTheDocument()
- expect(screen.getByText(/do you offer packages or subscriptions\?/i)).toBeInTheDocument()
- expect(screen.getByText(/how quickly can i expect results\?/i)).toBeInTheDocument()
- expect(screen.getByText(/do you specialize in any particular industry\?/i)).toBeInTheDocument()
- expect(screen.getByText(/can you help with remote job searches\?/i)).toBeInTheDocument()
- })
-
- it('includes all pricing tier features', () => {
- render()
-
- expect(screen.getByText(/initial assessment/i)).toBeInTheDocument()
- expect(screen.getByText(/no commitment required/i)).toBeInTheDocument()
- expect(screen.getByText(/dedicated 1-on-1 time/i)).toBeInTheDocument()
- expect(screen.getByText(/weekly coaching sessions/i)).toBeInTheDocument()
- expect(screen.getByText(/priority scheduling/i)).toBeInTheDocument()
- })
-
- it('has multiple CTA buttons throughout the page', () => {
- render()
-
- const getStartedButtons = screen.getAllByRole('link', { name: /get started/i })
- expect(getStartedButtons.length).toBeGreaterThan(0)
- })
})
diff --git a/artifacts/postmortem_site_outage_2026-07.md b/artifacts/postmortem_site_outage_2026-07.md
new file mode 100644
index 0000000..f598685
--- /dev/null
+++ b/artifacts/postmortem_site_outage_2026-07.md
@@ -0,0 +1,98 @@
+# Post-Mortem: bidwell.info Outage (detected 2026-07-21)
+
+## Summary
+
+The production site (https://bidwell.info) served GitHub's 404 page for an
+extended period — every route, including the apex domain, the www subdomain,
+and the underlying https://dralgorhythm.github.io/bidwell/ URL. Detected
+2026-07-21 during a routine repository check; the outage began silently at an
+unknown point after 2026-02-28.
+
+## Root Cause
+
+**GitHub Pages was disabled because the repository is private and the account
+plan does not support GitHub Pages on private repositories.**
+
+Confirmed by the Pages API, which rejects both deployments and configuration
+updates with:
+
+> `Page is disabled because current plan does not support private GitHub Pages` (HTTP 422)
+
+When Pages was disabled, GitHub also removed the custom-domain binding
+(`cname: null` in the Pages config), which is why bidwell.info 404s even
+though DNS still points correctly at GitHub Pages
+(A records 185.199.108–111.153, `www` CNAME to `dralgorhythm.github.io`).
+
+The site last deployed successfully on 2025-12-08 and worked at that time, so
+the repository visibility (or the account plan) changed sometime afterward.
+
+## Contributing Causes
+
+1. **No independent uptime monitoring.** The Post-Deploy Health Check workflow
+ only ran via `workflow_run` after a deploy. With no deploys happening, an
+ outage between deploys could never be detected. The site was down for
+ months with zero signal.
+2. **CI was red on `main` since December, halting all merges (and therefore
+ all deploys).** PR #32 (career-guidance page) was merged on 2025-12-08 with
+ a failing pipeline: its test suite asserted generated marketing copy that
+ had been rewritten before merge, leaving 18 permanently failing tests on
+ `main`. All three subsequent dependabot PRs inherited the failing test leg
+ and could not go green.
+3. **Merging with a red pipeline** (PR #32) established the broken baseline.
+ No branch protection requires checks to pass before merge.
+
+## Timeline
+
+| Date | Event |
+|------|-------|
+| 2025-12-07/08 | PR #32 pipeline fails 3×; merged anyway. Deploy succeeds; site healthy. Failing tests now on `main`. |
+| 2025-12-12 → 2026-02-28 | Dependabot PRs (#35 lodash-es, #36 next, #37 rollup) all fail the inherited test leg; nothing merges; no deploys. |
+| After 2026-02-28 | Repo private / plan state disables GitHub Pages; custom domain binding removed; site 404s. Exact date unknown (no monitoring). |
+| 2026-07-21 | Outage detected. Triage, CI fix, and monitoring added (PR #38). Redeploy blocked pending account-level decision. |
+
+## Resolution
+
+- **CI restored** (PR #38): career-guidance tests rewritten against the actual
+ page content — structural assertions (headings, services, engagement
+ options, FAQ, link safety, a11y via axe, metadata) instead of verbatim
+ marketing copy. Full suite green: 189/189.
+- **Weekly scheduled health check added** (PR #38): the health-check workflow
+ now runs Mondays 08:00 UTC in addition to post-deploy, so a dead site fails
+ the workflow and GitHub emails the failure notification.
+- **Dependabot PRs unblocked**: verified locally against the fixed test suite
+ (including a full typecheck/test/build pass on the `next` 16.1.5 bump).
+
+## Remaining Action (account-level decision required)
+
+Restoring the site requires one of:
+
+1. **Make the repository public** — free; Pages works immediately. Full git
+ history was scanned with gitleaks (166 commits): no secrets found. Content
+ is a portfolio site plus site-planning docs.
+2. **Upgrade the GitHub plan** (Pro) — keeps the repo private with Pages.
+3. **Move hosting** (e.g. Cloudflare Pages) — supports private repos on free
+ tier, but deviates from the tech-strategy golden path (GitHub Pages for
+ static frontend).
+
+After option 1 or 2, recovery is:
+
+```bash
+# Re-attach the custom domain (may require re-enabling Pages in Settings → Pages first)
+gh api -X PUT repos/dralgorhythm/bidwell/pages -f build_type=workflow -f cname=bidwell.info
+# Redeploy
+gh workflow run deploy.yml --ref main
+# Verify
+gh workflow run health-check.yml
+```
+
+HTTPS for the custom domain may take a few minutes while GitHub re-provisions
+the certificate.
+
+## Lessons
+
+- Uptime monitoring must be independent of the deploy pipeline; an outage
+ that only manifests between deploys is otherwise invisible.
+- Never merge red: consider branch protection requiring the validate checks.
+- Note: GitHub disables `schedule:` workflow triggers after ~60 days of
+ repository inactivity — the weekly health check protects an active repo,
+ but a fully dormant one still needs external monitoring.