1 Commits
Author SHA1 Message Date
sneak 8f88094307 Install pinned goimports in script/bootstrap (closes #184)
Check / check (pull_request) Skipped
script/fmt runs gofmt, goimports, and npx prettier, but bootstrap
installed only git, make, go, and golangci-lint, so `make fmt` failed
with `goimports: not found` on a fresh machine. bootstrap's contract is
to install all dependencies idempotently.

Add ensure_goimports: it skips when goimports is already on PATH,
otherwise `go install`s golang.org/x/tools/cmd/goimports at a pinned
exact version (v0.49.0; integrity via the Go module checksum database)
and places the binary in /usr/local/bin so it is on PATH regardless of
shell config, mirroring the golangci-lint release install. v0.49.0
requires Go 1.25, matching go.mod; v0.50.0 would force a Go 1.26
toolchain download.

Model: opus-4-8
2026-09-22 09:04:33 +00:00
7 changed files with 71 additions and 91 deletions
+1 -5
View File
@@ -8,12 +8,8 @@ RUN go mod download
COPY . . COPY . .
# golangci-lint is invoked directly here, not via `make lint`: script/lint
# now runs the linter by building Dockerfile.lint, and shelling out to
# `docker build` from inside this image build would be docker-in-docker.
# This image is golangci/golangci-lint, so the pinned linter is on PATH.
RUN make fmt-check RUN make fmt-check
RUN golangci-lint run --config .golangci.yml ./... RUN make lint
# Build stage — tests and compilation # Build stage — tests and compilation
# golang:1.25-alpine # golang:1.25-alpine
-23
View File
@@ -1,23 +0,0 @@
# Lint image — runs golangci-lint inside a container so every lint uses
# the pinned linter, never a host binary. Linting is a build step, so a
# successful build is a clean lint. Built by script/lint.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Caching is waived for linting: on an unchanged tree a cached build runs
# no linter and still exits 0 in under a second. script/lint passes a
# fresh GATE_RUN every time, and referencing it here forces this step to
# re-run, so the linter always executes.
#
# `golangci-lint config verify` is deliberately NOT run: it fetches its
# JSON schema over an unpinned live HTTPS call, which REPO_POLICIES.md
# forbids (all external references must be pinned by hash).
ARG GATE_RUN
RUN echo "lint run: ${GATE_RUN}"; golangci-lint run --config .golangci.yml ./...
-10
View File
@@ -20,16 +20,6 @@ main cannot regress.
# Completed Steps # Completed Steps
- 2026-09-22: Fixed the flaky `t.TempDir` cleanup race in
`internal/service/webhook` by tracking the async deployment goroutine
in a `sync.WaitGroup` and exposing `WaitForDeployments`; tests now
synchronize on completion instead of sleeping (#198).
- 2026-09-22: Linting now runs only in Docker. Added `Dockerfile.lint`
(pinned golangci-lint v2.12.2, cache-busted via a `GATE_RUN` build arg
so the linter always executes), reduced `script/lint` to building it,
dropped the golangci-lint install from `script/bootstrap`, and switched
the `Dockerfile` lint stage to invoke `golangci-lint` directly instead
of `make lint` to avoid docker-in-docker (#188).
- 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites - 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites
the vendored `static/js/alpine.min.js` bundle (#185). the vendored `static/js/alpine.min.js` bundle (#185).
- 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy - 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy
+2 -15
View File
@@ -6,7 +6,6 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"sync"
"go.uber.org/fx" "go.uber.org/fx"
@@ -32,10 +31,6 @@ type Service struct {
db *database.Database db *database.Database
deploy *deploy.Service deploy *deploy.Service
params *ServiceParams params *ServiceParams
// deployments tracks the deployment goroutines started by
// triggerDeployment so callers can wait for them to finish.
deployments sync.WaitGroup
} }
// New creates a new webhook Service. // New creates a new webhook Service.
@@ -113,14 +108,6 @@ func (svc *Service) HandleWebhook(
return nil return nil
} }
// WaitForDeployments blocks until every deployment goroutine started by
// HandleWebhook has finished, including all writes under the data
// directory. It exists so callers and tests can synchronize on async
// deployment completion instead of polling or sleeping.
func (svc *Service) WaitForDeployments() {
svc.deployments.Wait()
}
func (svc *Service) triggerDeployment( func (svc *Service) triggerDeployment(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
@@ -130,7 +117,7 @@ func (svc *Service) triggerDeployment(
eventID := event.ID eventID := event.ID
appName := app.Name appName := app.Name
svc.deployments.Go(func() { go func() {
// Use context.WithoutCancel to ensure deployment completes // Use context.WithoutCancel to ensure deployment completes
// even if the HTTP request context is cancelled. // even if the HTTP request context is cancelled.
deployCtx := context.WithoutCancel(ctx) deployCtx := context.WithoutCancel(ctx)
@@ -143,5 +130,5 @@ func (svc *Service) triggerDeployment(
// Mark event as processed // Mark event as processed
event.Processed = true event.Processed = true
_ = event.Save(deployCtx) _ = event.Save(deployCtx)
}) }()
} }
+7 -9
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -827,9 +828,8 @@ func TestExtractBranch(testingT *testing.T) {
) )
require.NoError(t, err) require.NoError(t, err)
// Wait for the async deployment goroutine to finish so its // Allow async deployment goroutine to complete before test cleanup
// writes under the temp dir complete before test cleanup. time.Sleep(100 * time.Millisecond)
svc.WaitForDeployments()
events, err := app.GetWebhookEvents(context.Background(), 10) events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err) require.NoError(t, err)
@@ -867,9 +867,8 @@ func TestHandleWebhookMatchingBranch(t *testing.T) {
) )
require.NoError(t, err) require.NoError(t, err)
// Wait for the async deployment goroutine to finish so its writes // Allow async deployment goroutine to complete before test cleanup
// under the temp dir complete before test cleanup. time.Sleep(100 * time.Millisecond)
svc.WaitForDeployments()
events, err := app.GetWebhookEvents(context.Background(), 10) events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err) require.NoError(t, err)
@@ -963,9 +962,8 @@ func assertHandleWebhookDeploys(
err := svc.HandleWebhook(context.Background(), app, source, pushEventType, payload) err := svc.HandleWebhook(context.Background(), app, source, pushEventType, payload)
require.NoError(t, err) require.NoError(t, err)
// Wait for the async deployment goroutine to finish so its writes // Allow async deployment goroutine to complete before test cleanup
// under the temp dir complete before test cleanup. time.Sleep(100 * time.Millisecond)
svc.WaitForDeployments()
events, err := app.GetWebhookEvents(context.Background(), 10) events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err) require.NoError(t, err)
+59 -15
View File
@@ -3,15 +3,20 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git, # or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). goimports is installed with `go install` at a pinned # make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
# it is installed from a hash-verified GitHub release archive (never
# curl | sh). goimports is installed with `go install` at a pinned
# version (integrity via the Go module checksum database) into # version (integrity via the Go module checksum database) into
# /usr/local/bin so it is on PATH. The linter is not installed here: it # /usr/local/bin so it is on PATH.
# runs only in Docker via script/lint, so docker is its sole prerequisite.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions. Never "latest"; exact versions only. # Pinned versions, 2026-08-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.12.2"
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
# golang.org/x/tools goimports, 2026-08-13. v0.49.0 requires Go 1.25 (matches # golang.org/x/tools goimports, 2026-08-13. v0.49.0 requires Go 1.25 (matches
# go.mod); v0.50.0 needs Go 1.26. Integrity via the Go module checksum database. # go.mod); v0.50.0 needs Go 1.26. Integrity via the Go module checksum database.
GOIMPORTS_VERSION="v0.49.0" GOIMPORTS_VERSION="v0.49.0"
@@ -56,10 +61,56 @@ missing() {
! command -v "$1" >/dev/null 2>&1 ! command -v "$1" >/dev/null 2>&1
} }
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# apt has no golangci-lint package: install a pinned release archive
# from GitHub, verified by hardcoded sha256 (never curl | sh).
install_golangci_lint_release() {
case "$(uname -m)" in
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
*)
echo "bootstrap: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
if missing curl; then pkg_install curl curl curl curl; fi
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/$name.tar.gz" \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
verify_sha256 "$tmp/$name.tar.gz" "$sha"
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
rm -rf "$tmp"
}
ensure_golangci_lint() {
if ! missing golangci-lint; then return 0; fi
detect_pkgmgr
case "$PKGMGR" in
apt) install_golangci_lint_release ;;
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
esac
}
# goimports is not packaged uniformly across nix/apt/brew/apk, so install it # goimports is not packaged uniformly across nix/apt/brew/apk, so install it
# with `go install` at a pinned version and place the binary in /usr/local/bin # with `go install` at a pinned version and place the binary in /usr/local/bin
# so it is on PATH regardless of shell config. Requires go, which main # so it is on PATH regardless of shell config, as the golangci-lint release
# installs first. # install does. Requires go, which main installs first.
ensure_goimports() { ensure_goimports() {
if ! missing goimports; then return 0; fi if ! missing goimports; then return 0; fi
detect_pkgmgr detect_pkgmgr
@@ -76,18 +127,11 @@ main() {
if missing git; then pkg_install git git git git; fi if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
# Go toolchain # Go toolchain and linter
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
ensure_golangci_lint
ensure_goimports ensure_goimports
# The linter runs only in Docker (script/lint). Warn, don't fail: the
# rest of the repo works without it.
if missing docker; then
echo "bootstrap: WARNING: docker not found; make lint and" >&2
echo "bootstrap: make check require it. Install docker to run" >&2
echo "bootstrap: the linter." >&2
fi
go mod download go mod download
echo "bootstrap complete" echo "bootstrap complete"
+2 -14
View File
@@ -1,24 +1,12 @@
#!/bin/sh #!/bin/sh
# script/lint: run golangci-lint. The linter is never installed on the # script/lint: run the linter.
# host; it runs only inside Docker, from the pinned image in
# Dockerfile.lint, so every run uses the same linter version everywhere.
# Linting is a build step there, so a successful build is a clean lint.
#
# GATE_RUN differs every run so the lint layer always executes; a cached
# build would otherwise exit 0 in under a second having linted nothing.
# --output=type=cacheonly discards the image and keeps only build cache,
# so no tagged image is left behind.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build \ golangci-lint run --config .golangci.yml ./...
--build-arg GATE_RUN="$(date +%s)-$$" \
--output=type=cacheonly \
-f Dockerfile.lint \
.
} }
main "$@" main "$@"