Skip to content

preset: filename-handling, and names nobody can read shown as escapes #501

preset: filename-handling, and names nobody can read shown as escapes

preset: filename-handling, and names nobody can read shown as escapes #501

Workflow file for this run

name: CI
on:
# Only main, because every other branch reaches this through a pull request
# and would otherwise run the whole suite twice for one change. Measured on
# pull request 4, the first one this project had: every job appeared twice,
# once for the branch push and once for the pull request, and the race
# detector ran both times at about ten minutes each.
#
# The cost of the narrower trigger is that pushing a branch with no pull
# request open gives no signal. Since 2026-08-27 main only takes pull
# requests, so that state is a step on the way rather than a place work sits.
push:
branches: [main]
pull_request:
workflow_dispatch:
schedule:
# Weekly, for the fuzzing job below only. Every other job here runs on
# push, and fuzzing deliberately does not - see that job for why.
- cron: "17 4 * * 1"
permissions:
contents: read
env:
# The exact toolchain used for tests and releases. go.mod declares a
# minimum - this is the pin. Raising it can change generated bytes, so the
# byte stability guard has to be green before it moves.
GO_VERSION: "1.27.0"
jobs:
test:
name: test on ${{ matrix.os }}
# A hung job otherwise holds a runner until the GitHub default of six
# hours. Remeasured 2026-09-03, because the sentence here said "the matrix
# runs in about a minute" and had not been true for a long time: the test
# step alone takes 399 s on ubuntu, 476 s on windows and 491 s on macOS.
# The race detector took 148 s when it was measured and has its own job
# and its own numbers now, and fuzzing is given 5 minutes a target by its
# own loop.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
env:
# The engine, the command line and the tests build without CGO on every
# system. Only the desktop window needs a C compiler, and it is built
# separately.
#
# macOS is the exception and it is the toolkit's, not ours. Measured on
# 2026-08-20, the first run this matrix had since the toolkit arrived:
# with CGO off, vet cannot type check fyne's own internal/widget on
# darwin - subscribeScrollerStyle and three others are undefined, because
# the file defining them is behind a build tag that CGO off removes.
# Nothing of ours is involved and there is nothing of ours to fix, so that
# one runner pays for a C toolchain it already has.
CGO_ENABLED: ${{ matrix.os == 'macos-latest' && '1' || '0' }}
# The one guard that builds the window WITH cgo is read by the imports
# job below instead, and this asks it to skip here. Measured 2026-09-17:
# with CGO off, nothing else in this job compiles the OpenGL binding or
# GLFW, so that guard was the run's one cold cgo build - 822 s for the
# Windows test step against 434 s warm, four minutes under the timeout,
# and cold again after every change to go.sum. The variable is named in
# exactly one place, internal/guard/noimport_test.go, and a guard there
# holds this job to setting it and the imports job to not setting it.
TFG_IMPORT_TABLE_JOB: "1"
steps:
# Every checkout in these workflows turns the token off. The checkout
# keeps the job's token in .git/config unless told not to, and the jobs
# go on to run code from the pull request under test. Nothing after a
# checkout here pushes or fetches - the release and the pages talk to
# GitHub through gh and through actions that carry their own token - so
# the credential has no use once the tree is on disk. A guard asks each
# checkout (checkoutcredentials_test.go), so a new one cannot forget.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ env.GO_VERSION }}
check-latest: false
# Keyed on go.sum. The one dependency is the YAML parser behind the
# recipe - see docs/STACK.md.
cache: true
- name: formatting
run: |
test -z "$(gofmt -l .)" || { echo "gofmt found unformatted files:"; gofmt -l .; exit 1; }
shell: bash
- name: vet
run: go vet -tags "$(cat .github/build-tags)" ./...
- name: the dependency list has not grown by accident
# Every dependency is a licence question and a byte stability
# question. A new one arriving as somebody's transitive import has
# to be visible rather than discovered later.
#
# Two questions rather than one, since the graphics toolkit arrived
# on 2026-08-05. The graph below is 65 modules and most of them are
# Fyne's. Their licences were measured before it was added and every
# one in the linked set is permissive and one way compatible with
# GPL-3.0: 13 BSD-3, 9 MIT, 1 Apache-2.0, 1 BSD-2, 1 ISC.
#
# Changed once since, on 2026-08-05, when the window gained a
# folder picker. github.com/FyshOS/fancyfs was already named here
# because the toolkit requires it - importing the dialog package
# moved it from named to downloaded, checksummed and compiled in.
# Checked before it was accepted: BSD-3, 129 lines, written by the
# toolkit's own author, and reached from one line of the dialog
# package where it decorates folder icons. Tidying after it pruned
# github.com/kr/text from go.mod, which took github.com/creack/pty
# out of the graph, taking the count from 69 to 68.
#
# Changed again on 2026-08-27, when the toolkit went to 2.8.1. Three
# modules left the graph and none of them was ours to begin with -
# github.com/felixge/fgprof, github.com/google/pprof and
# github.com/pkg/profile all arrived through Fyne's own tooling and
# that release no longer asks for them, taking the count from 68 to
# 65. A fourth, github.com/fredbi/uri, stayed in the graph and left
# the linked set instead: 2.8.1 parses addresses itself. That is the
# difference these two questions exist to keep apart, so it is worth
# saying which one moved - the notices file tracks the linked set and
# dropped it, this list tracks the graph and keeps it.
#
# Changed on 2026-08-31 by a Dependabot bump, and it is the exact case
# the second paragraph of .github/dependabot.yml warns about. Taking
# github.com/nicksnyder/go-i18n/v2 from 2.5.1 to 2.6.1 pulled a module
# nobody asked for into the graph: go.yaml.in/yaml/v3, which is the
# same yaml library as gopkg.in/yaml.v3 under the module path that
# project moved to. Both are in the graph now, because other modules
# still ask for the old path.
#
# Its licence was read from the pinned version rather than off a web
# page: LICENSE is MIT and Apache-2.0 together - the files ported from
# libyaml keep the MIT of the original C, the rest is Apache-2.0 with a
# NOTICE from Canonical - and both are one way compatible with GPL-3.0.
# It is in the GRAPH and linked into NEITHER binary, which is the
# distinction these two questions exist to keep apart, so it belongs
# here and not in the notices. The count went 67 to 68.
#
# Changed on 2026-08-29 when AVIF arrived, and again on 2026-08-31
# when JPEG XL did: github.com/gen2brain/gav1d and then
# github.com/gen2brain/jxl, both picture encoders written in Go with
# empty go.mod files of their own, so neither brought anything else
# into the graph. Both licences were read out of the pinned module
# rather than off a web page - BSD-2-Clause with an AOM patent grant,
# and BSD-3-Clause with a Google patent grant - and both are one way
# compatible with GPL-3.0. The count went 65 to 66 to 67.
#
# Worth saying plainly, because it is the failure this comment exists
# to prevent: the sentence above went stale on 2026-08-29 and nobody
# noticed. It still said 65 while the list held 66, because AVIF
# updated the list and not the prose. The list is checked by the job
# below and the prose is checked by nobody.
#
# Unchanged on 2026-09-17, when github.com/go-gl/gl gained a replace
# directive pointing at a copy under third_party, and that is worth
# saying because it looks like the kind of change this list exists to
# notice. go list still reports the module under its own path, so
# the list below is the same - what moved is where the bytes come
# from, and two guards hold that instead: one keeps the copy equal to
# the published version plus exactly the one patch its PATCH.md
# describes, and one reads the built window binary's import table.
# go.sum no longer carries the module's sum, because a replaced
# module has none there. The sum lives in that PATCH.md now.
#
# The second question protects what ships to most people. The
# command line binary links exactly four external modules and the
# toolkit is not among them, so a build for a server carries no
# window, no OpenGL and - see internal/guard - no socket.
run: |
set -euo pipefail
# Built with printf rather than written across several lines. A
# continuation starting in column one reads as the end of the jobs
# block to anything scanning this file by indentation, and the
# guard on job timeouts does exactly that - it counted one job
# instead of seven and went green whatever it was given.
expected=$(printf '%s\n' \
fyne.io/fyne/v2 \
fyne.io/systray \
github.com/BurntSushi/toml \
github.com/FyshOS/fancyfs \
github.com/akavel/rsrc \
github.com/anthonynsimon/bild \
github.com/clipperhouse/uax29/v2 \
github.com/cpuguy83/go-md2man/v2 \
github.com/davecgh/go-spew \
github.com/fogleman/gg \
github.com/fredbi/uri \
github.com/fsnotify/fsnotify \
github.com/fyne-io/gl-js \
github.com/fyne-io/glfw-js \
github.com/fyne-io/image \
github.com/fyne-io/oksvg \
github.com/gen2brain/gav1d \
github.com/gen2brain/jxl \
github.com/go-gl/gl \
github.com/go-gl/glfw/v3.4/glfw \
github.com/go-ole/go-ole \
github.com/go-text/render \
github.com/go-text/typesetting \
github.com/go-text/typesetting-utils \
github.com/goccy/go-yaml \
github.com/godbus/dbus/v5 \
github.com/golang/freetype \
github.com/hack-pad/go-indexeddb \
github.com/hack-pad/safejs \
github.com/inconshreveable/mousetrap \
github.com/jackmordaunt/icns/v2 \
github.com/jeandeaual/go-locale \
github.com/josephspurrier/goversioninfo \
github.com/jsummers/gobmp \
github.com/kr/text \
github.com/lucor/goinfo \
github.com/mattn/go-runewidth \
github.com/mcuadros/go-version \
github.com/natefinch/atomic \
github.com/nfnt/resize \
github.com/nicksnyder/go-i18n/v2 \
github.com/niemeyer/pretty \
github.com/pmezard/go-difflib \
github.com/russross/blackfriday/v2 \
github.com/rymdport/portal \
github.com/spf13/cobra \
github.com/spf13/pflag \
github.com/srwiley/oksvg \
github.com/srwiley/rasterx \
github.com/stretchr/objx \
github.com/stretchr/testify \
github.com/urfave/cli/v2 \
github.com/xrash/smetrics \
github.com/yuin/goldmark \
go.yaml.in/yaml/v3 \
golang.org/x/crypto \
golang.org/x/image \
golang.org/x/mobile \
golang.org/x/mod \
golang.org/x/net \
golang.org/x/sync \
golang.org/x/sys \
golang.org/x/term \
golang.org/x/text \
golang.org/x/tools \
golang.org/x/tools/go/vcs \
gopkg.in/check.v1 \
gopkg.in/yaml.v3)
# LC_ALL=C on every sort here, and on the written list too. Measured on
# 2026-08-20, the first run this job ever had on macOS: both lists
# held the same 68 modules and the comparison still failed, because
# BSD sort and GNU sort disagree about where a hyphen goes. The check
# is a string comparison, so an ordering difference reads exactly like
# a dependency appearing out of nowhere.
expected=$(printf '%s\n' "$expected" | LC_ALL=C sort)
actual=$(go list -m -f '{{.Path}}' all | grep -v '^github.com/donislawdev/TestingFilesGenerator$' | LC_ALL=C sort)
if [ "$actual" != "$expected" ]; then
echo "the module list changed."
echo "expected: $expected"
echo "actual : $actual"
exit 1
fi
echo "dependencies unchanged, $(echo "$expected" | wc -l) modules"
linked=$(go list -deps -tags "$(cat .github/build-tags)" -f '{{if .Module}}{{.Module.Path}}{{end}}' ./cmd/tfg | LC_ALL=C sort -u | grep -v '^github.com/donislawdev/TestingFilesGenerator$' | grep .)
wanted=$(printf '%s\n' github.com/gen2brain/gav1d github.com/gen2brain/jxl github.com/goccy/go-yaml golang.org/x/text)
if [ "$linked" != "$wanted" ]; then
echo "the command line binary links a different set of modules."
echo "expected: $wanted"
echo "actual : $linked"
exit 1
fi
echo "the command line binary links only: $linked"
shell: bash
- name: test
# The timeout is stated for the reason the race job and the coverage
# gate both state theirs: Go allows ten minutes PER PACKAGE by default,
# this job allows twenty for all of it, and internal/guard is one
# package holding almost every test there is. A run that went past the
# first without approaching the second would die as a stack trace out
# of whichever test happened to be running, which is what the coverage
# gate did on 2026-09-03.
#
# Measured that day, on the run that caught it: 399 s on ubuntu, 476 s
# on windows, 491 s on macOS. macOS therefore had 109 s of room under a
# limit nobody had chosen, and the same fleet was measured swinging by
# more than 25 percent between two runs of one branch.
run: go test -tags "$(cat .github/build-tags)" ./... -count=1 -timeout 18m
- name: build the command line binary
run: go build -tags "$(cat .github/build-tags)" ./cmd/tfg
- name: build the window binary
# The toolkit arrived on 2026-08-05 and this step still passes, which
# is the point of how it was wired in. Reaching the toolkit's app
# package needs CGO and, on Linux, X11 development headers no runner
# carries by default - so that one file sits behind a cgo build tag and
# everything building a widget tree sits outside it.
#
# On Linux and Windows CGO_ENABLED is 0, so this proves the tree still
# compiles where there is no compiler and no graphics. The tests go
# further: they render a screen to an image and read it, also with CGO
# off.
#
# On macOS it proves something else, and better - the windowed binary
# actually LINKS. That runner has CGO on because the toolkit cannot be
# type checked on darwin without it, so the build here is the real one.
# Which closes half of the gap this comment used to name: linking was
# done natively and nowhere but on the owner's machine.
#
# The flags below are Windows only. -H windowsgui names a Windows
# subsystem, and handing it to the darwin linker is what turned this
# step red on 2026-08-20 the moment macOS gained a C toolchain.
#
# The linker flags come from the file rather than from this line, so
# that the release build and this one cannot drift apart. The file is
# the single place they live, the same way the coverage threshold is.
# Without them Windows attaches a console window to the program, which
# it decides from one number in the PE header - and that number is what
# the guard reads, out of a binary it builds rather than out of this
# command.
run: |
set -euo pipefail
if [ "$RUNNER_OS" = "Windows" ]; then
go build -tags "$(cat .github/build-tags)" -ldflags="$(cat .github/gui-ldflags)" ./cmd/tfg-gui
else
go build -tags "$(cat .github/build-tags)" ./cmd/tfg-gui
fi
shell: bash
imports:
name: import table of the window binary
# One guard, in a job of its own, because it is the one guard that builds
# the window WITH cgo: TestTheWindowBinaryDoesNotImportOpenGLAtLoadTime
# links tfg-gui.exe the way a release does and reads its import table,
# which is how the software renderer's whole premise is held (O218). In
# the test matrix that build was the run's only cold cgo build - measured
# 2026-09-17, 822 s for the Windows test step against 434 s warm, four
# minutes under the timeout, and cold again after every change to go.sum,
# which is every Dependabot pull request. The matrix asks the guard to
# skip there (TFG_IMPORT_TABLE_JOB), and this job runs it.
#
# The cache is this job's own. setup-go keys its cache on the hash of the
# files named here (source of v7.0.0, cache-restore.ts), and the matrix
# job on the same runner keys on go.sum alone. Under one key the matrix
# would save first, without a single cgo object, and this job would pay
# the cold build on every run. The second file is the sum of the patched
# OpenGL binding, which is what the cgo build actually compiles - so the
# key moves when either input to those objects does.
#
# Two things make the step fail rather than pass on nothing, and both were
# measured before they were written: go test with a -run pattern that
# matches no test exits 0 saying "no tests to run", and the guard skips
# with exit 0 on a runner without gcc. So the log is read for the PASS
# line of the one test this job exists for, under pipefail so a failing
# go test is not hidden behind tee. The name in the pattern is held equal
# to the function in internal/guard/noimport_test.go by a guard.
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ env.GO_VERSION }}
check-latest: false
cache: true
cache-dependency-path: |
go.sum
third_party/go-gl-gl/PATCH.md
- name: the window binary imports no opengl32.dll at load time
run: |
set -euo pipefail
go test -tags "$(cat .github/build-tags)" ./internal/guard/ -count=1 -run '^TestTheWindowBinaryDoesNotImportOpenGLAtLoadTime$' -v -timeout 12m 2>&1 | tee import-table.log
grep -q -- '--- PASS: TestTheWindowBinaryDoesNotImportOpenGLAtLoadTime' import-table.log
shell: bash
govulncheck:
name: known vulnerabilities
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ env.GO_VERSION }}
check-latest: false
cache: true
- name: the headers the toolkit needs
# The window reaches OpenGL through C, so anything that builds or
# analyses the whole tree on Linux compiles GLFW - and no runner carries
# its headers. Measured on 2026-08-20, the first run after the
# repository went public and so the first one this job has ever had
# against the toolkit: "wayland-client-core.h: No such file or
# directory", and the job never reached our code at all.
#
# Taken from the toolkit's own CI rather than from a remembered list.
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1-mesa-dev libwayland-dev libx11-dev libxkbcommon-dev xorg-dev
shell: bash
- name: govulncheck
# The official Go vulnerability scanner. BSD-3-Clause, read from the
# LICENSE file of the pinned version rather than recalled. Run rather
# than imported, so it never enters go.mod.
#
# It reports only what is actually reachable from our code, which is
# what makes it worth having: a scanner that lists every advisory
# touching the module graph produces noise, and noise gets switched off.
# Measured before switching it on, 2026-08-02: no vulnerabilities found.
# v1.8.0 since 2026-09-16, run on the tree before the pin moved: the same
# verdict as v1.7.0, exit 0 under Go 1.27 - the two pins before this one
# had stopped working with the compiler without anybody noticing.
run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 -tags "$(cat .github/build-tags)" ./...
staticcheck:
name: staticcheck
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ env.GO_VERSION }}
check-latest: false
cache: true
- name: the headers the toolkit needs
# The window reaches OpenGL through C, so anything that builds or
# analyses the whole tree on Linux compiles GLFW - and no runner carries
# its headers. Measured on 2026-08-20, the first run after the
# repository went public and so the first one this job has ever had
# against the toolkit: "wayland-client-core.h: No such file or
# directory", and the job never reached our code at all.
#
# Taken from the toolkit's own CI rather than from a remembered list.
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1-mesa-dev libwayland-dev libx11-dev libxkbcommon-dev xorg-dev
shell: bash
- name: staticcheck
# MIT, pinned. Run rather than imported, so it never enters go.mod and
# the dependency gate above does not see it - measured twice on
# 2026-08-02, go.mod and go.sum both untouched afterwards.
#
# The version is pinned because an unpinned analyser turns somebody
# else's release into a red build on a commit that changed nothing.
#
# Which checks run, and why ST1005 does not, is in staticcheck.conf.
# Measured before switching this on: two findings in the whole tree,
# both of them the word "Pillow" at the start of an error string, which
# is the name of the library that refused the image rather than a
# sentence. Zero findings with the config in place.
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 -tags "$(cat .github/build-tags)" ./...
lint:
name: linters
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ env.GO_VERSION }}
check-latest: false
cache: true
- name: the headers the toolkit needs
# The same reason as in the staticcheck job above: anything that
# analyses the whole tree on Linux compiles GLFW through C, and no
# runner carries its headers.
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1-mesa-dev libwayland-dev libx11-dev libxkbcommon-dev xorg-dev
shell: bash
- name: the configuration is valid
# Run separately and first, because a plain run ACCEPTS a configuration
# this refuses. A key that does not exist, or a value outside what the
# schema allows, is otherwise ignored in silence - and a linter reading
# a setting nobody applied is a gate that reports what it feels like.
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 config verify
- name: golangci-lint
# Run rather than imported, so it never enters go.mod and the
# dependency gate above does not see it - the same arrangement as
# staticcheck, measured there on 2026-08-02.
#
# The version is pinned because an unpinned analyser turns somebody
# else's release into a red build on a commit that changed nothing.
#
# Which linters run, and the measurement behind each, is in
# .golangci.yml. Measured before switching this on: zero findings from
# ineffassign and one from misspell, which turned out to be a comment
# written in the wrong language rather than a typo.
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run --build-tags "$(cat .github/build-tags)" ./...
semgrep:
name: semgrep
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: the scanner, pinned
# Pinned for the reason staticcheck and golangci-lint are pinned: an
# unpinned analyser turns somebody else's release into a red build on a
# commit that changed nothing.
#
# The version lives in .github/requirements-semgrep.txt rather than on
# this line, and the reason is in that file: a version written into a
# workflow step is watched by nothing, which is how staticcheck came to
# be two releases behind a compiler it could not read. A requirements
# file is an ecosystem Dependabot reads, so this one now arrives as a
# pull request when it moves.
#
# The licence note and the reason for pinning moved there with it.
run: pip install -r .github/requirements-semgrep.txt
- name: scan
# No account and no token. p/default is fetched anonymously from the
# registry, used here, and never carried in this repository, because the
# Semgrep Rules License allows the first and forbids the second.
#
# No --error and no --severity: the scan reports and the gate decides.
# --severity knows INFO, WARNING and ERROR only, while registry rules
# also carry HIGH and CRITICAL, so a gate built on that flag would
# ignore exactly the severities it was asked to block.
#
# Metrics are off. This repository does not send telemetry about its own
# source anywhere, which is the same promise the tool itself makes.
#
# One rule is excluded, and the reason is measured rather than assumed.
# missing-integrity asks for a subresource integrity attribute, which
# applies to a script or a stylesheet fetched from another origin. On
# every page of web/public it matches lines 8 to 11 - the canonical link
# and three hreflang alternates - while the one real stylesheet, at line
# 31, it does not match at all. 48 findings, none of them about a
# subresource. This site loads nothing from another origin by design.
#
# Excluded here rather than through a .semgrepignore file, and that is
# also from measurement: a .semgrepignore REPLACES semgrep own default
# patterns instead of adding to them. Measured 2026-08-27, adding one
# took the scan from 217 files to 366 by pulling in all 166 guard test
# files, and with them a new blocking finding. Security scanning of test
# files is a question this project already answered for gosec, and the
# answer was no.
run: |
semgrep scan --config p/default --metrics=off --oss-only --json --output semgrep.json --quiet --exclude-rule html.security.audit.missing-integrity.missing-integrity
- name: decide
# Separate from the scan on purpose. Measured on the development machine
# 2026-08-27: a semgrep whose core failed printed four errors, exited 0,
# and wrote a 23 byte file that was not JSON. Reading the exit code would
# have called that a clean tree. The gate reads the report.
run: python .github/scripts/semgrep_gate.py semgrep.json
sbom:
name: bill of materials
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ env.GO_VERSION }}
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: graphics and windowing headers
# The window links OpenGL through C, so without these the toolkit does
# not compile at all - and a scan of a binary that was never built is a
# scan of nothing. Taken from the toolkit's own CI, the same list the
# release workflow uses.
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgl1-mesa-dev \
libwayland-dev \
libx11-dev \
libxkbcommon-dev \
xorg-dev
- name: build what a release would publish
# Both binaries, because they do not carry the same code: the command
# line links two modules and the window twenty-eight plus the fonts and
# drawings they bring. Scanning one of them would ask half the question.
run: |
set -euo pipefail
mkdir -p dist
CGO_ENABLED=0 go build -tags "$(cat .github/build-tags)" -trimpath -o dist/tfg ./cmd/tfg
CGO_ENABLED=1 go build -tags "$(cat .github/build-tags)" -trimpath -o dist/tfg-gui ./cmd/tfg-gui
ls -l dist
- name: the document we would publish
# Generated from internal/legal, the reviewed list of what we ship, with
# versions read from the build. Not from a scan: measured 2026-08-27,
# syft reading the window binary names every module with an exact
# version and attaches a licence to one of thirty.
run: go run -tags "$(cat .github/build-tags)" ./internal/legal/cmd/sbom -seed "${GITHUB_SHA}" -o ours.spdx.json
- name: scan the binaries
# Pinned by commit like every other action here. The scan is evidence,
# not the document: its job is to ask whether the built binaries contain
# something the registry does not know about.
uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.0
with:
path: dist
format: syft-json
output-file: scan.json
upload-artifact: false
upload-release-assets: false
- name: the registry has to account for everything the scan found
# Separate from the scan for the reason the semgrep gate is separate: a
# scanner that falls over can still exit zero, and a gate reading the
# exit code would call that a clean tree. This one reads the report, and
# refuses when the report is unusable rather than treating it as empty.
run: python .github/scripts/sbom_gate.py scan.json ours.spdx.json
touched:
name: what this push touched
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
concurrency: ${{ steps.look.outputs.concurrency }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The comparison needs the earlier commit, and the default checkout
# fetches one.
fetch-depth: 0
- name: did anything concurrent change
id: look
# The three files concurrency is allowed to live in are not a guess.
# They are the map in internal/guard/concurrency_test.go.
#
# This used to say the map guard was enough to keep the two in step: "a
# fourth file growing a goroutine turns that guard red before it gets
# here, so this list cannot quietly fall behind the tree". That was not
# true, and adding the fourth file on 2026-09-05 is what showed it. The
# map guard is red only while the new file is NOT in the map. The moment
# somebody adds it there - which is what the guard's own message tells
# them to do - it goes green, and nothing at all asks whether this line
# was updated too. The list could then fall behind exactly when it
# mattered: concurrency living in a file the detector is not run for.
#
# TestTheRaceDetectorIsRunForEveryFileThatDeclaresConcurrency now reads
# this line and compares it against the map, so the two cannot drift.
#
# go.mod is watched as well. A toolchain or dependency change can alter
# what the detector sees even when none of our own lines moved.
#
# So is this file, since 2026-09-24 and the owner's decision. A change
# to the race job itself used to leave it skipped, so the first run of
# a new definition was the weekly sweep, days after it merged. And so
# is .github/build-tags, which every command of the job reads and which
# decides the files it compiles - the same reason as go.mod, named by
# an outside review of the pull request that split the job.
#
# Anything unclear counts as touched. A first push, a branch with no
# comparable parent, a pull request event with no before - all answer
# true, because the cost of running the detector when it was not needed
# is ten minutes and the cost of skipping it when it was is a data race
# in somebody else's file.
run: |
set -euo pipefail
watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/gui/run_cgo.go internal/gui/window/tidy.go internal/audit/parallel.go internal/engine/parallel.go go.mod .github/workflows/ci.yml .github/build-tags'
# On a pull request there is no "before" - the field belongs to a push
# - so this asked for something empty and every pull request answered
# "touched". That quietly undid the decision of 2026-08-20, because
# from the day this project started taking pull requests the detector
# was back to running on everything. The base of the pull request is
# the commit to compare against, and github.sha is the merge commit,
# so the difference between them is exactly what the pull request
# changes.
before="${{ github.event.pull_request.base.sha || github.event.before }}"
if [ -z "$before" ] \
|| [ "$before" = "0000000000000000000000000000000000000000" ] \
|| ! git cat-file -e "${before}^{commit}" 2>/dev/null
then
echo "no earlier commit to compare against, so this counts as touched"
echo "concurrency=true" >> "$GITHUB_OUTPUT"
exit 0
fi
changed="$(git diff --name-only "$before" "${{ github.sha }}")"
echo "changed in this push:"
echo "$changed"
hit=false
for f in $watched; do
if printf '%s\n' "$changed" | grep -qx "$f"; then
echo " -> $f is one of the files the race detector is for"
hit=true
fi
done
echo "concurrency=$hit" >> "$GITHUB_OUTPUT"
shell: bash
race:
name: race detector (part ${{ strategy.job-index }} of ${{ strategy.job-total }})
needs: touched
# Not on every push, decided on 2026-08-20 after the owner asked what it was
# costing. Measured that day: 10m31s on the runner, against about a minute
# for the whole matrix - so it was the longest thing in the run by a factor
# of ten, on every push, including the ones that only touched a document.
#
# What makes that safe to change rather than a corner cut: concurrency in
# this tree is confined to the files declared in
# internal/guard/concurrency_test.go, by a guard that fails if another one
# grows a goroutine. A push that does not touch them cannot introduce a race
# for this to find, so running it there bought nothing.
#
# Three ways in now. A push that touches one of those files or this
# workflow, the weekly sweep that fuzzing already uses, and by hand. The
# weekly run is what catches a race that arrives through a dependency
# rather than through us.
if: >-
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
needs.touched.outputs.concurrency == 'true'
runs-on: ubuntu-latest
# Four parts since 2026-09-24, the owner's decision. The whole package under
# the detector took 32 to 42 minutes, and six of the twelve runs on
# 2026-09-23 and 2026-09-24 were killed at the ceiling with no data race in
# their logs. Both ceilings had been raised once already, on 2026-08-31 -
# the job's from 30 to 45 minutes and Go's from 25 to 40 - for the same
# reason, and a problem that keeps coming back is the wrong shape rather
# than a missing notch. The guards run one after another - none calls
# t.Parallel - so four processes share the time between them, and every
# test still runs under the detector once, in one of them.
#
# Every part reports. One red part does not cancel the others, whose tests
# would then go unrun.
#
# The ceiling is the sum of the limits inside it, with room left over, so
# that each of them fires first and names what was slow: about four minutes
# to set up and compile (the compile alone took 3m03s on the first run),
# twenty for the guards, five for the other packages. Measured on the first
# run, split by a sorted list: 677, 635, 164 and 154 s of guards in the
# four parts, 14m55s for the longest part. The two slowest guards take 329
# and 222 s, and wherever they land together, that part is the slow one.
strategy:
fail-fast: false
matrix:
part: [0, 1, 2, 3]
timeout-minutes: 35
env:
# The one thing in this project that needs a C toolchain. Linux runners
# ship one, so this job carries the cost and the matrix above stays on
# CGO_ENABLED=0 and stays fast.
CGO_ENABLED: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ env.GO_VERSION }}
check-latest: false
cache: true
- name: the headers the toolkit needs
# The window reaches OpenGL through C, so anything that builds or
# analyses the whole tree on Linux compiles GLFW - and no runner carries
# its headers. Measured on 2026-08-20, the first run after the
# repository went public and so the first one this job has ever had
# against the toolkit: "wayland-client-core.h: No such file or
# directory", and the job never reached our code at all.
#
# Taken from the toolkit's own CI rather than from a remembered list.
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1-mesa-dev libwayland-dev libx11-dev libxkbcommon-dev xorg-dev
shell: bash
- name: test this part under the race detector
# A data race is the one defect class here that nothing else notices. It
# does not change a size, and on the run that happens to interleave the
# safe way it does not change a byte either - so determinism and the
# pinned values both stay green while the file is wrong once a month on
# somebody else's machine. The guard that keeps concurrency confined to
# the declared files lives in internal/guard, so this and that one
# answer different halves of the same worry.
#
# Which tests are this part's is worked out here, from the test binary,
# rather than written down. A list kept by hand would miss the next
# guard somebody adds, and miss it green. The list is asked with -race,
# because a file in the package builds only without it, and it keeps
# Fuzz and Example because go test runs those as tests too.
#
# A test's part is the checksum of its name (cksum) modulo the number
# of parts, so adding or removing a test moves no other test. It was
# every fourth name of a sorted list at first, and one guard added on
# this job's own pull request moved every name after it - the part
# holding the slowest guards changed from one commit to the next. The
# parts are even only on average, and which one is slow now changes only
# with what is in it.
#
# Three things fail the part rather than let it pass on nothing -
# go test with a -run pattern that matches no test exits 0. An index the
# matrix does not agree with (the documentation does not say that
# job-index counts from nought, so this asks), a part given no test,
# and a part that never started a test it was given. That last one
# compares names rather than counts, so a stray line that looks like
# a start cannot stand in for a test that did not run.
#
# The timeout is stated rather than left to Go's ten minutes a package,
# and stays below the job's ceiling, so a slow part fails as a test with
# a name rather than as a killed job with no output. Go's ten minutes
# killed this job on 2026-08-25, before a timeout was stated here.
# Twenty minutes is 1.8 times the slowest part measured.
env:
PART: ${{ strategy.job-index }}
PARTS: ${{ strategy.job-total }}
LISTED: ${{ matrix.part }}
run: |
set -euo pipefail
if [ "$PART" != "$LISTED" ] || [ "$PART" -ge "$PARTS" ]; then
echo "job $PART of $PARTS is part $LISTED in the matrix, and the split needs the two equal and below the total."
echo "List strategy.matrix.part as 0, 1, 2 and on, in order, and check the context names in this step's env."
exit 1
fi
go test -tags "$(cat .github/build-tags)" ./internal/guard/ -race -list '.*' > listed.txt || { cat listed.txt; exit 1; }
grep -E '^(Test|Fuzz|Example)' listed.txt > names.txt || { echo "the test binary listed no Test, Fuzz or Example function. What it printed:"; cat listed.txt; exit 1; }
while IFS= read -r name; do
read -r sum _ < <(printf '%s' "$name" | cksum)
if [ $((sum % PARTS)) -eq "$PART" ]; then
echo "$name"
fi
done < names.txt > mine.txt
planned=$(wc -l < mine.txt)
echo "part $PART of $PARTS runs $planned of $(wc -l < names.txt) tests"
if [ "$planned" -eq 0 ]; then
echo "part $PART of $PARTS was given no test - the checksum of no listed name lands here."
echo "With this few tests, use fewer parts in strategy.matrix.part."
exit 1
fi
go test -tags "$(cat .github/build-tags)" ./internal/guard/ -count=1 -race -timeout 20m -v -run "^($(paste -sd'|' mine.txt))\$" 2>&1 | tee part.log
missing=$(comm -23 <(sort mine.txt) <(sed -n 's/^=== RUN \([^/]*\)$/\1/p' part.log | sort -u))
if [ -n "$missing" ]; then
echo "part $PART was given $planned tests and never ran these:"
echo "$missing"
echo "Each of these is a name the -run pattern did not reach, or a test the binary skipped before it started."
exit 1
fi
shell: bash
- name: the other packages under the race detector
# Every package outside internal/guard, in part 0 alone. None holds a
# test today - the first run took one second for all of them - and one
# written there has to reach the detector as well.
#
# A step of its own with a ceiling of its own, rather than a second
# command after the guards. Go's -timeout holds one test binary, so two
# commands in one step add up under nothing but the job's ceiling, and
# the job would be killed past it with no name in the log - an outside
# review of the pull request that split the job. Runs after red guards
# as well, so its answer is not lost with theirs.
if: ${{ !cancelled() && matrix.part == 0 }}
timeout-minutes: 5
run: |
set -euo pipefail
go list -tags "$(cat .github/build-tags)" ./... | grep -v '/internal/guard$' > others.txt
mapfile -t others < others.txt
go test -tags "$(cat .github/build-tags)" -count=1 -race -timeout 4m "${others[@]}"
shell: bash
coverage:
name: coverage gate
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CGO_ENABLED: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ env.GO_VERSION }}
check-latest: false
# Keyed on go.sum. The one dependency is the YAML parser behind the
# recipe - see docs/STACK.md.
cache: true
- name: measure
# -coverpkg is not optional here. The guard tests live in their own
# package, and by default Go credits coverage only to the package
# under test - which reports 0.0% and makes the gate meaningless.
# Measured, not assumed.
#
# The timeout is stated rather than left to Go, since 2026-09-03, and
# for the same reason the race job above states its own. Go allows ten
# minutes PER PACKAGE by default while this job allows twenty for all of
# it, so internal/guard died on a limit nobody had chosen - a stack
# trace out of whichever test was running when the alarm went off,
# instead of a failure naming something.
#
# Measured on the runner rather than guessed. This step took 375 s and
# 457 s on two consecutive main runs of 2026-09-02 and 2026-09-03, which
# is 22 percent of variance on code that barely moved between them, and
# the default cuts in at 600 s. A branch adding eight seconds of
# coverage instrumented work then timed out. Eight seconds is not what
# went wrong: 457 against 600 was never a margin, and a limit that
# decides on how busy the runner is tells you nothing about the code.
#
# Atomic counters are the cost. Every statement in every internal
# package pays one, and this package renders twenty five screens and
# generates files for twenty four formats. Eighteen minutes sits under
# the job's own ceiling on purpose, so a genuinely stuck run still fails
# as a test with output rather than as a killed job without any.
run: >
go test -tags "$(cat .github/build-tags)" ./... -count=1 -covermode=atomic
-coverpkg=./internal/...,./cmd/...
-coverprofile=coverage.out -timeout 18m
- name: gate
# The threshold lives in exactly one place, .github/coverage-threshold.
# It rises with coverage and is never lowered to turn a red run green.
# Lowering it is a decision for the owner, not a way to get unblocked.
run: |
set -euo pipefail
threshold=$(tr -d '[:space:]' < .github/coverage-threshold)
actual=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%","",$3); print $3}')
echo "coverage ${actual}% - threshold ${threshold}%"
awk -v a="$actual" -v t="$threshold" 'BEGIN { exit (a+0 >= t+0) ? 0 : 1 }' \
|| { echo "coverage ${actual}% is below the threshold ${threshold}%"; exit 1; }
shell: bash
fidelity:
name: reference tools actually installed
# The oracle guards skip when the tool they need is missing, loudly, and a
# skip is not a check. The matrix runners have python and node and nothing
# else, so on an ordinary push most of those guards report a skip and the
# run is green having verified almost nothing about the files themselves.
#
# This job installs Inkscape, 7z, ffmpeg and poppler so the same guards run
# for real. It is separate from the matrix because installing a graphics
# stack takes minutes and a push should not wait for it.
#
# What it does NOT do, said plainly: it does not walk many sizes. The
# guards it runs check the realistic size and the smallest ones, which is
# where the one defect this ever found was hiding - an SVG that rendered to
# a blank canvas at exactly its minimum, 2026-08-03. The wider sweep across
# sizes, seeds and label settings lives in tools/probes/fidelity-sweep.py
# and is run by hand, because tools/ is outside the repository. See O51.
runs-on: ubuntu-latest
timeout-minutes: 30
env:
CGO_ENABLED: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ env.GO_VERSION }}
check-latest: false
cache: true
- name: the reference tools
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
p7zip-full ffmpeg poppler-utils inkscape python3-pil python3-yaml
# The distribution's Pillow cannot open an AVIF - measured on this
# runner, PIL.UnidentifiedImageError on a file the structural checker
# had just called sound. Installed for the SAME interpreter the oracle
# reaches for, then asked whether it can actually read one. No version
# is named: the question is what this Pillow can do, not when the
# support landed.
python -m pip install --break-system-packages --upgrade pillow
python - <<'PY'
from PIL import Image, features
print("pillow", Image.__version__, "avif:", features.check("avif"))
if not features.check("avif"):
raise SystemExit("this Pillow cannot read AVIF, so the oracle would fail on a good file")
PY
shell: bash
- name: every format past its reference tool
# -v so that a skip is visible in the log. A tool that failed to install
# would otherwise turn this job back into the green nothing it exists
# to replace.
run: go test -tags "$(cat .github/build-tags)" ./internal/guard/ -count=1 -run 'ReferenceTool' -v
shell: bash
fuzz:
name: fuzzing with a time budget
# Weekly and by hand, never on a push. Fuzzing searches without end, so a
# budget picked to fit a commit gate would be a gate that says "fuzzing
# passed" after twenty seconds of looking - which is worth less than not
# claiming it. See docs/OBSERVATIONS.md, O34.
#
# What the push jobs do run is the seed corpus in testdata/fuzz, because
# go test executes fuzz targets as ordinary tests. That is regression
# cover for what has already been found, and it is not the same thing as
# searching.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
# Four targets at five minutes each, plus the build and the baseline pass.
timeout-minutes: 45
env:
CGO_ENABLED: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ env.GO_VERSION }}
check-latest: false
cache: true
- name: search
# One target at a time, because -fuzz takes exactly one. Five minutes
# each - long enough to be a search rather than a gesture, short enough
# that a weekly run stays cheap.
#
# A finding here is not fixed by this job. It has to be pulled into
# testdata/fuzz and committed, or it disappears with the runner and the
# next search starts from nothing.
run: |
set -euo pipefail
for target in FuzzParseSize FuzzParseRecipe FuzzNameTemplate FuzzCanonicalRecipe; do
echo "=== $target ==="
go test -tags "$(cat .github/build-tags)" ./internal/guard/ -run "^$" -fuzz "^${target}$" -fuzztime 5m
done
shell: bash
- name: keep what was found
# The corpus a failing run leaves behind is the whole value of the run,
# and it lives in the runner's cache directory rather than the tree.
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-findings
path: |
internal/guard/testdata/fuzz/**
~/.cache/go-build/fuzz/**
if-no-files-found: warn