dockerimg: switch to a debian base image

Developing on a musl-based alpine image seems to suck.
A lot of python stuff doesn't work.
Even when told it's alpine, LLMs still want to apt-get install things.

Along with switching to debian, simplify everything.
We *always* use debian bookwork now. If there are python
things, the LLM needs to install them on top of the model.
This will make fallback mode easier: if a build fails, then we
drop ExtraCmds and try again. (Future work.)

While here: avoid using docker buildx in tests because
it seems to vary a lot version-to-version.
diff --git a/dockerimg/createdockerfile.go b/dockerimg/createdockerfile.go
index 7971d38..68f074a 100644
--- a/dockerimg/createdockerfile.go
+++ b/dockerimg/createdockerfile.go
@@ -7,6 +7,7 @@
 	"encoding/hex"
 	"encoding/json"
 	"fmt"
+	"io"
 	"io/fs"
 	"maps"
 	"net/http"
@@ -22,41 +23,39 @@
 	for _, path := range slices.Sorted(maps.Keys(initFiles)) {
 		fmt.Fprintf(h, "%s\n%s\n\n", path, initFiles[path])
 	}
-	fmt.Fprintf(h, "docker template 1\n%s\n", dockerfileCustomTmpl)
-	fmt.Fprintf(h, "docker template 2\n%s\n", dockerfileDefaultTmpl)
+	fmt.Fprintf(h, "docker template\n%s\n", dockerfileDefaultTmpl)
 	return hex.EncodeToString(h.Sum(nil))
 }
 
 // DefaultImage is intended to ONLY be used by the pushdockerimg.go script.
-func DefaultImage() (name, dockerfile, hash string) {
-	buf := new(bytes.Buffer)
-	err := template.Must(template.New("dockerfile").Parse(dockerfileBaseTmpl)).Execute(buf, map[string]string{
-		"From": defaultBaseImg,
-	})
-	if err != nil {
-		panic(err)
-	}
-	return dockerfileDefaultImg, buf.String(), hashInitFiles(nil)
+func DefaultImage() (name, dockerfile, tag string) {
+	return dockerImgName, dockerfileBase, dockerfileBaseHash()
 }
 
-const dockerfileDefaultImg = "ghcr.io/boldsoftware/sketch:v1"
+const dockerImgRepo = "boldsoftware/sketch"
+const dockerImgName = "ghcr.io/" + dockerImgRepo
 
-const defaultBaseImg = "golang:1.24.2-alpine3.21"
+func dockerfileBaseHash() string {
+	h := sha256.New()
+	io.WriteString(h, dockerfileBase)
+	return hex.EncodeToString(h.Sum(nil))[:32]
+}
 
-// TODO: add semgrep, prettier -- they require node/npm/etc which is more complicated than apk
-// If/when we do this, add them into the list of available tools in bash.go.
-const dockerfileBaseTmpl = `FROM {{.From}}
+const dockerfileBase = `FROM golang:1.24-bookworm
 
-RUN apk add bash git make jq sqlite gcc musl-dev linux-headers npm nodejs go github-cli ripgrep fzf python3 curl vim grep
+RUN set -eux; \
+	apt-get update; \
+	apt-get install -y --no-install-recommends \
+		git jq sqlite3 npm nodejs gh ripgrep fzf python3 curl vim
 
-ENV GOTOOLCHAIN=auto
-ENV GOPATH=/go
 ENV PATH="$GOPATH/bin:$PATH"
 
 RUN go install golang.org/x/tools/cmd/goimports@latest
 RUN go install golang.org/x/tools/gopls@latest
 RUN go install mvdan.cc/gofumpt@latest
 
+ENV GOTOOLCHAIN=auto
+
 RUN mkdir -p /root/.cache/sketch/webui
 `
 
@@ -67,7 +66,6 @@
 RUN git config --global user.email "$GIT_USER_EMAIL" && \
     git config --global user.name "$GIT_USER_NAME"
 
-LABEL sketch_context="{{.InitFilesHash}}"
 COPY . /app
 
 WORKDIR /app{{.SubDir}}
@@ -78,13 +76,64 @@
 CMD ["/bin/sketch"]
 `
 
-// dockerfileCustomTmpl is the dockerfile template used when the LLM
-// chooses a custom base image.
-const dockerfileCustomTmpl = dockerfileBaseTmpl + dockerfileFragment
+var dockerfileDefaultTmpl = "FROM " + dockerImgName + ":" + dockerfileBaseHash() + "\n" + dockerfileFragment
 
-// dockerfileDefaultTmpl is the dockerfile used when the LLM went with
-// the defaultBaseImg. In this case, we use a pre-canned image.
-const dockerfileDefaultTmpl = "FROM " + dockerfileDefaultImg + "\n" + dockerfileFragment
+func readPublishedTags() ([]string, error) {
+	req, err := http.NewRequest("GET", "https://ghcr.io/token?service=ghcr.io&scope=repository:"+dockerImgRepo+":pull", nil)
+	if err != nil {
+		return nil, fmt.Errorf("token: %w", err)
+	}
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("token: %w", err)
+	}
+	body, err := io.ReadAll(res.Body)
+	res.Body.Close()
+	if err != nil || res.StatusCode != 200 {
+		return nil, fmt.Errorf("token: %d: %s: %w", res.StatusCode, body, err)
+	}
+	var tokenBody struct {
+		Token string `json:"token"`
+	}
+	if err := json.Unmarshal(body, &tokenBody); err != nil {
+		return nil, fmt.Errorf("token: %w: %s", err, body)
+	}
+
+	req, err = http.NewRequest("GET", "https://ghcr.io/v2/"+dockerImgRepo+"/tags/list", nil)
+	if err != nil {
+		return nil, fmt.Errorf("tags: %w", err)
+	}
+	req.Header.Set("Authorization", "Bearer "+tokenBody.Token)
+	res, err = http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("tags: %w", err)
+	}
+	body, err = io.ReadAll(res.Body)
+	res.Body.Close()
+	if err != nil || res.StatusCode != 200 {
+		return nil, fmt.Errorf("tags: %d: %s: %w", res.StatusCode, body, err)
+	}
+	var tags struct {
+		Tags []string `json:"tags"`
+	}
+	if err := json.Unmarshal(body, &tags); err != nil {
+		return nil, fmt.Errorf("tags: %w: %s", err, body)
+	}
+	return tags.Tags, nil
+}
+
+func checkTagExists(tag string) error {
+	tags, err := readPublishedTags()
+	if err != nil {
+		return fmt.Errorf("check tag exists: %w", err)
+	}
+	for _, t := range tags {
+		if t == tag {
+			return nil // found it
+		}
+	}
+	return fmt.Errorf("check tag exists: %q not found in %v", tag, tags)
+}
 
 // createDockerfile creates a Dockerfile for a git repo.
 // It expects the relevant initFiles to have been provided.
@@ -97,7 +146,7 @@
 		subPathWorkingDir = "/" + subPathWorkingDir
 	}
 	toolCalled := false
-	var dockerfileFROM, dockerfileExtraCmds string
+	var dockerfileExtraCmds string
 	runDockerfile := func(ctx context.Context, input json.RawMessage) (string, error) {
 		// TODO: unmarshal straight into a struct
 		var m map[string]any
@@ -105,10 +154,6 @@
 			return "", fmt.Errorf(`input=%[1]v (%[1]T), wanted a map[string]any, got: %w`, input, err)
 		}
 		var ok bool
-		dockerfileFROM, ok = m["from"].(string)
-		if !ok {
-			return "", fmt.Errorf(`input["from"]=%[1]v (%[1]T), wanted a string`, m["path"])
-		}
 		dockerfileExtraCmds, ok = m["extra_cmds"].(string)
 		if !ok {
 			return "", fmt.Errorf(`input["extra_cmds"]=%[1]v (%[1]T), wanted a string`, m["path"])
@@ -129,12 +174,8 @@
 		Run:         runDockerfile,
 		InputSchema: ant.MustSchema(`{
   "type": "object",
-  "required": ["from", "extra_cmds"],
+  "required": ["extra_cmds"],
   "properties": {
-    "from": {
-	  "type": "string",
-	  "description": "The alpine base image provided to the dockerfile FROM command"
-	},
     "extra_cmds": {
       "type": "string",
       "description": "Extra commands to add to the dockerfile."
@@ -164,19 +205,14 @@
 The parameters to dockerfile fill out the From and ExtraCmds
 template variables in the following Go template:
 
-` + "```\n" + dockerfileCustomTmpl + "\n```" + `
+` + "```\n" + dockerfileBase + dockerfileFragment + "\n```" + `
 
 In particular:
-- Assume it is primarily a Go project. For a minimal env, prefer ` + defaultBaseImg + ` as a base image.
-- If any python is needed at all, switch to using a python alpine image as a the base and apk add go.
-  Favor using uv, and use one of these base images, depending on the preferred python version:
-    ghcr.io/astral-sh/uv:python3.13-alpine
-    ghcr.io/astral-sh/uv:python3.12-alpine
-    ghcr.io/astral-sh/uv:python3.11-alpine
-- When using pip to install packages, use: uv pip install --system.
+- Assume it is primarily a Go project.
 - Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.
 - Include any tools particular to this repository that can be inferred from the given context.
-- Append || true to any apk add commands in case the package does not exist.
+- Append || true to any apt-get install commands in case the package does not exist.
+- MINIMIZE the number of extra_cmds generated. Straightforward environments do not need any.
 - Do NOT expose any ports.
 - Do NOT generate any CMD or ENTRYPOINT extra commands.
 `,
@@ -210,20 +246,18 @@
 		return "", fmt.Errorf("no dockerfile returned")
 	}
 
-	tmpl := dockerfileCustomTmpl
-	if dockerfileFROM == defaultBaseImg {
-		// Because the LLM has chosen the image we recommended, we
-		// can use a pre-canned image of our entire template, which
-		// saves a lot of build time.
-		tmpl = dockerfileDefaultTmpl
+	tmpl := dockerfileDefaultTmpl
+	if tag := dockerfileBaseHash(); checkTagExists(tag) != nil {
+		// In development, if you edit dockerfileBase but don't release
+		// (as is reasonable for testing things!) the hash won't exist
+		// yet. In that case, we skip the sketch image and build it ourselves.
+		fmt.Printf("published container tag %s:%s missing; building locally\n", dockerImgName, tag)
+		tmpl = dockerfileBase + dockerfileFragment
 	}
-
 	buf := new(bytes.Buffer)
 	err = template.Must(template.New("dockerfile").Parse(tmpl)).Execute(buf, map[string]string{
-		"From":          dockerfileFROM,
-		"ExtraCmds":     dockerfileExtraCmds,
-		"InitFilesHash": hashInitFiles(initFiles),
-		"SubDir":        subPathWorkingDir,
+		"ExtraCmds": dockerfileExtraCmds,
+		"SubDir":    subPathWorkingDir,
 	})
 	if err != nil {
 		return "", fmt.Errorf("dockerfile template failed: %w", err)