fix(ingest): canonicalize DateTime column values to RFC 3339 UTC #1154
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |