Skip to content

fix(ingest): canonicalize DateTime column values to RFC 3339 UTC #1154

fix(ingest): canonicalize DateTime column values to RFC 3339 UTC

fix(ingest): canonicalize DateTime column values to RFC 3339 UTC #1154

Workflow file for this run

name: CI
# Job DAG over the same Makefile targets `make ci` runs locally — local
# `make ci` stays the dev mirror; CI spreads the phases across free
# 4-core public-repo runners and shapes the graph for wall-clock.
#
# READ FIRST: .github/workflows/README.md — the architecture doc. It has
# the DAG diagram, the design invariants (sole required check, the
# coverage job, cache key policy, trust domains), and the
# how-to-add-a-job recipe. Comments in this file explain only what's
# local to a step.
#
# The short version:
# - `changes` classifies the change set once; the test/docs jobs hang
# off it and skip when their inputs didn't change.
# - e2e (the long pole) builds its own inputs and runs the suite
# exactly like local `make test-e2e`; each suite uploads a raw
# coverage fragment.
# - the `coverage` job merges those fragments and applies every
# threshold gate once (`make cov`) after all suites finish — exactly
# like local `make ci`'s final step.
# - the aggregator job NAMED "CI" is the ruleset's sole required check
# (skipped jobs pass).
on:
push:
branches: [main]
pull_request:
# `ready_for_review` isn't in the default trigger set — without it,
# draft → ready transitions don't re-trigger CI. `edited` is left out
# deliberately: a title/description edit would cancel + restart a full
# in-flight run via the concurrency group. Title edits are instead
# nudged along by housekeeping.yml re-running the failed title job.
types: [opened, synchronize, reopened, ready_for_review]
branches: [main]
# Merge queue: every queued PR gets a CI run against its merge-group ref
# (main ⊕ the PRs ahead of it ⊕ this PR) — the integration gate that
# replaces the old "require branches to be up to date" rule. Event-
# filtered jobs sit out (title was validated on the PR; the deploys are
# push/PR-scoped); everything else runs. A workflow WITHOUT this trigger
# never reports the required `CI` check on merge groups, so queued PRs
# would hang — keep it even if the queue is temporarily disabled.
merge_group:
# Manual re-run escape hatch — `gh workflow run CI --ref <branch>`.
workflow_dispatch:
# Default-deny; each job below redeclares exactly what it needs. Only the
# docs-preview job (sticky PR comment) holds a write scope.
permissions:
contents: read
# PR pushes: cancel in-flight, only the latest commit matters. Main pushes:
# queue serially so every commit gets its own run.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
# ── Change detection ───────────────────────────────────────────────
# Classifies the changed files (scripts/ci/classify-changes.sh — the
# single source of truth for the code/docs outputs and their fail-closed
# rules) so the test/docs jobs can skip work.
changes:
name: Detect changes
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
outputs:
code: ${{ steps.detect.outputs.code }}
docs: ${{ steps.detect.outputs.docs }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Classify changed files
id: detect
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# On merge_group events the diff base is the merge group's own
# base (current main), not an `event.before` — `||` picks
# whichever the event provides.
PUSH_BEFORE: ${{ github.event.merge_group.base_sha || github.event.before }}
PUSH_SHA: ${{ github.sha }}
run: scripts/ci/classify-changes.sh >> "$GITHUB_OUTPUT"
# ── PR title (Conventional Commits) ────────────────────────────────
# The blocking half of the title gate (housekeeping.yml keeps the sticky
# explainer comment, which needs pull_request_target write perms on fork
# PRs). Lives under the CI aggregator so "CI" stays the only required
# check. The script runs from a checkout of the DEFAULT branch — never
# the PR tree — so a PR can't tamper with its own validator.
title:
name: PR title
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 1
persist-credentials: false
- name: Check Conventional Commits format
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# Logic lives in scripts/ci/check-pr-title.sh (shellcheck-gated),
# resolved from this trusted-main checkout like scripts/lint-pr-title.sh.
run: scripts/ci/check-pr-title.sh
# ── Static checks ──────────────────────────────────────────────────
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- id: setup
uses: ./.github/actions/setup-env
with:
go-cache-suffix: "-lint"
golangci: "true"
astro: "true" # check-docs (astro check) reuses the content cache
# tidy + gofumpt + golangci + vulncheck + shellcheck + actionlint +
# Biome + markdownlint + misspell + astro check + tsc. No Docker,
# no browser. (Cache saves are automatic — setup-env's nested
# actions/cache steps save at job end on an exact-key miss.)
- name: Run static checks
run: make verify
# ── Docs site build ────────────────────────────────────────────────
# Astro site → docs-dist artifact, feeding the two deploy jobs. Pure
# pnpm (build-docs → check-docs → build-ts needs no Go toolchain), and
# only runs when a docs-affecting file changed — Go binaries are no
# longer built here: compile breakage is already caught by lint
# (golangci type-checks every package), the test suites, and the e2e
# job linking the cover binary; release builds by goreleaser-validate /
# publish-dev.
docs-build:
name: Docs build
needs: changes
if: needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# fetch-depth: 0 (full history) so the docs build can read each page's
# real last-commit date from git: Starlight's `lastUpdated` footer
# silently falls back to the build time on the default shallow
# (depth-1) clone, making every page look freshly edited.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- id: setup
uses: ./.github/actions/setup-env
with:
go: "false"
playwright: "true" # rehype-mermaid renders diagrams via Chromium
astro: "true"
- name: Build docs site
run: make build-docs
- name: Upload docs site
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docs-dist
path: docs/dist
if-no-files-found: error
retention-days: 7
overwrite: true # re-runs of this job re-upload
# On a lockfile change (= pnpm cache-key rotation; lockfile changes
# always flip changes.docs=true, so this job runs), drop store
# entries unreferenced by any workspace project before the post-job
# save captures the store — else the cache grows unboundedly as
# deps churn. Runs after the artifact upload, so docs-preview's
# poll never waits on it.
- name: Prune pnpm store before save
if: steps.setup.outputs.pnpm-cache-hit != 'true'
run: pnpm store prune
# ── Test suites ────────────────────────────────────────────────────
# COV_DEFER=1: collect coverage, skip the per-suite render + gate —
# each suite just uploads its raw fragment and the `coverage` job
# applies every gate once, exactly like local `make ci`.
unit:
name: Unit tests
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- id: setup
uses: ./.github/actions/setup-env
with:
go-cache-suffix: "-unit"
- name: Run Go unit tests + SDK vitest tests
run: make test-unit test-ts COV_DEFER=1
- name: Upload coverage fragment
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-unit
path: tmp/coverage
if-no-files-found: error
retention-days: 3
overwrite: true
integration:
name: Integration tests
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# Background pull overlaps the ~25s of cache restores below, so
# testcontainers finds the image already local (a concurrent pull of
# the same tag is safe — dockerd dedupes layer downloads). Image tag
# must match tests/integration/setup_test.go.
- name: Prefetch ClickHouse image (background)
run: docker pull -q clickhouse/clickhouse-server:latest >/tmp/clickhouse-pull.log 2>&1 &
- id: setup
uses: ./.github/actions/setup-env
with:
go-cache-suffix: "-integration"
node: "false" # pure Go + testcontainers ClickHouse
- name: Run Go integration tests
run: make test-integration COV_DEFER=1
- name: Upload coverage fragment
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-integration
path: tmp/coverage
if-no-files-found: error
retention-days: 3
overwrite: true
# The run's long pole. Builds its own inputs (SDK dist + cover binary,
# in parallel, on a warm cache) instead of waiting on a builder job,
# runs the suite exactly like local `make test-e2e` (one orchestrator,
# sequential files), and uploads its coverage fragment for the
# `coverage` job to merge + gate — symmetric with unit/integration.
e2e:
name: E2E tests
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# Background pull overlaps the ~35s of setup + build below, so the
# orchestrator finds the image already local (a concurrent pull of
# the same tag is safe — dockerd dedupes layer downloads). Image tag
# must match scripts/orchestrator/main.go.
- name: Prefetch ClickHouse image (background)
run: docker pull -q clickhouse/clickhouse-server:latest >/tmp/clickhouse-pull.log 2>&1 &
# Suffix is -e2e-cov, not -e2e: this cache must carry the
# cover-instrumented server objects build-cover compiles below, but
# saves are gated on an exact-key miss — the pre-existing -e2e entry
# (orchestrator objects only) would exact-hit forever and never
# absorb them, leaving build-cover cold on every run.
- id: setup
uses: ./.github/actions/setup-env
with:
go-cache-suffix: "-e2e-cov"
# `-j` builds the prereqs (build-ts ∥ build-cover) concurrently,
# then runs the orchestrator: ClickHouse testcontainer + the cover
# binary + the SDK vitest suite.
- name: Build SDK dist + cover binary, run E2E suite
run: make -j "$(nproc)" test-e2e COV_DEFER=1
- name: Upload coverage fragment
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-e2e
path: tmp/coverage
if-no-files-found: error
retention-days: 3
overwrite: true
# ── Consolidated coverage report + gates ───────────────────────────
# Merges the three suites' coverage fragments and applies every
# threshold gate once (`make cov`) — exactly like local `make ci`'s
# final step. A dedicated job (rather than folded into e2e's tail) so
# the gate is decoupled from the e2e suite's own pass/fail.
#
# `needs: changes` only — NOT the suites: a `needs` edge is a scheduling
# barrier (GitHub won't pick up a runner / checkout / restore caches /
# install deps until the needed jobs finish), which would serialize this
# job's ~50s of setup onto the critical path after the last suite, for
# nothing — the setup doesn't depend on the suites' results. Instead it
# starts at run creation, does its setup IN PARALLEL with the suites, and
# blocks only at the merge, polling for the three fragments
# (scripts/ci/wait-artifact.sh) so `make cov` fires within seconds of the
# last suite finishing. Tail on the critical path: ~10s, not ~50s.
coverage:
name: Coverage
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
# Generous: this job starts early and idle-polls until e2e (the long
# pole, itself 25-min capped) uploads. Must comfortably outwait a
# slow-but-valid e2e that was ALSO scheduled later than this job on a
# busy runner pool (both hang off `changes`, no inter-dependency); a
# genuinely failed/timed-out e2e is caught fast by the poll's
# producer-check, not this cap.
timeout-minutes: 35
permissions:
contents: read
actions: read # poll for + download the suites' coverage fragments from this run
# Upload the Cobertura report to GitHub Code Quality, which posts the
# PR coverage comment (aggregate + per-file diff vs main). Narrow
# scope: it can't read secrets or write contents. Public preview, so
# actionlint doesn't know the scope yet — suppressed in
# .github/actionlint.yaml.
code-quality: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- id: setup
uses: ./.github/actions/setup-env
with:
go-cache-suffix: "-cov"
# `go run ./scripts/cov report` (make cov) shells `pnpm exec nyc`
# for the TS coverage merge, so node_modules must be present. This
# (and the checkout + cache restores above) overlaps the suites.
- name: Install workspace deps
run: make pnpm-install
# Block here — and only here — until the data the merge needs exists.
# Fails fast if a producer concluded without producing (its own
# failure already reds the aggregator). Producer names must match the
# suites' job `name:` fields.
- name: Wait for coverage fragments
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# --timeout (30 min) comfortably outwaits e2e's own 25-min job cap
# plus any queue delay between the two independently-scheduled jobs
# (default is 10 min); a failed/timed-out e2e is caught fast by the
# producer-check, so the large cap costs nothing on the failure path.
run: >-
scripts/ci/wait-artifact.sh
--artifacts coverage-unit,coverage-integration,coverage-e2e
--producers "Unit tests,Integration tests,E2E tests"
--timeout 1800
- name: Download coverage fragments
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: coverage-*
merge-multiple: true
path: tmp/coverage
- name: Render consolidated report + gate thresholds
run: make cov
# ── GitHub Code Quality: PR coverage comment (non-gating) ──────────
# Convert the merged Go profile to Cobertura and hand it to GitHub
# Code Quality, whose bot posts the PR coverage comment (aggregate +
# per-file gained/lost vs main). `!cancelled()` so a coverage *drop*
# that fails the gate above still gets a comment; the upload is
# continue-on-error, so this preview feature can never red CI — the
# gate stays `make cov`. The -ignore-dirs mirror .testcoverage.yml's
# global excludes so Code Quality's % tracks the merged-total gate.
- name: Convert Go coverage to Cobertura
if: ${{ !cancelled() }}
run: |
[ -f tmp/coverage/total/coverage.txt ] || exit 0
go tool gocover-cobertura \
-ignore-dirs '/(testutil|tests|scripts)(/|$)' \
< tmp/coverage/total/coverage.txt > tmp/coverage/go-coverage.xml
# Skipped on fork PRs (no code-quality token), per GitHub's own guard:
# baseline on main pushes, comparison on same-repo PRs.
- name: Upload Go coverage to Code Quality
if: >-
${{ !cancelled() && (
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)
) }}
continue-on-error: true
uses: actions/upload-code-coverage@abb5995db9e0199b0e2bb9dbd136fce4cb1ec4d3 # v1.3.0
with:
file: tmp/coverage/go-coverage.xml
language: go
# ── Go coverage badge data (main only) ─────────────────────────────
# Emit the shields.io endpoint JSON for the merged Go total (the exact
# gated number) and hand it to the `badge` job, the sole holder of
# contents:write, which publishes it to the `badges` branch.
- name: Generate Go coverage badge
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }}
run: |
mkdir -p tmp/coverage/badge
go run ./scripts/cov badge > tmp/coverage/badge/coverage-go.json
- name: Upload badge data
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: go-coverage-badge
path: tmp/coverage/badge
if-no-files-found: error
retention-days: 3
overwrite: true
# Coverage in the job-summary panel — the at-a-glance per-run number
# (the README badge + Code Quality PR comment are the published
# surfaces; this stays for the full per-package func table).
- name: Coverage summary
if: always()
run: |
if [ -f tmp/coverage/total/coverage.txt ]; then
{
echo '## Test Coverage (merged unit + integration + e2e)'
echo '```'
go tool cover -func=tmp/coverage/total/coverage.txt | tail -n 30
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi
# ── Coverage badge publish (main only, non-gating) ─────────────────
# Publishes the Go merged-total coverage to the orphan `badges` branch as
# a shields.io endpoint JSON, which the README badge reads over
# raw.githubusercontent.com (#133). The ONLY job holding contents:write,
# and it runs solely on main pushes off a trusted-main checkout — it never
# executes PR code (trust-domain invariant). Non-gating: deliberately NOT
# in the `CI` aggregator's needs (a badge push must never block a merge) —
# it reports its own "Coverage badge" status. The number is produced by
# the coverage job (`cov badge`, the exact gated value) and passed here as
# the go-coverage-badge artifact, so this job needs no Go toolchain.
badge:
name: Coverage badge
needs: [changes, coverage]
if: >-
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write # push the badge JSON to the `badges` branch
actions: read # download the go-coverage-badge artifact from this run
steps:
# persist-credentials stays at its default (true): this job's whole
# purpose is to push, and it runs only on trusted main. publish-badge.sh
# fetches the badges branch itself, so a shallow checkout is enough.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 1
- name: Download badge data
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: go-coverage-badge
path: tmp/coverage/badge
- name: Publish badge to the badges branch
run: scripts/ci/publish-badge.sh tmp/coverage/badge/coverage-go.json coverage-go.json
# ── Docs deploys ─────────────────────────────────────────────────────
# Both deploy jobs check out the DEFAULT branch (or, on push, the pushed
# main commit itself) and install wrangler from that trusted lockfile —
# PR-authored code never executes in a job that can read the Cloudflare
# token (#305). The only PR-derived input is the static docs-dist
# artifact, uploaded as data. Consequence: a PR changing docs/worker/ or
# docs/wrangler.jsonc previews through MAIN's worker + config; those
# changes take effect when the PR merges.
#
# Cloudflare's own Workers Builds can't build this site (no headless
# browser at build time for rehype-mermaid), so we ship from here. See
# docs/wrangler.jsonc. Both jobs soft-gate on the secrets so a
# not-yet-configured repo warns and skips instead of failing CI.
# PRs: upload a non-active version and post its preview URL as a sticky
# comment. Needs only the build artifact, so it publishes right after the
# build. NON-GATING: deliberately NOT in the `ci` aggregator's `needs`
# (it is in `timing`'s, for the table) — the preview is a convenience,
# not a merge gate. `docs-build` already validates the build (and is
# required); this only deploys MAIN's worker + the static dist to a
# preview URL, so a slow or failed Cloudflare deploy must not delay or
# red the required `CI` check. It still reports its own "Docs preview"
# check status (red on failure, with the URL on success). Fork PRs skip:
# no secrets there, and the job would have nothing to do.
docs-preview:
name: Docs preview
needs: [changes, docs-build]
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
# The sticky-comment endpoint (/repos/.../issues/{num}/comments)
# requires `issues: write`; `pull-requests: write` alone doesn't
# grant it.
issues: write
pull-requests: write
steps:
# Trusted tree: wrangler + worker source + config resolve from main,
# not the PR. No persisted credentials — the comment step's fallback
# `git fetch origin <sha>` works anonymously on a public repo (PR
# heads are advertised under refs/pull/*). Note this also pins
# setup-env itself to main's copy in this job.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 1
persist-credentials: false
- id: setup
uses: ./.github/actions/setup-env
with:
go: "false"
- name: Install workspace deps (trusted lockfile)
run: make pnpm-install
- name: Download docs site artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docs-dist
path: docs/dist
- name: Check deploy secrets
id: cfsecrets
env:
TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ACCT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
if [ -n "$TOKEN" ] && [ -n "$ACCT" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::warning::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not set — skipping docs preview."
fi
# A real upload failure (e.g. a misscoped CLOUDFLARE_API_TOKEN →
# "Authentication error [code: 10000]") reds the PR instead of hiding
# — the retry loop first absorbs transient Cloudflare API blips so a
# one-off hiccup doesn't.
- name: Upload docs preview
id: preview
if: steps.cfsecrets.outputs.ok == 'true'
working-directory: docs
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
log="$RUNNER_TEMP/wrangler-upload.log"
# Retry absorbs transient CF API blips; a persistent failure falls
# through to exit 1 and reds the PR.
n=0
until pnpm exec wrangler versions upload 2>&1 | tee "$log"; do
n=$((n + 1))
if [ "$n" -ge 3 ]; then
echo "::error::wrangler versions upload failed after $n attempts — see log above."
exit 1
fi
echo "::warning::wrangler versions upload failed (attempt $n/3) — retrying in 10s."
sleep 10
done
url="$(grep -iE 'Version Preview URL' "$log" | grep -oE 'https://[A-Za-z0-9.-]+\.workers\.dev' | head -1 || true)"
[ -z "$url" ] && url="$(grep -oE 'https://[A-Za-z0-9.-]+\.workers\.dev' "$log" | head -1 || true)"
echo "url=$url" >> "$GITHUB_OUTPUT"
if [ -n "$url" ]; then
{ echo "### 📚 Docs preview"; echo "$url"; } >> "$GITHUB_STEP_SUMMARY"
else
echo "::warning::Upload succeeded but no preview URL was parsed from the wrangler output."
fi
# One sticky comment, updated in place (matched by the marker) so
# re-pushes don't spam the PR — built by scripts/ci/docs-preview-comment.sh,
# which resolves from the trusted MAIN checkout like everything else in
# this job. Runs whether the upload succeeded or failed — `!cancelled()`
# + an outcome check, since a bare `if:` is implicitly `success()` and
# would skip after the upload step's exit 1 — so a failed deploy is
# noted rather than leaving a stale "live" comment. Skipped only when
# no upload was attempted (outcome == 'skipped').
- name: Comment docs preview status
if: ${{ !cancelled() && (steps.preview.outcome == 'success' || steps.preview.outcome == 'failure') }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
URL: ${{ steps.preview.outputs.url }}
SHA: ${{ github.event.pull_request.head.sha }}
PREVIEW_OUTCOME: ${{ steps.preview.outcome }}
run: scripts/ci/docs-preview-comment.sh
# Production: push (or manual dispatch) on main → publish the active
# version served on wavehouse.dev. Gated on the full pipeline (lint +
# docs build + every suite + the merged coverage gate) — not
# best-effort: a failure here surfaces as a red CI run. `title`/
# `docs-preview` are deliberately NOT in `needs`: they skip on push
# events, and a failed need would skip this job; a skipped need
# (coverage/suites on a docs-only push) is treated as success, so the
# deploy still runs.
docs-deploy:
name: Docs deploy
needs: [changes, lint, docs-build, unit, integration, e2e, coverage]
if: >-
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
github.ref == 'refs/heads/main' &&
needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
# Push-to-main checkout IS the trusted tree at the pushed commit.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- id: setup
uses: ./.github/actions/setup-env
with:
go: "false"
- name: Install workspace deps
run: make pnpm-install
- name: Download docs site artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docs-dist
path: docs/dist
- name: Check deploy secrets
id: cfsecrets
env:
TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ACCT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
if [ -n "$TOKEN" ] && [ -n "$ACCT" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::warning::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not set — skipping docs deploy."
fi
- name: Deploy docs (production)
if: steps.cfsecrets.outputs.ok == 'true'
working-directory: docs
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: pnpm exec wrangler deploy
# ── Aggregator: the ruleset's sole required check ──────────────────
# Fails when any upstream job failed or was cancelled; treats `skipped`
# as a pass (path-filtered suites, event-filtered title/deploy jobs are
# intentional no-ops). `!cancelled()` rather than `always()` so a
# cancel-in-progress superseded run doesn't burn a runner just to report
# a failure nobody reads — the replacement run produces the fresh "CI".
#
# `docs-preview` is deliberately NOT a need: it's the convenience preview
# deploy (see its job), non-gating by design, so it neither delays nor
# reds the required check — its own "Docs preview" status reports it. The
# `docs-build` it depends on IS here, so a broken docs BUILD still gates.
# `timing` and `badge` (both non-gating) are the only other jobs excluded.
ci:
name: CI
needs:
[
changes,
title,
lint,
docs-build,
unit,
integration,
e2e,
coverage,
docs-deploy,
]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Gate on all jobs
env:
NEEDS: ${{ toJSON(needs) }}
run: |
jq -r 'to_entries[] | "\(.value.result)\t\(.key)"' <<<"$NEEDS"
bad="$(jq -r '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key] | join(", ")' <<<"$NEEDS")"
if [ -n "$bad" ]; then
echo "::error::Jobs failed or were cancelled: $bad"
exit 1
fi
echo "All jobs succeeded (or were intentionally skipped)."
# ── Wall-clock summary (non-gating) ────────────────────────────────
# Writes a per-job timing table to the run's Summary page so "where did
# the time go" never needs per-job archaeology. Deliberately NOT in the
# aggregator: it needs a checkout + an API call, and the aggregator is
# the critical path's last hop. continue-on-error so a hiccup here can
# never red a run the aggregator passed.
timing:
name: Timing summary
needs:
[
changes,
title,
lint,
docs-build,
unit,
integration,
e2e,
coverage,
badge,
docs-preview,
docs-deploy,
]
if: ${{ !cancelled() }}
continue-on-error: true
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read # reads the run's own job timings
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Write wall-clock table
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: scripts/ci/timing-summary.sh >> "$GITHUB_STEP_SUMMARY"