From e1dfe662ea0fb6713c7fe77b14ff65b19e4a8380 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Thu, 13 Aug 2026 09:02:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=B5=D0=B9=D1=82=20=D0=B2=D0=B8=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B8=20Dockerfile,=20=D0=B0=20pre-commit=20=E2=80=94=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D1=80=D0=BE=D0=BD=D1=83=D1=82=D1=8B=D0=B5=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - шаги shell (shellcheck) и dockerfile (hadolint) заведены; два правила hadolint подавлены поимённо с причиной — DL3007 до задачи pin-runtime-image-base и DL3018 по существу - lefthook гоняет на затронутых файлах gofmt, golangci-lint по их каталогам, shellcheck, hadolint и gitleaks — около секунды; полный набор в pre-commit не переносится намеренно - заведён мутационный тест скрипта сверки версий: 20 сценариев спеки toolchain плюс требование сообщения называть все четыре места, независимость исхода от установленного go и запрет звать go, docker и сеть --- Taskfile.yml | 34 +++ lefthook.yml | 32 ++- scripts/check_go_version_test.go | 356 +++++++++++++++++++++++++++++++ 3 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 scripts/check_go_version_test.go diff --git a/Taskfile.yml b/Taskfile.yml index 6e66296..bd2ac4a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -35,6 +35,8 @@ tasks: fi - go test ./... - golangci-lint run + - task: shell + - task: dockerfile - task: go-version - task: docs - task: tasks @@ -42,6 +44,38 @@ tasks: # Последним: единственный шаг, которому нужна сеть, и самый долгий. - task: vulns + shell: + desc: 'shellcheck на скрипты оболочки' + cmds: + # Скриптов два и оба свои: шаг сверки версий и `docker/entrypoint.sh`. + # Второй в образ копируется, но не исполняется — `ENTRYPOINT` в + # `Dockerfile` закомментирован, — и проверяется он именно поэтому: код, + # который никто не гоняет, портится незаметно. Ни один из двух не виден ни + # `go vet`, ни `golangci-lint`. + - | + if ! command -v shellcheck >/dev/null 2>&1; then + echo "shellcheck не найден в PATH" + echo "поставь: apt install shellcheck (или https://github.com/koalaman/shellcheck)" + exit 3 + fi + shellcheck scripts/check-go-version.sh docker/entrypoint.sh + + dockerfile: + desc: 'hadolint на Dockerfile' + cmds: + # DL3007 (`alpine:latest` у рантайм-слоя) подавлен: это открытая задача + # `pin-runtime-image-base`, и до её решения шаг краснел бы на известном. + # DL3018 (закрепить версии пакетов `apk`) подавлен по существу: alpine не + # держит старые версии в репозитории, и закрепление ломает сборку через + # недели — то есть лечение хуже болезни. + - | + if ! command -v hadolint >/dev/null 2>&1; then + echo "hadolint не найден в PATH" + echo "поставь: https://github.com/hadolint/hadolint/releases" + exit 3 + fi + hadolint --ignore DL3007 --ignore DL3018 Dockerfile + go-version: desc: 'Одна версия Go в go.mod, Dockerfile, CLAUDE.md и README.md' cmds: diff --git a/lefthook.yml b/lefthook.yml index 788ae2f..7695ab3 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,11 +1,41 @@ # Refer for explanation to following link: # https://lefthook.dev/configuration/ +# +# Предкоммитные проверки — дешёвая часть гейта на **затронутых файлах**. Полный +# набор здесь не гоняется намеренно: он идёт минуты, а pre-commit обязан быть +# быстрым. Что ловит pre-commit и что остаётся только гейту — CLAUDE.md, +# раздел «Гейт»; перечень правил и их дома — docs/autotests.md. templates: av-hooks-dir: "/home/av/projects/private/git-hooks" pre-commit: jobs: - + # Форматирование правится на месте и добавляется в коммит: спорить тут не о + # чем, а гейт на неотформатированном файле краснеет. + - name: "gofmt" + glob: "*.go" + run: "gofmt -w {staged_files}" + stage_fixed: true + + # Линтеры гоняются по пакетам затронутых файлов, а не по всему дереву: + # golangci-lint принимает файлы только из одного каталога, поэтому на вход + # идут каталоги. + - name: "golangci-lint" + glob: "*.go" + run: | + dirs=$(printf '%s\n' {staged_files} | xargs -r -n1 dirname | sort -u) + golangci-lint run $dirs + + - name: "shellcheck" + glob: "*.sh" + run: "shellcheck {staged_files}" + + # Подавления те же, что у шага гейта, и по тем же причинам — Taskfile.yml, + # задача `dockerfile`. + - name: "hadolint" + glob: "Dockerfile" + run: "hadolint --ignore DL3007 --ignore DL3018 {staged_files}" + - name: "gitleaks" run: "gitleaks git --staged" diff --git a/scripts/check_go_version_test.go b/scripts/check_go_version_test.go new file mode 100644 index 0000000..9793007 --- /dev/null +++ b/scripts/check_go_version_test.go @@ -0,0 +1,356 @@ +// Package scripts — проверки скриптов репозитория. Рабочего кода на Go в нём +// нет: пакет существует ради того, чтобы `go test ./...` гонял и shell. +// +// Норма шага сверки версий — openspec/specs/toolchain/spec.md. Каждый её +// сценарий проверяется здесь мутацией: дерево-образец собирается во временном +// каталоге, портится ровно одним способом, и от скрипта требуется объявленный +// исход. Прежде сценарии подтверждались разовыми ручными прогонами — после +// первой правки образца они перестали бы выполняться молча. +package scripts + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Дерево-образец: все четыре места называют одну версию. +// +// `CLAUDE.md` держит второе число **за** разделом стека намеренно: так выглядит +// правдивая строка о закрытом долге, и норма велит её не читать. +var fixture = map[string]string{ + "go.mod": "module example\n\ngo 1.26.0\n", + "Dockerfile": "FROM docker.io/library/golang:1.26-alpine AS builder\n" + + "RUN true\n\n" + + "FROM docker.io/library/alpine:3.22\n", + "CLAUDE.md": "# CLAUDE.md\n\n" + + "## Стек\n\nGo 1.26, встроенная PocketBase.\n\n" + + "## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n", + "README.md": "# transcriber\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n", +} + +// allPlaces — все четыре места и число каждого: этого требует норма от +// сообщения о расхождении. Числа два, потому что разошедшееся место называет +// своё. +var allPlaces = []string{"go.mod", "Dockerfile", "CLAUDE.md", "README.md", "1.26", "1.25"} + +const ( + exitOK = 0 + exitDrift = 1 + exitUsage = 2 + exitEnviron = 3 +) + +func TestСверкаВерсийПоСценариямНормы(t *testing.T) { + cases := []struct { + name string + // mutate портит дерево-образец; nil — дерево не портится. + mutate func(t *testing.T, root string) + // args — аргументы скрипта. + args []string + // dir — рабочий каталог прогона относительно корня дерева. + dir string + want int + // says — что обязано прозвучать в сообщении. + says string + // saysAll — что обязано прозвучать всё разом. Норма требует от сообщения + // о расхождении **все четыре места и число каждого**: в дефекте + // 2026-08-12 три места из четырёх говорили одно и то же, и неверными + // были именно они — по сообщению о паре человек чинит не то место. + saysAll []string + // saysNot — чего в сообщении быть не должно. + saysNot string + }{ + {name: "все четыре места названы одинаково", want: exitOK}, + { + name: "патч сборочного образа отличается законно", + mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26.5-alpine"), + want: exitOK, + }, + { + name: "база сборочного образа сменилась", + mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26-bookworm"), + want: exitOK, + }, + { + name: "сборочный образ собран в два слоя", + mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.26-alpine AS tools"), + want: exitOK, + }, + { + name: "слои сборочного образа разошлись между собой", + mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.25-alpine AS tools"), + want: exitDrift, + says: "Dockerfile", + }, + { + name: "разошёлся сборочный образ", + mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.25-alpine"), + want: exitDrift, + says: "разошлись", + saysAll: allPlaces, + }, + { + name: "разошлось требование модуля", + mutate: replace("go.mod", "go 1.26.0", "go 1.25.0"), + want: exitDrift, + says: "разошлись", + saysAll: allPlaces, + }, + { + name: "разошлась памятка", + mutate: replace("CLAUDE.md", "Go 1.26, встроенная", "Go 1.25, встроенная"), + want: exitDrift, + says: "разошлись", + saysAll: allPlaces, + }, + { + name: "разошёлся README", + mutate: replace("README.md", "Go 1.26 и ffmpeg", "Go 1.25 и ffmpeg"), + want: exitDrift, + says: "разошлись", + saysAll: allPlaces, + }, + { + name: "раздел стека называет версию дважды", + mutate: replace("CLAUDE.md", "встроенная PocketBase.", "встроенная PocketBase, всё та же Go 1.26."), + want: exitDrift, + says: "больше одного раза", + }, + { + name: "число за пределами раздела стека не читается", + mutate: replace("CLAUDE.md", "Go 1.24 — долг закрыт.", "Go 1.24 и Go 1.23 — долги закрыты."), + want: exitOK, + }, + { + name: "заголовок раздела встретился внутри блока кода", + mutate: replace("README.md", "## Технологии\n\nGo 1.26 и ffmpeg.\n", + "## Пример\n\n```md\n## Технологии\n\nGo 1.19 из примера.\n```\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n"), + want: exitOK, + }, + { + name: "раздел стека закрыт заголовком верхнего уровня", + mutate: replace("CLAUDE.md", "## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n", + "# Приложение\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n"), + want: exitOK, + }, + { + name: "раздела стека нет вовсе", + mutate: replace("CLAUDE.md", "## Стек", "## Инструменты"), + want: exitDrift, + says: "нет раздела", + }, + { + name: "версия не названа там, где должна быть", + mutate: replace("README.md", "Go 1.26 и ffmpeg.", "ffmpeg и всё остальное."), + want: exitDrift, + says: "не называет версию", + }, + { + name: "модуль объявляет версию пятым местом", + mutate: replace("go.mod", "go 1.26.0", "go 1.26.0\n\ntoolchain go1.27.0"), + want: exitDrift, + says: "toolchain", + }, + { + name: "места нет вовсе", + mutate: remove("README.md"), + want: exitEnviron, + says: "нет файла", + }, + { + name: "место существует, но не читается", + mutate: unreadable("README.md"), + want: exitEnviron, + says: "нечитаем", + saysNot: "не называет версию", + }, + { + name: "шаг запущен не из корня проекта", + dir: "scripts", + want: exitOK, + }, + { + name: "шагу переданы аргументы", + args: []string{"--base", "origin/master"}, + want: exitUsage, + says: "Использование", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + root := treeWithScript(t) + if c.mutate != nil { + c.mutate(t, root) + } + code, out := runScript(t, root, c.dir, c.args) + if code != c.want { + t.Errorf("код возврата %d, ожидался %d\nвывод:\n%s", code, c.want, out) + } + if c.says != "" && !strings.Contains(out, c.says) { + t.Errorf("в сообщении нет %q\nвывод:\n%s", c.says, out) + } + for _, want := range c.saysAll { + if !strings.Contains(out, want) { + t.Errorf("сообщение не называет %q\nвывод:\n%s", want, out) + } + } + if c.saysNot != "" && strings.Contains(out, c.saysNot) { + t.Errorf("в сообщении есть лишнее %q\nвывод:\n%s", c.saysNot, out) + } + }) + } +} + +// Норма требует, чтобы исход был функцией коммита, а не машины: скрипт не +// спрашивает установленный инструмент. Проверяется это прогоном без `go` в +// `PATH` — исход обязан не измениться. +func TestИсходНеЗависитОтУстановленногоGo(t *testing.T) { + root := treeWithScript(t) + + withGo, outWith := runScript(t, root, "", nil) + if withGo != exitOK { + t.Fatalf("дерево-образец обязано сходиться, а код %d:\n%s", withGo, outWith) + } + + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("go не найден в PATH — проверять нечего") + } + var kept []string + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + if dir != filepath.Dir(goBin) { + kept = append(kept, dir) + } + } + withoutGo, outWithout := runScript(t, root, "", nil, "PATH="+strings.Join(kept, string(os.PathListSeparator))) + if withoutGo != withGo { + t.Errorf("без go в PATH код %d, с ним %d\nвывод:\n%s", withoutGo, withGo, outWithout) + } +} + +// Скрипт не зовёт ни `go`, ни `docker`, ни сеть — это читается из его текста, и +// правило держит именно текст: прогон без сети в наборе проверок недоступен. +func TestСкриптНеЗоветНиGoНиDocker(t *testing.T) { + body, err := os.ReadFile("check-go-version.sh") + if err != nil { + t.Fatalf("читаю скрипт: %v", err) + } + // Границы слова обязательны: имя `read_dockerfile` и переменная `dockerfile` + // законны — читается файл, а не зовётся демон. + code := withoutComments(string(body)) + for _, forbidden := range []string{`go\s+version`, `go\s+env`, `GOTOOLCHAIN`, `\bdocker\b`, `\bcurl\b`, `\bwget\b`} { + if regexp.MustCompile(forbidden).FindString(code) != "" { + t.Errorf("скрипт зовёт %s вне комментария: исход перестаёт быть функцией коммита", forbidden) + } + } +} + +// --- Помощники -------------------------------------------------------------- + +// treeWithScript собирает дерево-образец и кладёт в него сам скрипт: корень он +// считает от своего расположения, поэтому проверяется копия внутри дерева. +func treeWithScript(t *testing.T) string { + t.Helper() + root := t.TempDir() + for name, body := range fixture { + write(t, filepath.Join(root, name), body, 0o644) + } + script, err := os.ReadFile("check-go-version.sh") + if err != nil { + t.Fatalf("читаю скрипт: %v", err) + } + if err := os.Mkdir(filepath.Join(root, "scripts"), 0o755); err != nil { + t.Fatalf("завожу каталог scripts: %v", err) + } + write(t, filepath.Join(root, "scripts", "check-go-version.sh"), string(script), 0o755) + return root +} + +// runScript гоняет скрипт и отдаёт код возврата с объединённым выводом. +// `env` — добавка к окружению прогона, `args` — аргументы скрипта. +func runScript(t *testing.T, root, dir string, args []string, env ...string) (int, string) { + t.Helper() + cmd := exec.Command("sh", append([]string{filepath.Join(root, "scripts", "check-go-version.sh")}, args...)...) + cmd.Dir = filepath.Join(root, dir) + if len(env) > 0 { + cmd.Env = append(os.Environ(), env...) + } + out, err := cmd.CombinedOutput() + code := 0 + if err != nil { + var exit *exec.ExitError + if !errors.As(err, &exit) { + t.Fatalf("прогон скрипта: %v", err) + } + code = exit.ExitCode() + } + return code, string(out) +} + +func replace(file, old, new string) func(*testing.T, string) { + return func(t *testing.T, root string) { + t.Helper() + path := filepath.Join(root, file) + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("читаю %s: %v", file, err) + } + if !strings.Contains(string(body), old) { + t.Fatalf("в образце %s нет %q: мутация потеряла предмет", file, old) + } + write(t, path, strings.Replace(string(body), old, new, 1), 0o644) + } +} + +func remove(file string) func(*testing.T, string) { + return func(t *testing.T, root string) { + t.Helper() + if err := os.Remove(filepath.Join(root, file)); err != nil { + t.Fatalf("убираю %s: %v", file, err) + } + } +} + +func unreadable(file string) func(*testing.T, string) { + return func(t *testing.T, root string) { + t.Helper() + path := filepath.Join(root, file) + if err := os.Chmod(path, 0o000); err != nil { + t.Fatalf("снимаю права с %s: %v", file, err) + } + // Права возвращаются, иначе уборка временного каталога отказала бы. + t.Cleanup(func() { + if err := os.Chmod(path, 0o644); err != nil { + t.Errorf("возвращаю права %s: %v", file, err) + } + }) + if body, err := os.ReadFile(path); err == nil { + t.Skipf("файл читается и без прав (%d байт) — прогон под root?", len(body)) + } + } +} + +func write(t *testing.T, path, body string, perm os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte(body), perm); err != nil { + t.Fatalf("пишу %s: %v", path, err) + } +} + +// withoutComments снимает строки-комментарии: слово в объяснении вызовом не +// является, а объяснения в этом скрипте длиннее самого кода. +func withoutComments(body string) string { + var kept []string + for line := range strings.SplitSeq(body, "\n") { + if !strings.HasPrefix(strings.TrimSpace(line), "#") { + kept = append(kept, line) + } + } + return strings.Join(kept, "\n") +}