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
23 changes: 23 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# The Dockerfile COPYs explicit paths, so this file is about build-context
# size, not correctness: without it every `docker build` uploads .git, the
# node_modules trees under packages/, and the docs site to the daemon first.
.git
.github
.venv
**/__pycache__
**/*.pyc
**/node_modules
**/dist
**/.pytest_cache
**/.ruff_cache
**/.mypy_cache
docs
site
tests
packages/fathom-editor
packages/fathom-go
packages/fathom-ts
packages/fathom-studio/src
packages/fathom-studio/tests
coverage.xml
*.log
97 changes: 97 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,100 @@ jobs:

- name: Version sync
run: uv run python scripts/check_version_sync.py

# The image had been broken since the Studio was moved into a uv workspace
# member (`uv sync` parses the dev group before `--no-dev` can exclude it,
# so the build died on a member that was never copied in) and nothing
# noticed, because nothing in CI had ever built it. Build it here, and
# exercise the three things the docs promise about it.
docker:
needs: changes
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7

- name: Build image
run: docker build -t fathom:ci .

- name: Smoke test the image
run: |
set -euo pipefail
# Non-default PORT: the CMD used to advertise ${PORT} and then
# hard-code 8080, so a run that honours it is the actual assertion.
cid="$(docker run -d -p 127.0.0.1:18080:9099 -e PORT=9099 fathom:ci)"
trap 'docker logs "$cid" || true; docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT

for _ in $(seq 1 60); do
curl -fsS http://127.0.0.1:18080/health >/dev/null 2>&1 && break
sleep 1
done
curl -fsS http://127.0.0.1:18080/health | grep -q '"status":"ok"'

# Never root.
test "$(docker exec "$cid" id -un)" = "fathom"

# /rules is the documented mount point and is created by root before
# the image drops privileges; the server user has to be able to use it.
docker exec "$cid" test -w /rules

# The declared HEALTHCHECK has to actually pass, not just exist.
for _ in $(seq 1 60); do
status="$(docker inspect --format '{{.State.Health.Status}}' "$cid")"
[ "$status" = "healthy" ] && break
[ "$status" = "unhealthy" ] && { echo "::error::HEALTHCHECK reports unhealthy"; exit 1; }
sleep 2
done
test "$status" = "healthy"

# A dependency bump that introduces a known-vulnerable package is invisible
# until the next Dependabot alert fires -- which is after it has landed on
# main. This reviews the manifest/lock diff of the PR itself, so the alert
# arrives while the change is still a proposal. Pull-request only: the action
# needs a base and a head to diff, which a push to main does not give it.
dependency-review:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Review dependency changes
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
with:
# `moderate` would fail on advisories we would not hold a release
# for; `high` is the line where a bump stops being routine.
fail-on-severity: high
# Advisory-only for licences: this repo is MIT, and a copyleft tool
# in a dev group is a judgement call, not a build break.
license-check: false

# README publishes four performance targets. Until now nothing measured
# them, so they were true only by luck -- and one ("YAML compilation
# < 50ms") was already false for the SSVC pack this repo ships. The
# benchmark now holds the numbers and this job enforces them.
#
# Gating is on medians, and `--slack 1.5` multiplies every published target
# before comparing: this runner measured ~1.7x slower than a developer
# machine on the compilation case, so enforcing the published numbers here
# verbatim would fail on the hardware rather than on the code. A red bench is
# meant to mean "something got materially slower", never "the runner was
# busy".
bench:
needs: changes
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: "3.13"

- name: Install dependencies
run: uv sync

- name: Benchmark
run: uv run python scripts/benchmark.py --slack 1.5
14 changes: 13 additions & 1 deletion .github/workflows/dependabot-auto-approve.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ name: dependabot-auto-approve
# review. github-actions[bot] approving dependabot[bot]'s PR is a distinct
# identity, so the approval counts (repo setting
# "Allow GitHub Actions to create and approve pull requests" is enabled).
#
# `github_actions` bumps are excluded: those PRs rewrite the workflow files
# themselves -- including the release path that holds the signing key and the
# PyPI identity -- so they are the one ecosystem where an unreviewed merge
# changes what CI itself is allowed to do. They stay open for a human.

on: pull_request

Expand All @@ -28,7 +33,14 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Approve patch/minor
if: steps.meta.outputs.update-type == 'version-update:semver-patch' || steps.meta.outputs.update-type == 'version-update:semver-minor'
# Branch prefix as well as the ecosystem output: `package-ecosystem`
# is empty on some grouped PRs, and an empty string would pass a
# `!=` test.
if: >
(steps.meta.outputs.update-type == 'version-update:semver-patch' ||
steps.meta.outputs.update-type == 'version-update:semver-minor') &&
steps.meta.outputs.package-ecosystem != 'github_actions' &&
!startsWith(github.event.pull_request.head.ref, 'dependabot/github_actions/')
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ name: dependabot-auto-merge
# never contain a major. The gate is therefore "not a major" — which auto-merges
# grouped (null) and explicit patch/minor PRs while leaving explicit majors open.
#
# `github_actions` bumps are excluded: those PRs rewrite the workflow files
# themselves — including the release path that holds the signing key and the
# PyPI identity — so they are the one ecosystem where an unreviewed merge
# changes what CI is allowed to do. They stay open for a human.
#
# Uses pull_request_target so the workflow token can approve/merge. It never checks
# out or runs PR code — it only calls the gh API — the GitHub-documented safe pattern.
on: pull_request_target
Expand All @@ -25,8 +30,14 @@ jobs:
id: meta
uses: dependabot/fetch-metadata@v3

- name: Approve and enable auto-merge (skip majors)
if: ${{ steps.meta.outputs.update-type != 'version-update:semver-major' }}
- name: Approve and enable auto-merge (skip majors and workflow bumps)
# Branch prefix as well as the ecosystem output: `package-ecosystem`
# is empty on some grouped PRs, and an empty string would pass a
# `!=` test.
if: >
steps.meta.outputs.update-type != 'version-update:semver-major' &&
steps.meta.outputs.package-ecosystem != 'github_actions' &&
!startsWith(github.event.pull_request.head.ref, 'dependabot/github_actions/')
run: |
gh pr review --approve "$PR_URL"
gh pr merge --auto --squash "$PR_URL"
Expand Down
75 changes: 75 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: docker-publish

# The README advertised `docker run kraken/fathom:latest`, an image that did
# not exist on any registry and that no workflow had ever built. This is the
# workflow that makes the claim true.
#
# GHCR rather than Docker Hub: it authenticates with the built-in
# GITHUB_TOKEN, so the release path gains no new long-lived credential and no
# second account to protect, and it has no anonymous pull-rate limit.
on:
push:
tags:
- "v*.*.*"

permissions:
contents: read

jobs:
# Every publish workflow shares this precondition; see release-gate.yml.
gate:
uses: ./.github/workflows/release-gate.yml
permissions:
contents: read
checks: read

publish:
needs: gate
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
# For the build-provenance attestation, which is what lets a consumer
# verify the image was built from this repo at this commit rather than
# pushed by hand.
id-token: write
attestations: write
steps:
# SHA-pinned: this job can write to the org's container registry.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# ghcr.io/<owner>/<repo>, lowercased by the action -- the org is
# "KrakenNet" but registry paths are case-sensitive and must be lower.
- name: Derive tags
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest

- name: Build and push
id: push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

- name: Attest provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
25 changes: 19 additions & 6 deletions .github/workflows/main-health.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,28 @@ name: main-health
# main, so a red main can't sit unnoticed and silently poison every open PR
# (it once stayed red for 6 days). Uses workflow_run so it fires after the
# real CI workflows finish on a push to main.
#
# The publish workflows are watched for the same reason, on tags: npm-publish
# had failed on all four of its runs (NPM_TOKEN never set, so the TS SDK was
# never published) and nothing anywhere reported it.
on:
workflow_run:
workflows: [CI, docs, go-ci, ts-ci]
workflows: [CI, docs, go-ci, ts-ci, pypi-publish, npm-publish, docker-publish]
types: [completed]

permissions:
issues: write

jobs:
alert:
# Only main pushes, only failures.
# Only failures, only pushes -- to main, or to a release tag. A tag push
# reports the tag as head_branch, so restricting to 'main' alone would
# have kept every failed release silent.
if: >
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.conclusion == 'failure'
github.event.workflow_run.conclusion == 'failure' &&
(github.event.workflow_run.head_branch == 'main' ||
startsWith(github.event.workflow_run.head_branch, 'v'))
runs-on: ubuntu-latest
steps:
- name: Open or update tracking issue
Expand All @@ -28,16 +35,22 @@ jobs:
WF: ${{ github.event.workflow_run.name }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
SHA: ${{ github.event.workflow_run.head_sha }}
REF: ${{ github.event.workflow_run.head_branch }}
run: |
set -euo pipefail
title="main is red: ${WF} failed"
if [ "$REF" = "main" ]; then
title="main is red: ${WF} failed"
note="Workflow **${WF}** failed on \`main\` at ${SHA} — ${RUN_URL}"$'\n\n'"A red main blocks and poisons every open PR. Fix forward or revert."
else
title="release ${REF} failed: ${WF}"
note="Workflow **${WF}** failed for tag \`${REF}\` at ${SHA} — ${RUN_URL}"$'\n\n'"The release is incomplete: this artifact was not published. Fix and re-run the workflow for the tag."
fi
# Idempotent label (create once, ignore if present).
gh label create main-health --color b60205 \
--description "main branch is failing CI" 2>/dev/null || true
existing="$(gh issue list --state open --label main-health \
--search "in:title ${WF} failed" --json number,title \
--jq ".[] | select(.title == \"${title}\") | .number" | head -1)"
note="Workflow **${WF}** failed on \`main\` at ${SHA} — ${RUN_URL}"$'\n\n'"A red main blocks and poisons every open PR. Fix forward or revert."
if [ -n "$existing" ]; then
gh issue comment "$existing" --body "Still failing: ${RUN_URL} (${SHA})"
else
Expand Down
Loading
Loading