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
15 changes: 13 additions & 2 deletions .github/workflows/link-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ jobs:
# ran on). github.sha for workflow_run = current default-branch head.
if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_sha != github.sha) }}
runs-on: ubuntu-latest
timeout-minutes: 10
# 15, not 10: html-proofer's image/fragment pass over the full page set
# runs after lychee inside the same job.
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -73,4 +75,13 @@ jobs:
with:
tool: lychee

- run: bundle exec rake test:links
# html-proofer catches what lychee does not: image src files that are
# referenced but missing from the build, and malformed hash fragments.
# The task existed in the Rakefile but was wired into no workflow,
# hook, or script - dead code reading as coverage (2026-08-07 audit).
# It is non-blocking by its own design; test:links stays blocking.
#
# ONE rake invocation, not two steps: build_for_linkcheck memoizes per
# process, so the pair shares a single production build. Two steps
# would build the whole site twice and blow the job timeout.
- run: bundle exec rake test:links test:html_proofer
39 changes: 39 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,42 @@ jobs:
env:
PRECOMPILED_ASSETS: '1'
HUGO_DEFAULT_PATH: _dest/public-test

# Asset-pipeline integration: dev vs prod branching in css-processor,
# css-inline, and js-processor (fingerprint/integrity/minify/crossorigin).
# Previously ran only inside `rake test` on push-to-master, so a PR could
# not see it - and it used to `skip` itself when a build failed, reporting
# green for the exact regression it guards (both fixed 2026-08-07).
#
# Its own job, not a step in unit_tests: it drives two full Hugo builds of
# its own and would put that job over its timeout.
integration_tests:
name: Asset Pipeline
if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_sha != github.sha) }}
runs-on: ubuntu-latest
# 25, measured, not guessed. The suite itself runs ~10 min (two full Hugo
# builds, and the dev-environment one cannot reuse the production-keyed
# resource cache), and `actions/checkout` on this repo has been observed
# taking 7 min on a slow runner - 17 min against a 15-minute cap. A gate
# that flakes on timeout is worse than no gate: it teaches people to
# ignore red. Headroom is cheaper than that.
timeout-minutes: 25
steps:
- uses: actions/checkout@v7

- uses: ruby/setup-ruby@v1
with:
ruby-version: '4.0'
bundler-cache: true

# build: 'false' - the suite runs its own dev and prod builds.
- uses: ./.github/actions/setup-hugo
with:
build: 'false'

# This suite shells out to `hugo build` directly (it needs the dev and
# prod builds side by side), so it does not inherit the PATH that
# bin/hugo-build sets for PostCSS - mirror it here.
- run: PATH="./node_modules/.bin:$PATH" bundle exec rake test:integration
env:
HUGO_CACHEDIR: /tmp/hugo_cache
51 changes: 48 additions & 3 deletions .okf/build/ci-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ resource: .github/workflows/link-check.yml
generated:
by: process:okf-migrate
at: 2026-07-31T16:30:00Z
verified:
- by: claude/opus-5
at: 2026-08-07T00:00:00Z
---

# What CI enforces on a PR
Expand All @@ -20,15 +23,57 @@ generated:
Plus `bin/lint-css` (stylelint warning ratchet) piggybacked on the
unit_tests job. See local pre-PR gates in [test-gates.md](test-gates.md).

# Two more PR gates (added 2026-08-07)

| Check | Workflow | Runs |
|---|---|---|
| `Asset Pipeline` (`rake test:integration`) | `publish.yml` | Every push/PR |
| `rake test:html_proofer` | `link-check.yml` | Same trigger as `test:links`, same job, non-blocking |

Both were dead before this: `test:integration` ran only inside `rake test` on
push-to-master AND `skip`ped itself when the Hugo build failed (a broken build
reported green with every test skipped - now `flunk`s with the build output);
`test:html_proofer` was invoked by no workflow, hook, or script at all.

**Job runtimes are cache-dependent - budget for the cold case** (measured
2026-08-07 across three runs). `Broken Internal Links` ran 3.5 min on a warm
resource cache and **10.7 min** right after master moved and invalidated it; at
its old 10-minute timeout that run would have failed for no reason but cache
state. `Asset Pipeline` runs ~10 min (two full Hugo builds; the
dev-environment one cannot reuse the production-keyed `resources/_gen` cache),
and `actions/checkout` on this repo was observed taking **7 min** on a slow
runner - 17 min of wall clock against what was a 15-minute cap. Timeouts are
now 15 and 25. Do not trim them back toward the observed average: a gate that
flakes on timeout teaches people to ignore red, which costs more than the
runner minutes.

Two things to preserve when touching either:
- **`test:links` and `test:html_proofer` share ONE rake invocation**
(`rake test:links test:html_proofer`). Both default to the same `OUTPUT_DIR`
and each triggers `build_for_linkcheck`, which is memoized per rake PROCESS.
Split them into two `run:` steps and the site builds twice - the exact
double-build that blew this job's timeout and forced `setup-hugo build: 'false'`.
- **`test:integration` gets its own job**, not a step in `unit_tests`: it drives
two full Hugo builds of its own (~50s locally) and would push that job over
its timeout.

Still local-only: **`rake test:guards`** runs in `.githooks/pre-push`; a PR
pushed with `SKIP_CHECKS=1` never sees it.

Full gap analysis with `lib/` coverage numbers and per-layer evidence:
`docs/20-29-testing-qa/20.10-test-coverage-gap-analysis-reference.md`.

# Toolchain single source of truth

`.mise.toml` pins hugo/bun/node/ruby (local install via `mise install`;
`bin/setup` wraps it + doctor). CI copies of the pins live in
`.github/actions/setup-hugo/action.yml` (hugo default, bun-version,
node-version), workflow `ruby-version` inputs, and the `.dev/compose.yml`
image tag; the drift test fails the build when any copy diverges - update
them together. `_hugo.yml` must NOT carry its own pins (it calls the
composite with `build: 'false'`); the drift test enforces that too.
image tag. **Nothing enforces this** since the drift gate
(`test/unit/toolchain_pins_test.rb`) was deleted on 2026-08-01 as a
config-mirror anti-pattern - the copies are synced by convention now, so
update them in the same commit by hand. `_hugo.yml` must NOT carry its own
pins (it calls the composite with `build: 'false'`), also by convention.
Gotchas: the Ruby pin must be an EXACT patch version - rbenv reads
`.ruby-version` and never matches a fuzzy "4.0"; agent containers block
`api.github.com` through the proxy, so `mise install` cannot fetch
Expand Down
2 changes: 1 addition & 1 deletion .okf/build/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

* [Hugo build pipeline](hugo-build.md) - bin/hugo-build with the 8 course validators
* [Test gates](test-gates.md) - the local suites and when each is a commit blocker
* [CI gates](ci-gates.md) - what GitHub Actions enforces: build, unit, path-scoped link check (no visual regression in CI)
* [CI gates](ci-gates.md) - what GitHub Actions enforces: build, unit, path-scoped link check (visual regression is report-only), and what gates a PR never sees
* [Template PDFs](pdf-templates.md) - regenerating the downloadable course PDFs
177 changes: 177 additions & 0 deletions .okf/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,3 +652,180 @@ than it returns. Those posts still cross-link sibling technical posts, which is
what actually keeps readers on the site. Founder-stream posts keep the funnel
requirement unchanged. Exemption recorded in 20.08 and applied to
`kamal-2-multi-server-deployment-complete-guide`.
## 2026-08-07 - Test coverage gap analysis; two false-green mechanisms found

Full audit written to
`docs/20-29-testing-qa/20.10-test-coverage-gap-analysis-reference.md`.

* **`lib/` is healthy**: 91.6% line coverage (716/782 relevant lines),
measured with SimpleCov over `test/unit/sync/**` +
`course_validators_test.rb` (103 runs, 207 assertions). Worst files are
network-error branches in `dev_to_article_fetcher` (67.9%) and
`sources/base` (75.0%). SimpleCov is in the Gemfile but `require`d
nowhere, so no coverage is collected in any run today - the measurement
needs a `RUBYOPT=-r<cov.rb>` shim.
* **False green #1**: `test/integration/hugo_pipeline_test.rb:48-51` `skip`s
the whole asset-pipeline suite when the Hugo build fails - the exact
failure it guards. Should `flunk` with the build stderr.
* **False green #2**: 42 conditional assertion guards across the unit tests
(`if robots_meta ... assert ... end`, e.g. `baseof_template_test.rb:153`,
`404_template_test.rb:186`). They pass when the element is absent, while
reading as "present and well-formed".
* **`rake test:html_proofer` is invoked nowhere** (no workflow, hook, or
script); `rake test:integration` never gates a PR. Recorded in
[ci-gates](/build/ci-gates.md).
* **ci-gates.md was stale**: it claimed a toolchain drift test "fails the
build when any copy diverges". That test was deleted 2026-08-01 (see
entry above) - pins are now synced by convention with zero enforcement.
Corrected. Same stale phrase removed from the Rakefile `:guards` comment.
* **Validator scope boundary is measurable**: `CourseValidators` filters on
`course_chapter == true`, so 82 of 727 content pages are gated. Em-dash
files by scope: course 0/82, marketing 1/37, blog 208/607. The CLAUDE.md
`-` not `—` rule holds exactly where a validator enforces it. Blog needs
ratchet semantics (no NEW violations), not a hard fail on 208 legacy
dev.to imports.
* **Untested money paths**: the contact/free-consultation form renders every
field `name` from `.Site.Params.forms.contact.*`; Hugo renders a missing
param as `""` with no error, the page stays pixel-identical, and leads
submit blank. `seo/faq-schema.html` ships on 10 service pages with zero
tests while article/breadcrumb/organization/service schemas each have one.
* **`lib/sync/sources/sanity.rb`** (129 lines) is referenced by nothing
outside itself and loaded by no test - 0% covered, and it holds the
`sanity-ruby` gem dependency in place. Delete-or-test decision.

## 2026-08-07 - Closed the two false-green mechanisms; wired the two dead CI gates

Follow-through on the gap analysis logged above. `rake test:unit` 272 -> 285
runs, 5723 -> 5935 assertions, still 0 failures.

* **Integration suite fails loudly now**: `hugo_pipeline_test.rb` replaced its
two `*_ready?` predicates with `build_failure`, which returns nil or a
diagnostic and is `flunk`ed in `setup`. It distinguishes "hugo not on PATH"
from a real build error and prints the last 30 lines of build output.
Verified by shimming `hugo` to `/bin/false` - the suite fails instead of
skipping 11 tests into a green report.
* **`rake test:integration` now gates PRs** as an `Asset Pipeline` job in
publish.yml, separate from `unit_tests` (it drives two Hugo builds of its
own, ~50s locally) with `setup-hugo build: 'false'`.
* **`rake test:html_proofer` is finally invoked**: link-check.yml runs
`rake test:links test:html_proofer` as ONE rake invocation. `build_for_linkcheck`
is memoized per process so the pair shares a single production build - two
separate steps would each trigger their own build, which is exactly what blew
that job's timeout before (the reason the workflow passes `build: 'false'`).
Timeout 10 -> 15 min.
* **Two schema test files were dead code**: `breadcrumb_schema_test.rb` and
`service_schema_test.rb` were commented out in full behind stale
"restore when <X> schema implemented in reverted HTML" TODOs. The build emits
both `BreadcrumbList` and `Service` today. Uncommented, 3 tests each, green.
Lesson: a test file existing is not coverage - grep for `def test_`, not for
the filename.
* **New `test/unit/lead_forms_test.rb`**: the funnel forms' field `name`s come
from `[params.forms.*]`, and Hugo renders a missing param as `""` without
failing the build. RED-verified by renaming `first_name` in hugo.toml and
rebuilding - the test names the broken field id and the config key.
* **New `test/unit/meta_tags/faq_schema_test.rb`** (6 tests) including a sweep
over every service page declaring `faqs` in frontmatter, so a template guard
that stops matching turns red instead of silently dropping rich results.
* **Guard sweep, `baseof` + `404`**: presence assertions where the element
exists; dead branches deleted where it does not (`.logo-image-main` no longer
exists anywhere; no `meta[name=referrer]`; no search form). The mermaid SRI
test asserted the retired jsdelivr+SRI implementation while running against
index.html, which never loads mermaid - retargeted to a diagram page and
rewritten to assert same-origin, matching the self-hosting change.
* **Test-env gotcha**: `parse_html_file` uses bare `File.read`, so on a
container with no `LANG` (`Encoding.default_external` = US-ASCII) Nokogiri
aborts with "FATAL: Invalid bytes in character encoding", every selector
returns empty, and 73 template tests fail for a non-template reason. Run the
suite under `LANG=C.UTF-8`.

## 2026-08-07 - Guard sweep found a live bug the false-green was hiding

Continued the conditional-assertion sweep into `list_template_test.rb` and
`home_template_test.rb`. `rake test:unit` now 276 runs / 5967 assertions /
0 failures (from 272 / 5723): the suite got SMALLER and checks MORE.

* **Live bug, hidden for as long as the test existed**:
`test_list_page_date_information` selected post items with
`"article, .post, .post-item, .entry"`. The blog index renders `.blog-post`
and nothing else from that list, so the selector matched ZERO items on every
run - and `if items.any?` turned that into a pass. Both list tests now share
one `ITEM_SELECTOR` constant so the two cannot drift apart again. This is the
concrete argument for the sweep: a guard does not just fail to catch future
regressions, it hides present ones.
* **`setup` skips are the same defect one level up**: `list_template_test`
skipped all 13 tests when no list page was found. The blog index vanishing IS
the regression. Now `refute_empty`.
* **Classification rule that made the sweep tractable**: check the built page
first, then decide. Element present -> replace the guard with a presence
assertion. Element absent -> the branch is dead; delete it and record in
place what to assert if the feature ships. Deleted this round: filtering/
sorting, RSS head link, search, `.breadcrumb`/`.author`/`.category` elements,
homepage breadcrumbs, CSP meta, analytics (environment-gated out of the test
build).
* **Discarded-value lines are a sibling smell**: `external_scripts.length +
external_stylesheets.length` and `large_images.any? { ... }` computed a value
and dropped it. Where an invariant was behind them it is now asserted - the
404 page, blog index, and homepage each load zero third-party scripts and
stylesheets, which is also why the site needs no dns-prefetch.
* **Scan over-reports**: `.each` over a literal array, or over a collection the
test already asserted non-empty, always runs. 61 raw hits, 32 addressed;
the rest live in template_cleanup_validation (9), hugo_partials (8),
single_template (5), seo_schema (3) + singletons. Scan script is in
`docs/20-29-testing-qa/20.10-test-coverage-gap-analysis-reference.md` §5.

## 2026-08-07 - Guard sweep complete: 61 candidates triaged, four live bugs found

Finished the conditional-assertion sweep (single_template, hugo_partials,
template_cleanup_validation, seo_schema, asset_url_validation,
hugo_asset_validation, testimonial_shortcode). `rake test:unit` 275 runs /
6086 assertions / 0 failures, from 272 / 5723 - assertions +363 while the
test count went DOWN by 13. That ratio is the whole point of the exercise.

**Four live bugs the guards were hiding** (a guard does not just miss future
regressions, it hides present ones):

1. **`single_template_test.rb` never tested a single page.** `@test_pages` led
with `"blog/index.html"` and `.first` picked it, so 376 lines nominally
covering `single.html` ran against the LIST page. Pinned to a real post via
`SINGLE_PAGE`. Retargeting immediately exposed bug 4.
2. **Two item selectors omitted `.blog-post`** - the only class the blog index
renders. `test_list_page_date_information` and `test_blog_post_partials`
matched zero items on every run.
3. **`css_urls.any? do |url| assert ... end`** in asset_url_validation:
`any?` short-circuits on the first truthy block result and `assert` returns
true, so only the FIRST stylesheet was ever checked.
4. **Over-strict a11y rule**: image-only links were flagged as having no
accessible name. A link wrapping an image takes its name from the image
`alt` (WCAG 2.1 SC 1.1.1). Blog posts wrap YouTube thumbnails this way.

**Skip-style guards are the same defect one level up** and are all gone:
list_template skipped 13 tests with no list page; 404_template skipped 12 with
no 404.html; template_cleanup_validation had 9 `next unless test_page_exists?`
+ 3 `return unless` (now one `assert_empty missing` in setup, helper deleted);
seo_schema called `skip "Schema N is empty - might indicate template issue"` -
an empty JSON-LD block IS that template issue.

**Reusable rule for this class of work**: check the BUILT page first, then
decide. Element present -> presence assertion. Element absent -> the branch is
dead; delete it and record in place what to assert if the feature ships. Never
promote a guard to an assertion without confirming the element exists, and
never delete without confirming it does not.

## 2026-08-07 - CI timeouts sized from measured cold-cache runs, not averages

Three runs of the new gates produced hard numbers worth keeping:

* `Broken Internal Links`: **3.5 min warm, 10.7 min cold** (right after master
moved and invalidated `resources/_gen`). At its original 10-minute timeout
the cold run would have gone red for nothing but cache state. Now 15.
* `Asset Pipeline`: **~10 min** for the suite itself - two full Hugo builds,
and the dev-environment build cannot reuse the production-keyed resource
cache, so it reprocesses images. Plus `actions/checkout` measured at **7 min**
on one slow runner: 17 min of wall clock against a 15-minute cap. Now 25.

**Rule**: size a CI timeout from the worst observed run plus headroom, never
from the average. A gate that flakes on timeout is worse than no gate - it
trains reviewers to ignore red, and the runner minutes it "saves" are trivial
next to that. Diagnosis tell for this class: read the per-STEP timings in
`list_workflow_jobs`, not just the job duration - the 7-minute checkout was
invisible at job level and would have been misread as a slow test.
Loading
Loading