Safe PR CI with kind

Build a GitHub Actions check that tests pull requests on a kind cluster.

Introduction

30 Second Summary

A code change can look healthy in a quick test yet fail when the real service starts. That late surprise turns a routine review into a recovery job.

In this project, you will build a private contribution workflow where GitHub Actions tests every pull request inside a disposable kind Kubernetes cluster. On your laptop, Ollama uses qwen3:1.7b to name a failed step without deciding whether the change passes.

What You'll Build

You will watch one pull request move from a green pr check to a red Wait for readiness failure before returning to green on the same branch.

By the end of this project, you'll have:

  • An auditable first commit that shows only the five design documents before any code appears.
  • A single pull request journey that moves from green to red at Wait for readiness. The same pull request returns to green after the service is restored.
  • A released private handoff that shows issue 1 closed. It includes release v0.1.0. It also includes a completed upstream draft plus a Measured model score out of 5.
  • Secret Mission: Prove the permission guard rejects contents: write. Then restore the released tree.

Are there any prerequisites?

You need a GitHub account plus an editor with a built-in terminal. You also need internet access plus enough laptop space for the local model.

The project costs 0.00 USD when your private repository has enough included GitHub Actions usage for its hosted runs.

Before We Start

This checkpoint fixes the scope before any hands-on work: one private kind-ci-template repository plus one private GitHub Project at 0.00 USD, provided the hosted checks stay within included GitHub Actions usage. The trust boundary is clear: the pull request proves Go tests, image building, a disposable kind deployment, readiness, and smoke testing, while qwen3:1.7b stays on your laptop through Ollama at 127.0.0.1:11434 and may only name a failed workflow step without deciding whether a change passes.

Set Up Your Local Environment

Hosted GitHub Actions runs can fail for reasons that have nothing to do with your workflow. A missing tool or expired login can hide the real signal.

Settle those dependencies before you write any code. You will finish with an empty local repository on main.

In this step, get ready to:
  • Verify the local tools required by the workflow.
  • Confirm your Git identity, GitHub access, local model, and included Actions usage.
  • Create the empty kind-ci-template repository on main.
Verify your local toolchain

Your pull request workflow depends on Go, kind, and Kubernetes tooling. Checking them together exposes installation problems before they look like workflow failures.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Cursor or Visual Studio Code into system search.
  • Press Enter to open your installed editor.
  • Create a built-in terminal from the editor's terminal menu.
  • Use PowerShell on Windows.
  • Use bash or zsh on macOS and Linux.
  • Check the version-pinned tools by running these commands:
go version
kind version
kubectl version --client

What should these checks show?

  • The first command prints Go version 1.26 or newer.
  • The second command prints kind version v0.33.0.
  • The third command prints the installed kubectl client version.

✔️ I see the expected Go and kind versions

Your version-pinned tools are ready. The kubectl output confirms that the Kubernetes client is available.

ⓧ I see an older version

An older Go or kind version can change command behavior. Upgrade the affected tools before continuing.

macOS

  • Upgrade Go and the supporting tools with Homebrew by running:
brew install go git gh ollama
brew install kubectl
What do these commands install?

Homebrew installs or upgrades Go, Git, GitHub CLI, Ollama, and kubectl. The terminal prints progress for each formula.

Windows

  • Accept any package agreement prompts that appear during the installation.
  • Upgrade Go and the supporting tools with WinGet by running:
winget install GoLang.Go Git.Git GitHub.cli Ollama.Ollama
winget install -e --id Kubernetes.kubectl
What do these commands install?

WinGet installs or upgrades Go, Git, GitHub CLI, Ollama, and kubectl. Each package reports its installation result in PowerShell.

Linux

Linux package versions vary by distribution. Use your package manager when it provides the required version.

  • Close the current built-in terminal after the installation completes.
  • Create a new built-in terminal from the editor's terminal menu.
  • Install the required kind release by running:
go install sigs.k8s.io/kind@v0.33.0

What does this command do?

Go installs kind at the exact v0.33.0 release. An exact release keeps local checks aligned with the CI design.

  • Add Go's binary directory to your system path through the official kind instructions if the kind command remains unavailable.
  • Rerun all three version checks:
go version
kind version
kubectl version --client

What should I see now?

Go prints version 1.26 or newer. Kind prints v0.33.0.

Still seeing an older version?

Close every editor terminal after an upgrade. A new terminal reloads your system path.

Check that the command resolves to the newly installed binary. Help me find why my terminal still uses an older Go or kind version.

ⓧ Command not found

A missing command means the tool is absent from your system path. Install the missing tools before continuing.

macOS

  • Install Go and the supporting tools with Homebrew by running:
brew install go git gh ollama
brew install kubectl
What do these commands install?

Homebrew installs Go, Git, GitHub CLI, Ollama, and kubectl. The command output identifies each installed formula.

Windows

  • Accept any package agreement prompts that appear during the installation.
  • Install Go and the supporting tools with WinGet by running:
winget install GoLang.Go Git.Git GitHub.cli Ollama.Ollama
winget install -e --id Kubernetes.kubectl
What do these commands install?

WinGet installs Go, Git, GitHub CLI, Ollama, and kubectl. PowerShell reports whether each package installed successfully.

Linux

Linux distributions use different package managers. Follow the official installation path for each missing tool.

  • Close the current built-in terminal after the installation completes.
  • Create a new built-in terminal from the editor's terminal menu.
  • Install kind at the required release by running:
go install sigs.k8s.io/kind@v0.33.0

What does this command do?

Go downloads kind release v0.33.0 into Go's binary directory. This pins the cluster tool used by the project.

  • Add Go's binary directory to your system path through the official kind instructions if the kind command remains unavailable.
  • Rerun all three version checks:
go version
kind version
kubectl version --client

What should I see now?

Each command now prints version information. Go is at least 1.26 and kind is v0.33.0.

Still seeing command not found?

Close every terminal that was open during installation. Create a new built-in terminal so it loads the updated system path.

Help me fix a missing local tool after installation.

Confirm identity, access, model, and budget

Your commits need a Git identity. The GitHub CLI needs repository and Project access.

The failed-step explainer also depends on Ollama running locally. Its qwen3:1.7b model stays on your laptop.

  • Check your Git identity, GitHub authentication, and local models by running:
git config user.name
git config user.email
gh auth status
ollama list

What should these checks show?

  • The first two commands print the name and email stored in your Git configuration.
  • The third command confirms that GitHub CLI is signed in.
  • The final command lists qwen3:1.7b.

✔️ Every check shows the expected state

Your identity, authentication, and local model are available. Grant GitHub CLI the additional scope required for Projects.

  • Allow Project access by running:
gh auth refresh -s project

What does this command do?

GitHub CLI refreshes your authentication with the Project scope. Follow any browser confirmation flow that opens.

ⓧ A command runs but its state is wrong

Apply only the recovery actions that match your output. Rerun the complete check after each fix.

  • Set the missing Git identity values by running:
git config --global user.name "[[GIT_NAME="your Git commit name"]]"
git config --global user.email [[GIT_EMAIL="you@example.com"]]

What do these commands store?

Git stores your commit name and email in your user-wide configuration. Future commits use this identity.

  • Start the GitHub sign-in flow if the authentication check reports that you are signed out.
  • Follow the terminal and browser prompts until authentication completes.
  • Grant Project access after signing in by running these commands:
gh auth login
gh auth refresh -s project

What happens during sign-in?

GitHub CLI starts an interactive authentication flow. The second command adds the scope required to work with GitHub Projects.

Start Ollama locally

Ollama must answer on 127.0.0.1:11434 before it can list or run a model. Start the local service when the list command cannot connect.

macOS

  • Press Cmd+Space to open system search.
  • Type Ollama into system search.
  • Press Enter to start Ollama.

Windows

  • Press the Windows key to open system search.
  • Type Ollama into system search.
  • Press Enter to start Ollama.

Linux

  • Start the Ollama server in the current terminal by running:
ollama serve
What does this command do?

Ollama starts its local server and keeps this terminal occupied. Leave it running while you use the model.

  • Create another built-in terminal for the remaining commands.
  • Download the model if it is missing by running:
ollama pull qwen3:1.7b

What happens during the download?

The model is about 1.4 GB. The download can take several minutes while Ollama stores it on your laptop.

  • Rerun the identity, authentication, and model checks:
git config user.name
git config user.email
gh auth status
ollama list

What should I see now?

Git prints both identity values. GitHub CLI reports an authenticated account.

Ollama lists qwen3:1.7b as a local model.

Still seeing the wrong state?

Rerun only the failing command so its latest output stays visible. Avoid sharing authentication tokens or other credentials.

Help me fix my Git, GitHub CLI, or Ollama setup.

ⓧ Command not found

Install the missing base tool through the supported path for your operating system. Open a new terminal after the installation.

macOS

  • Install Git, GitHub CLI, and Ollama with Homebrew by running:
brew install go git gh ollama
What does this command install?

Homebrew installs the missing base tools. Its output reports the result for each formula.

Windows

  • Accept any package agreement prompts that appear during the installation.
  • Install Git, GitHub CLI, and Ollama with WinGet by running:
winget install GoLang.Go Git.Git GitHub.cli Ollama.Ollama
What does this command install?

WinGet installs the missing base tools. PowerShell reports whether each package installed successfully.

Linux

  • Close the current built-in terminal.
  • Create a new built-in terminal from the editor's terminal menu.
  • Set your Git identity by running:
git config --global user.name "[[GIT_NAME="your Git commit name"]]"
git config --global user.email [[GIT_EMAIL="you@example.com"]]

What do these commands store?

Git stores the identity attached to future commits. The global setting applies to repositories for your current user.

  • Start the GitHub authentication flow.
  • Follow the terminal and browser prompts until authentication completes.
  • Grant Project access by running these commands:
gh auth login
gh auth refresh -s project

What happens during authentication?

GitHub CLI connects your local command line to your account. The refreshed scope permits Project operations later in the project.

  • Start the installed Ollama application on Windows or macOS through system search.
  • Start the Ollama service on Linux with the documented server command.
  • Download the required local model by running:
ollama pull qwen3:1.7b

What happens during the download?

Ollama downloads the 1.4 GB model to your laptop. The model remains outside the hosted workflow.

  • Confirm the recovered tools by running:
git config user.name
git config user.email
gh auth status
ollama list

What should I see now?

Git prints both identity values. GitHub CLI confirms that you are signed in.

Ollama lists qwen3:1.7b.

Still missing a command?

A terminal opened before installation may keep an outdated system path. Close it before creating another built-in terminal.

Help me recover a missing Git, GitHub CLI, or Ollama command.

Checking billing can feel risky because private repository runs can become billable after included usage is consumed. This project stays at 0.00 USD only while enough included usage remains.

  • Open your GitHub account's billing page to check the remaining included GitHub Actions usage.
  • Confirm that the planned hosted checks fit within the remaining included usage.
  • Stop before the first hosted run if completing the checks would add spend.
  • Review the official GitHub Actions billing documentation if the available allowance is unclear.

Why check usage now?

Private repository runs consume the repository owner's included GitHub Actions usage. The workflow belongs later in the project, so this check prevents an unexpected charge before any hosted run starts.

Create the empty repository

The repository begins empty so the next step can make the five design documents the first commit. No application code belongs in this setup step.

  • Move to your home directory by running:
cd ~

What does this command do?

The command places your terminal in your home directory. The repository will have the same location on every supported system.

  • Create the kind-ci-template repository on main by running:
git init -b main kind-ci-template

What does this command do?

Git creates ~/kind-ci-template as a local repository. Its first branch is named main.

  • Click File in the editor's top menu.
  • Click Open Folder.
  • Select the kind-ci-template folder from your home directory.
  • Click Open to load the folder in the editor.
  • Create a built-in terminal from the editor's terminal menu.

Before you run the final check, what branch do you expect an empty repository created with -b main to report?

  • Verify the active branch by running:
git branch --show-current

What should I see?

The terminal prints main on its own line. This proves that the empty repository is open on the required starting branch.

Do you see a different result?

Check that the editor opened ~/kind-ci-template. A terminal opened in another folder may inspect a different repository.

Help me verify that my editor terminal is inside the correct repository.

Your setup is complete. The editor now holds an empty repository on main with the required local tools available.

Next, you will make the five design documents the repository's auditable first commit.

Create the Design Baseline

Your local Git repository is ready on main. The project still needs a written contract before application code can enter its history.

This step creates an auditable design baseline. The first commit records the ask, decisions, topology, implementation plan, and test plan without mixing in code.

In this step, get ready to:
  • Commit the five design documents as the complete first commit.
  • Add the supplied project kit without creating .github/workflows/pr.yml.
  • Publish the private repository and create the private Project workflow.
Commit the five design documents

A design baseline records what the project must prove before implementation choices can blur the original goal. Keeping these documents alone in the first commit makes that baseline easy to audit.

  • Use your editor's file creation control to create the five paths shown in the reference tab below.
  • Open the second tab to copy each complete file exactly.
  • Keep every application file out of the repository until this first commit is complete.

✔️ Awesome, I've got everything!

Confirm that your docs folder contains exactly the five design files. Save every file before returning to the terminal.

ⓧ I'd like to double check the full code

# Build specification

## The ask

Build one private repository named `kind-ci-template`. Use one private Project, one issue, one pull request, and release `v0.1.0`. Run every pull request update through a real kind cluster. Keep `qwen3:1.7b` on the laptop only. Keep the project cost at 0.00 USD.

## The problem

GitHub added pull request access controls on 2026-02-13 and open pull request limits on 2026-06-17. These controls reduce who may open pull requests and how many they may hold open. They do not prove that a change works on Kubernetes.

Use `pull_request`, not `pull_request_target`. A privileged `pull_request_target` run can receive the base repository token, secrets, and default-branch cache while it handles untrusted pull request input.

What this does not fix: a maintainer still reads every pull request that passes.

## Acceptance criteria

### AC-1

The GitHub repository and GitHub Project are private. `LICENSE` begins with `Apache License`.

### AC-2

The first commit contains only the five design documents and has the exact message `Add the design docs`.

### AC-3

`go test ./...` completes successfully and prints an `ok` line.

### AC-4

Replacing the checkout SHA with `actions/checkout@v7` makes the guard print `unpinned: actions/checkout@v7`. Restoring the workflow makes the test pass again.

### AC-5

The check named `pr` is green on `ci-workflow`. Its elapsed minutes are pasted as Measured evidence.

### AC-6

The temporary `planted-red` change makes `/readyz` return 503. The same pull request is red at `Wait for readiness`. The laptop explainer prints `step: Wait for readiness`. The red experiment is closed and `planted-red` is deleted.

### AC-7

The five stored logs are scored. The result is pasted as `score: N of 5 failing steps named` and labeled Measured. The model does not decide pass or fail.

### AC-8

The one pull request is merged, issue 1 is closed, release `v0.1.0` exists, the README answers are complete, `docs/upstream.md` ends with `Recommendation:`, and the repository and Project remain private.

## Threats and controls

| Threat | Control | Evidence |
| --- | --- | --- |
| Privileged pull request event | Use `pull_request`, never `pull_request_target` | Workflow guard |
| Mutable action reference | Require a 40-character SHA | AC-4 |
| Excess token permission | Use top-level `contents: read` | Permission guard |
| Secret exposure | Refuse secret references in `pr.yml` | Workflow guard |
| Changed kind binary | Check the published checksum | `Install kind` log |
| Registry or tag drift | Load `hello:pr` into kind and use `imagePullPolicy: Never` | Deployment and workflow |
| Logs leave the laptop | Send failed logs only to Ollama on `127.0.0.1` | Local explainer code |

## Out of scope

- arm64
- merge blocking
- a full secret scanner

## CNCF levels

- Kubernetes: Graduated.
- kind: a Kubernetes SIG project.
- GitHub Actions, Go, and Ollama: no CNCF project level is assigned here.

## Sources

- GitHub pull request access controls, 2026-02-13: https://github.blog/changelog/2026-02-13-new-repository-settings-for-configuring-pull-request-access/
- GitHub open pull request limits, 2026-06-17: https://github.blog/changelog/2026-06-17-limit-open-pull-requests-for-users-without-write-access/
- GitHub `pull_request_target` rule: https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/

What does this document define?

The build specification fixes the scope, acceptance criteria, security controls, and evidence requirements before implementation begins.

# Architecture decision records

## ADR-001: kind on the runner over a hosted cluster

Context: The pull request must prove the image, Deployment, readiness probe, Service, and smoke path without leaving a cluster running.

Choice: Create kind on the GitHub-hosted runner for the pull request.

Alternative: Use a hosted Kubernetes cluster.

Consequence: Each run creates and removes its own cluster. The run uses more time than unit tests alone.

Reversal trigger: Reverse this choice when a required test depends on managed cloud identity, managed ingress, or another service that kind cannot provide.

## ADR-002: The model on the laptop over the runner

Context: Failed logs need a short step label, but the model and inference must stay on the laptop.

Choice: Send failed log text to `qwen3:1.7b` through Ollama on `127.0.0.1:11434`.

Alternative: Run the model on the GitHub-hosted runner.

Consequence: Explanation requires the laptop model and a running local Ollama service. The model names a step but decides nothing.

Reversal trigger: Reverse this choice only when runner-side inference is approved and it still meets the 0.00 USD limit.

## ADR-003: FROM scratch over a pinned base image

Context: The final image needs only the static Go binary.

Choice: Use `FROM scratch`.

Alternative: Use a pinned base image.

Consequence: The final image has no shell or package manager for live debugging.

Reversal trigger: Reverse this choice when a tested runtime requirement needs files or tools that the static binary does not contain.

What do these records preserve?

Each architecture decision record captures the choice, alternative, consequence, and condition that could reverse it.

# Topology

```text
contributor
    |
    v
one pull request
    |
    v
.github/workflows/pr.yml
    |
    +--> Go test --> image hello:pr --> kind --> deploy --> readiness --> smoke test
                                                                         |
                                                                         v
                                                                     check pr

failed run
    |
    v
gh run view --log-failed
    |
    v
cmd/explain on the laptop --> Ollama qwen3:1.7b on 127.0.0.1:11434
                              names a step; decides nothing
```

What does this diagram show?

The topology separates the hosted pull request check from the laptop-only failure explanation path.

# Implementation plan

## Phases

| Phase | Timebox |
| --- | ---: |
| Setup | 10 minutes |
| Design | 8 minutes |
| Build | 22 minutes |
| Validation | 12 minutes |
| Close-out | 7 minutes |
| Total | 59 minutes |

No task was removed. The page stops at minute 59 to keep the project under the platform limit.

## FinOps

| Resource | Planned charge |
| --- | ---: |
| Private repository | 0.00 USD |
| Private Project | 0.00 USD |
| Hosted pull request runs within included usage | 0.00 USD |
| Disposable kind cluster inside each run | 0.00 USD |
| Ollama model on the laptop | 0.00 USD |
| Total | 0.00 USD |

Stop before the first hosted run if it would exceed included usage.

What does this plan control?

The plan timeboxes every phase and preserves the zero-cost boundary for hosted usage.

# Test plan

Run AC-3 through AC-7 in order. Paste each observed result beside its row.

| Order | Test | Expected result | Pasted result |
| ---: | --- | --- | --- |
| 1 | AC-3 | `ok` | |
| 2 | AC-4 | `unpinned: actions/checkout@v7`, then restored | |
| 3 | AC-5 | `pr` green with minutes | |
| 4 | AC-6 | Red at `Wait for readiness`; `step: Wait for readiness`; red experiment closed; `planted-red` deleted | |
| 5 | AC-7 | `score: N of 5 failing steps named`; Measured | |

What does this table enforce?

The ordered rows keep each observed result beside the acceptance criterion it proves.

  • Save all five files in the docs folder.
  • Confirm that your editor's file tree shows only those five project files outside Git's hidden metadata.
  • Stage only the five design documents by running this command:
git add docs/build-spec.md docs/decisions.md docs/topology.md docs/implementation-plan.md docs/test-plan.md

What does this command do?

Git adds only the five named paths to the staging area. No later application file can enter this commit.

  • Create the first commit with the required message by running this command:
git commit -m "Add the design docs"

Why preserve this commit?

This commit becomes the immutable point where the project requirements exist without implementation code.

Before you inspect the commit, predict which paths should appear in its file list.

  • Reveal the exact contents of the first commit by running this command:
git show --name-only --format= HEAD

What should I see?

You should see only docs/build-spec.md, docs/decisions.md, docs/topology.md, docs/implementation-plan.md, and docs/test-plan.md.

Seeing another path in the first commit?

Stop before creating more files. The first commit must contain only the five design paths.

Use Help me repair the first commit without losing my five design files.

Strong start. Your repository now has an auditable design contract before a single application file enters its history.

Add and check the project kit

The supplied kit contains the service, guard, cluster resources, local explainer, and contribution policies. The pull request workflow remains absent because you write that file in the next step.

Why kind instead of a hosted cluster?

A disposable kind cluster can test image loading, Kubernetes readiness, service routing, and the smoke path inside each pull request run.

A hosted cluster adds persistent infrastructure that this zero-cost practice repository does not need.

The example below represents a different workflow. Its unsafe lines show the controls your guard must reject.

name: example-package-lint

on:
  pull_request:

permissions:
  contents: write # Trap: more permission than this check needs.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - name: Fetch source
        uses: actions/checkout@v7 # Trap: a floating tag, not a 40-character SHA.
      - name: Run lint
        run: go test ./...
        continue-on-error: true # Trap: a required test only informs instead of failing the job.

What are the three traps?

  • The workflow grants write permission to a check that only needs to read source.
  • The checkout action uses a mutable tag instead of a 40-character commit SHA.
  • The required test can fail without failing the job.
  • Use your editor's file creation control to create every named file in the reference tab below except go.mod and go.sum.
  • Keep .github/workflows/pr.yml absent from the file tree.
  • Use the go.mod and go.sum references only to compare the files generated by the module commands.

✔️ Awesome, I've got everything!

Save every supplied file. Confirm that .github/workflows/pr.yml does not exist.

ⓧ I'd like to double check the full code

package main

import (
	"fmt"
	"log"
	"net/http"
)

func newHandler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprintln(w, "hello from kind-ci-template")
	})
	mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprintln(w, "ready")
	})
	return mux
}

func main() {
	fmt.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", newHandler()))
}

What does the service do?

The service exposes a greeting route plus the readiness route used by the cluster probe.

package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHandlers(t *testing.T) {
	tests := []struct {
		name string
		path string
		body string
	}{
		{name: "root", path: "/", body: "hello from kind-ci-template\n"},
		{name: "ready", path: "/readyz", body: "ready\n"},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, test.path, nil)
			rec := httptest.NewRecorder()
			newHandler().ServeHTTP(rec, req)

			if rec.Code != http.StatusOK {
				t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
			}
			if rec.Body.String() != test.body {
				t.Fatalf("body = %q, want %q", rec.Body.String(), test.body)
			}
		})
	}
}

What do these tests prove?

The handler tests verify the status and exact response body for both service routes.

FROM scratch
COPY bin/hello /hello
USER 65532
ENTRYPOINT ["/hello"]

What does the image contain?

The final image contains only the static service binary. It runs as user 65532.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello
  template:
    metadata:
      labels:
        app: hello
    spec:
      containers:
        - name: hello
          image: hello:pr
          imagePullPolicy: Never
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080

What does the Deployment enforce?

The Deployment uses the locally loaded image and sends readiness checks to /readyz on port 8080.

apiVersion: v1
kind: Service
metadata:
  name: hello
spec:
  selector:
    app: hello
  ports:
    - port: 80
      targetPort: 8080

What does the Service expose?

The Service routes port 80 to the application's port 8080.

package main

import (
	"fmt"
	"os"
	"regexp"
	"strings"
	"testing"

	"go.yaml.in/yaml/v3"
)

const workflowFile = ".github/workflows/pr.yml"

var pinnedUse = regexp.MustCompile(`^[^@[:space:]]+@[0-9a-f]{40}$`)

var requiredSteps = []string{
	"Check out",
	"Set up Go",
	"Go test",
	"Build image",
	"Install kind",
	"Create cluster",
	"Load image",
	"Deploy",
	"Wait for readiness",
	"Smoke test",
	"Timing summary",
}

func TestWorkflowGuard(t *testing.T) {
	data, err := os.ReadFile(workflowFile)
	if err != nil {
		t.Fatal(err)
	}

	var document yaml.Node
	if err := yaml.Unmarshal(data, &document); err != nil {
		t.Fatal(err)
	}
	if len(document.Content) == 0 {
		t.Fatal("empty workflow")
	}

	root := document.Content[0]
	findings := []string{}
	finding := func(line string) {
		fmt.Println(line)
		findings = append(findings, line)
	}

	trigger := mappingValue(root, "on")
	if trigger == nil || mappingValue(trigger, "pull_request") == nil {
		t.Error("pull_request trigger missing")
	}
	if trigger != nil && mappingValue(trigger, "pull_request_target") != nil {
		finding("trigger: pull_request_target")
	}

	permissions := mappingValue(root, "permissions")
	contentsSeen := false
	if permissions == nil || permissions.Kind != yaml.MappingNode {
		finding("permissions: contents is missing")
	} else {
		for i := 0; i+1 < len(permissions.Content); i += 2 {
			scope := permissions.Content[i].Value
			level := permissions.Content[i+1].Value
			if scope == "contents" {
				contentsSeen = true
			}
			if level != "read" {
				finding(fmt.Sprintf("permissions: %s is %s", scope, level))
			}
		}
		if !contentsSeen {
			finding("permissions: contents is missing")
		}
	}

	walk(root, func(node *yaml.Node) {
		if node.Kind == yaml.ScalarNode && strings.Contains(node.Value, "${{ secrets.") {
			finding("secret: " + workflowFile)
		}
		if node.Kind != yaml.MappingNode {
			return
		}
		for i := 0; i+1 < len(node.Content); i += 2 {
			if node.Content[i].Value != "uses" {
				continue
			}
			use := node.Content[i+1].Value
			if !pinnedUse.MatchString(use) {
				finding("unpinned: " + use)
			}
		}
	})

	steps := workflowSteps(root)
	for i, expected := range requiredSteps {
		if i >= len(steps) {
			finding("step: " + expected)
			continue
		}
		name := scalarValue(mappingValue(steps[i], "name"))
		if name != expected {
			finding("step: " + name)
		}
	}
	for i := len(requiredSteps); i < len(steps); i++ {
		finding("step: " + scalarValue(mappingValue(steps[i], "name")))
	}

	for _, step := range steps {
		name := scalarValue(mappingValue(step, "name"))
		informOnly := scalarValue(mappingValue(step, "continue-on-error")) == "true"
		if name == "Timing summary" && !informOnly {
			finding("step: " + name)
		}
		if name != "Timing summary" && informOnly {
			finding("step: " + name)
		}
	}

	if len(findings) != 0 {
		t.Fatalf("%d workflow policy finding(s)", len(findings))
	}
}

func mappingValue(node *yaml.Node, key string) *yaml.Node {
	if node == nil || node.Kind != yaml.MappingNode {
		return nil
	}
	for i := 0; i+1 < len(node.Content); i += 2 {
		if node.Content[i].Value == key {
			return node.Content[i+1]
		}
	}
	return nil
}

func scalarValue(node *yaml.Node) string {
	if node == nil {
		return "missing"
	}
	return node.Value
}

func walk(node *yaml.Node, visit func(*yaml.Node)) {
	if node == nil {
		return
	}
	visit(node)
	for _, child := range node.Content {
		walk(child, visit)
	}
}

func workflowSteps(root *yaml.Node) []*yaml.Node {
	jobs := mappingValue(root, "jobs")
	if jobs == nil || jobs.Kind != yaml.MappingNode {
		return nil
	}
	steps := []*yaml.Node{}
	for i := 1; i < len(jobs.Content); i += 2 {
		sequence := mappingValue(jobs.Content[i], "steps")
		if sequence == nil || sequence.Kind != yaml.SequenceNode {
			continue
		}
		steps = append(steps, sequence.Content...)
	}
	return steps
}

What does the workflow guard enforce?

The guard requires the safe trigger, read-only contents permission, immutable action references, no secret use, exact step order, and one inform-only timing step.

package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

const (
	ollamaURL   = "http://127.0.0.1:11434/api/chat"
	ollamaModel = "qwen3:1.7b"
	ollamaError = "Ollama did not answer on 127.0.0.1:11434; start it and run again."
)

var stepNames = []string{
	"Check out",
	"Set up Go",
	"Go test",
	"Build image",
	"Install kind",
	"Create cluster",
	"Load image",
	"Deploy",
	"Wait for readiness",
	"Smoke test",
	"Timing summary",
}

type message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type chatRequest struct {
	Model    string             `json:"model"`
	Messages []message          `json:"messages"`
	Stream   bool               `json:"stream"`
	Think    bool               `json:"think"`
	Options  map[string]float64 `json:"options"`
}

type chatResponse struct {
	Message message `json:"message"`
}

func main() {
	runMode := flag.String("run", "", "run to explain")
	scoreDir := flag.String("score", "", "directory of failure logs")
	flag.Parse()

	var err error
	switch {
	case *runMode == "latest-failed":
		err = explainLatestFailed()
	case *scoreDir != "":
		err = score(*scoreDir)
	default:
		err = errors.New("use -run latest-failed or -score DIR")
	}
	if err != nil {
		os.Exit(1)
	}
}

func explainLatestFailed() error {
	idOutput, err := exec.Command("gh", "run", "list", "--workflow", "pr.yml", "--status", "failure", "--limit", "1", "--json", "databaseId", "--jq", ".[0].databaseId").Output()
	if err != nil {
		return err
	}
	runID := strings.TrimSpace(string(idOutput))
	if runID == "" {
		return errors.New("no failed pr.yml run found")
	}

	logOutput, err := exec.Command("gh", "run", "view", runID, "--log-failed").CombinedOutput()
	if err != nil && len(logOutput) == 0 {
		return err
	}
	line, err := nameStep(string(logOutput))
	if err != nil {
		return err
	}
	fmt.Println(line)
	return nil
}

func score(dir string) error {
	expectedData, err := os.ReadFile(filepath.Join(dir, "expected.txt"))
	if err != nil {
		return err
	}
	correct := 0
	for _, row := range strings.Split(strings.TrimSpace(string(expectedData)), "\n") {
		parts := strings.SplitN(row, "|", 2)
		if len(parts) != 2 {
			return errors.New("bad expected.txt row")
		}
		logData, err := os.ReadFile(filepath.Join(dir, parts[0]))
		if err != nil {
			return err
		}
		actual, err := nameStep(string(logData))
		if err != nil {
			return err
		}
		if actual == "step: "+parts[1] {
			fmt.Printf("ok  %s\n", parts[0])
			correct++
		} else {
			fmt.Printf("MISS %s\n", parts[0])
		}
	}
	fmt.Printf("score: %d of 5 failing steps named\n", correct)
	return nil
}

func nameStep(logText string) (string, error) {
	prompt := "Return exactly one line in the form step: <name>. Choose the failing step from this list: " + strings.Join(stepNames, ", ") + ".\n\nLog:\n" + logText
	requestBody, err := json.Marshal(chatRequest{
		Model:    ollamaModel,
		Messages: []message{{Role: "user", Content: prompt}},
		Stream:   false,
		Think:    false,
		Options:  map[string]float64{"temperature": 0},
	})
	if err != nil {
		return "", err
	}

	response, err := http.Post(ollamaURL, "application/json", bytes.NewReader(requestBody))
	if err != nil {
		fmt.Println(ollamaError)
		return "", err
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusOK {
		fmt.Println(ollamaError)
		return "", errors.New("ollama returned a non-200 status")
	}

	var decoded chatResponse
	if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil {
		fmt.Println(ollamaError)
		return "", err
	}
	for _, step := range stepNames {
		if strings.Contains(decoded.Message.Content, step) {
			return "step: " + step, nil
		}
	}
	return "step: unknown", nil
}

What authority does the model have?

The local explainer asks qwen3:1.7b to name one failed step from a fixed list. The model never decides whether the pull request passes.

Go test
workflow_test.go: 1 workflow policy finding
FAIL kind-ci-template

What does this fixture represent?

This stored log represents a failure in the Go test step.

Build image
ERROR: failed to solve

What does this fixture represent?

This stored log represents an image build failure.

Install kind
kind-linux-amd64: FAILED

What does this fixture represent?

This stored log represents a failure while installing kind.

Wait for readiness
error: timed out waiting for the condition on deployments/hello

What does this fixture represent?

This stored log represents a Deployment readiness timeout.

Smoke test
curl: (7) Failed to connect to localhost port 8080

What does this fixture represent?

This stored log represents a failed smoke-test connection.

01.log|Go test
02.log|Build image
03.log|Install kind
04.log|Wait for readiness
05.log|Smoke test

What does this mapping provide?

The expected file supplies the correct step name for each measured fixture.

bin/

What stays out of Git?

The compiled bin directory remains outside version control.

* text=auto eol=lf

What does this attribute control?

Git stores text files with line-feed endings for consistent cross-platform behavior.

# Contributing

1. Work from one issue.
2. Run `go test ./...`.
3. Use a signed-off commit.
4. Open one pull request and wait for check `pr`.
5. Do not add secrets, write permissions, mutable actions, or `pull_request_target`.
6. The model may name a failed step. It does not approve or merge the change.

What does the contributor policy require?

The policy fixes the one-issue workflow, signed-off commits, required testing, and model authority boundary.

# Security

- Use `pull_request`, never `pull_request_target`.
- Keep top-level permissions at `contents: read`.
- Pin every `uses:` value to a 40-character commit SHA.
- Do not use secrets in the pull request workflow.
- Keep only `Timing summary` inform-only.
- Keep repository and Project visibility private.

Report a concern in the private repository. Do not include credentials, tokens, or private logs.

What does the security policy protect?

The policy limits workflow privileges and keeps sensitive reports inside the private repository.

# Code of Conduct

Be respectful, specific, and constructive. Do not harass, threaten, expose private information, or submit secrets. Maintainers may remove harmful content or restrict participation. Report concerns in the private repository.

What behavior does this set?

The conduct policy establishes respectful participation and protects private information.

# kind-ci-template

## System used

Answer:

## My decisions

Which steps fail the job and which only inform?

Answer:

## Did it hold here?

Did running every pull request on a real cluster catch what a unit test would miss, and what did it not cover?

Answer:

## After-action review

What was expected?

Answer:

What happened?

Answer:

What changed?

Answer:

What remains?

Answer:

Why are the answers empty?

The README reserves space for evidence-based answers that you write after running the workflow.

# Limits

- The repository and Project stay private.
- The budget is 0.00 USD.
- The project uses one issue and one pull request.
- The model stays on the laptop and decides nothing.
- The workflow proves a Go service in a disposable kind cluster.
- The workflow does not prove managed cloud identity, managed ingress, or a registry path.
- arm64 is out of scope.
- merge blocking is out of scope.
- a full secret scanner is out of scope.
- A maintainer still reads every pull request that passes.

What do these limits prevent?

The limits stop the practice project from implying coverage or authority it does not have.

# Draft upstream kind issue for GitHub Actions use

This project does not post this draft.

## Title

Answer:

## Problem

Answer:

## Step timings

Answer:

## Known case

Answer:

## Local fallback

Answer:

Recommendation:

What is this draft for?

The template holds a future upstream proposal without posting it or claiming acceptance.

module kind-ci-template

go 1.26

require go.yaml.in/yaml/v3 v3.0.6

What should the module file contain?

The generated module must use the project name, Go 1.26, and the pinned YAML dependency.

go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=

What should the checksums contain?

The checksum file must contain the two supplied entries for go.yaml.in/yaml/v3 v3.0.6.

  • Save every supplied project file.
  • Confirm that your file tree includes main.go, workflow_test.go, cmd/explain/main.go, the two k8s files, and the six failure fixtures.
  • Initialize the Go module by running this command:
go mod init kind-ci-template

What does this command create?

Go creates go.mod with kind-ci-template as the module path.

  • Add the pinned YAML module by running this command:
go get go.yaml.in/yaml/v3@v3.0.6

What does this command pin?

Go records version v3.0.6 of the YAML module used by the workflow guard.

  • Normalize the module files by running this command:
go mod tidy

What does this command finish?

Go aligns the declared dependency with the imports and writes the required checksums.

  • Compare the generated go.mod with the reference tab.
  • Compare the generated go.sum with the reference tab.

The license comes from GitHub's license endpoint. This avoids a hand-copied legal text with missing lines.

  • Create the complete Apache license file by running this command:
gh api licenses/apache-2.0 --jq .body > LICENSE

What does this command retrieve?

GitHub returns the Apache License body. The shell writes that body to LICENSE.

  • Open LICENSE in your editor.
  • Confirm that its first line is Apache License.

Before you run the static check, predict whether the supplied Go files should produce any diagnostics.

  • Check every Go package by running this command:
go vet ./...

What should I see?

The terminal should return to its prompt without a diagnostic. That quiet result means the supplied Go packages passed the static checks.

Seeing a Go diagnostic?

Compare the named file with its full reference above. Pay close attention to imports, quotes, braces, and exact identifier spelling.

Use Help me diagnose this go vet failure against the supplied kind-ci-template files.

  • Stage the complete supplied kit by running this command:
git add .

What enters this commit?

Git stages the supplied service, tests, cluster files, policies, fixtures, module files, and license. The workflow file remains absent.

  • Commit the project kit with a sign-off by running this command:
git commit -s -m "Add the project kit"

What does the sign-off add?

The -s option appends a Signed-off-by trailer using your configured Git identity.

That is the local kit complete. It now compiles cleanly while the learner-owned workflow remains deliberately unwritten.

Publish the private collaboration space

The repository and Project hold contribution evidence outside your laptop. Their visibility must stay private because the logs and review material belong to this practice environment.

This action publishes your local history to GitHub. The --private setting keeps the repository restricted from its first remote commit.

  • Create the private repository from the current folder by running this command:
gh repo create kind-ci-template --private --source=. --push

What does this publish?

GitHub creates the private remote repository and pushes your existing main history.

Before checking visibility, predict the exact word the repository query should print.

  • Verify the repository visibility by running this command:
gh repo view kind-ci-template --json visibility --jq .visibility

What should I see?

You should see PRIVATE as the complete output.

Repository visibility is not private?

Correct the repository visibility before creating any hosted workflow run.

  • Apply the private visibility setting by running this command:
gh repo edit kind-ci-template --visibility private --accept-visibility-change-consequences

Use Help me verify that my kind-ci-template repository is private.

A GitHub Project tracks the issue and pull request separately from repository visibility. You will explicitly keep this Project private.

  • Create the Project titled kind-ci-template by running this command:
gh project create --owner @me --title kind-ci-template

What does this create?

GitHub creates a user-owned Project and prints its details. You need its number for the privacy command.

  • Record the number shown for the new Project as your Project number.
  • Set the Project visibility to private by running this command:
gh project edit [[PROJECT_NUMBER="your Project number"]] --owner @me --visibility PRIVATE

What does this protect?

The Project becomes visible only to people who receive access. Repository privacy alone does not set Project privacy.

The first issue anchors the contribution workflow. Adding it to the Project keeps the planned work and later pull request in one private view.

  • Start the interactive issue creation flow by running this command:
gh issue create --project "kind-ci-template"

What prompts should I complete?

GitHub CLI asks for the issue title and body. It then creates the issue in the current repository and adds it to the named Project.

  • Enter Build the pull request workflow when GitHub CLI asks for the issue title.
  • Enter Implement the safe cluster-per-pull-request check. when GitHub CLI asks for the issue body.
  • Confirm the issue creation when GitHub CLI presents the final prompt.

You should receive a URL ending in /issues/1. That confirms the repository has exactly one issue numbered 1.

  • Create the workflow branch by running this command:
git switch -c ci-workflow

What does this branch separate?

The ci-workflow branch holds the learner-written workflow while main preserves the published baseline.

Before the final check, predict which branch name should now be active.

  • Verify the active branch by running this command:
git branch --show-current

What should I see?

You should see ci-workflow as the complete output.

  • Open the Project URL returned by the creation command in your browser.
  • Confirm that the Project visibility is Private.
  • Confirm that issue 1 appears in the Project.

Your design baseline and project kit are now preserved in a private contribution workspace. Next, you will write the one missing file that turns this contract into a safe pull request check.

Build the Pull Request Workflow

Your design baseline is committed. Your private GitHub repository now has its Project and issue ready for the contribution.

The supplied service and guard still lack the file that defines the safe pull request path. You will write one GitHub Actions workflow that tests the service on a disposable kind cluster running Kubernetes.

In this step, get ready to:
  • Author the guarded pull request workflow from its executable contract.
  • Run the Go tests until the workflow guard passes.
  • Publish the workflow through the one required pull request.
Write the guarded workflow

The YAML workflow is both automation and policy. The existing workflow_test.go file acts as an executable contract for its security controls and step order.

The example workflow from the design step belongs to a different check. Its write permission and floating tag show patterns that your workflow must refuse.

  • In the editor from earlier, use the file sidebar to create a .github folder inside kind-ci-template.
  • Inside .github, create a workflows folder.

You will see .github/workflows nested in the repository file tree.

  • Inside .github/workflows, create pr.yml.

What Must the Workflow Enforce?

  • The workflow name is pr.
  • The only trigger is pull_request.
  • The top-level permission is contents: read.
  • The checkout action uses actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1.
  • The Go setup action uses actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e.
  • No step refers to a secret.
  • Only Timing summary uses continue-on-error: true.

The guard also fixes the exact order of the workflow. Use these eleven step names without changing their spelling:

  • Check out.
  • Set up Go.
  • Go test.
  • Build image.
  • Install kind.
  • Create cluster.
  • Load image.
  • Deploy.
  • Wait for readiness.
  • Smoke test.
  • Timing summary.

What Runtime Path Should You Build?

  • The test step runs the existing Go test suite.
  • The image step compiles the service to bin/hello for the supplied Containerfile.
  • The image step builds the local image as hello:pr.
  • The installation step downloads https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-amd64.
  • The installation step downloads kind-linux-amd64.sha256sum from the same v0.33.0 release.
  • The installation step uses sha256sum to verify the downloaded binary against kind-linux-amd64.sha256sum.
  • The cluster step uses kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5.
  • The load step places hello:pr inside the cluster.
  • The deploy step applies k8s/deployment.yaml.
  • The deploy step applies k8s/service.yaml.
  • The readiness step uses kubectl to wait up to 120s for Deployment hello to become Available.
  • The smoke step uses kubectl to forward Service hello through 8080:80.
  • The smoke step configures curl for 5 retries against the forwarded service.
  • The final step appends measured minutes to $GITHUB_STEP_SUMMARY.
  • Write .github/workflows/pr.yml from the security contract and runtime path above.
  • Save .github/workflows/pr.yml.
Prove the local guard

Before you run the tests, do you think the guard will accept every permission, action pin, and step name in your workflow?

  • Test the service and workflow contract by running:
go test ./...

What Does This Test Prove?

The handler test proves that the supplied service still returns its expected responses. The workflow guard parses .github/workflows/pr.yml.

The guard checks the trigger, permissions, action pins, secret references, step order, and inform-only setting. An ok line means the workflow satisfies that local policy.

You will see an ok line for kind-ci-template. That is the workflow contract satisfied.

Seeing a Workflow Policy Finding?

Read the printed finding before editing the workflow. It names the permission, unpinned action, missing step, extra step, or misplaced inform-only setting that failed.

Compare the reported item with requiredSteps and TestWorkflowGuard in workflow_test.go.

Ask for help with the exact finding: Help me understand this workflow guard failure without writing pr.yml for me.

The workflow guard decides which controls fail the job. Your README records that policy in plain language for reviewers.

  • In README.md, find the My decisions section.
  • Under its empty Answer: line, write that every required workflow step fails the job.
  • State that only Timing summary informs without failing the job.
  • Leave the other learner answer fields empty.
  • Save README.md.

The My decisions answer now matches the behavior enforced by workflow_test.go.

Publish and watch the pull request

The local guard proves the workflow's static policy. The hosted run now has to prove that the service works on a real disposable cluster.

  • Stage only the workflow and README changes by running:
git add .github/workflows/pr.yml README.md

What Did Staging Change?

Git has prepared .github/workflows/pr.yml and README.md for the next commit. The supplied files remain unchanged.

  • Create the signed-off workflow commit by running:
git commit -s -m "Build the pull request workflow"

What Did the Sign-Off Add?

The -s option adds a Signed-off-by trailer using your configured Git identity. This records your contributor attestation in the commit.

  • Publish ci-workflow and set its upstream branch by running:
git push -u origin ci-workflow

What Did the Push Change?

The private repository now has a remote ci-workflow branch. Future pushes from this branch can use its saved upstream relationship.

Push Rejected?

Confirm that your current branch is ci-workflow. A branch mismatch can send the wrong history.

If authentication fails, refresh the GitHub sign-in from the setup step before retrying the push.

Get help with the exact output: Help me diagnose why ci-workflow will not push to origin.

Creating the pull request starts the hosted workflow. You already confirmed that included usage covers this run, so the project stays within its zero-cost limit.

  • Use Build the pull request workflow as the pull request title when prompted.
  • Use Closes #1 as the pull request body when prompted.
  • Keep main as the base branch.
  • Create the pull request and link the private Project by running:
gh pr create --project "kind-ci-template"

What Did the Pull Request Link?

The pull request links ci-workflow to main. It also joins the private Project.

The Closes #1 body links issue 1 to the contribution. The issue stays open until the pull request merges.

The hosted run can take several minutes while it downloads kind and creates the cluster. Before you watch it, do you expect the check named pr to finish green?

  • Watch the pull request checks by running:
gh pr checks ci-workflow --watch

What Does Green Prove?

You will see the check named pr finish green. This proves the image built successfully on the hosted runner.

The run also created a real cluster and waited for the Deployment. Its smoke request reached the forwarded Service.

Only Timing summary can inform without failing the job. It writes the measured duration to $GITHUB_STEP_SUMMARY.

No Checks Showing Yet?

The pull request event can take a short time to queue. Wait briefly before rerunning the watch command above.

If pr turns red, inspect the named failed step first. Its name maps directly to the eleven-step contract.

Get help interpreting the failed step: Help me diagnose my red pr check from its failed step and logs.

Your one pull request now proves the safe path on a real cluster. Next, you will make the guard and readiness path fail on demand before restoring this same pull request to green.

Validate Failure Controls

Your GitHub Actions check is green on the existing pull request. That proves the safe path works on a real cluster.

A green run alone does not prove the guard or readiness path fails on demand. You will trigger both failures before returning the same pull request to green. Ollama will name failed steps on your laptop. The tests and your evidence determine whether each control passes.

In this step, get ready to:
  • Prove that the workflow guard rejects a mutable action tag.
  • Make the existing pull request fail at its Kubernetes readiness check.
  • Restore the pull request before measuring the local failure classifier.
Prove the workflow guard fails closed

The workflow guard is an executable security policy. Its tests check the trigger, permissions, action pins, secret references, step order, and failure behavior.

Begin with AC-3 so you have a passing baseline. Before you run the test, do you expect the unchanged workflow to satisfy every guard rule?

  • Run the complete Go test suite from the built-in terminal:
go test ./...

What Does the Baseline Prove?

  • The handler tests confirm that the service returns its expected responses.
  • The workflow guard checks the security contract in .github/workflows/pr.yml.

You will see an ok line. Your local service tests and workflow policy guard both pass.

  • Paste the observed ok result into the AC-3 pasted-result cell in docs/test-plan.md.

AC-4 checks whether a mutable action tag can replace an immutable commit pin. You will make one surgical change to test that boundary.

  • In .github/workflows/pr.yml, find the line containing actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1.
  • Replace only that pinned action value with actions/checkout@v7.
  • Save .github/workflows/pr.yml.

Before you run the guard, do you think the tag reference will satisfy the immutable-action rule?

  • Test the modified workflow by running:
go test ./...

What Does the Guard Inspect?

The guard accepts a uses: value only when it ends with a 40-character commit SHA. A release tag can move to different code over time.

You will see unpinned: actions/checkout@v7 before the test fails. This failure is intentional. It proves the guard refuses a mutable dependency reference.

  • Restore the safe workflow from Git by running:
git restore .github/workflows/pr.yml

What Does This Restore?

This discards the temporary tag mutation in .github/workflows/pr.yml. The learner-written SHA-pinned workflow returns unchanged.

  • Verify the restored workflow by rerunning:
go test ./...

What Does the Second Pass Prove?

The second passing test proves the workflow is back inside its security contract. The temporary mutation has left no final file change.

That control has now proved both sides of its contract. The tag is refused. The original SHA pin passes again.

  • Paste unpinned: actions/checkout@v7 into the AC-4 pasted-result cell in docs/test-plan.md.
  • Add the restored ok result to the same AC-4 cell.
  • Save docs/test-plan.md.

Does the Restored Test Still Fail?

Confirm that the checkout line contains the original 40-character SHA. Check that no other line in .github/workflows/pr.yml changed during the mutation.

If the failure points somewhere else, use the printed finding to inspect that exact workflow rule. Help me diagnose why my restored workflow guard still fails.

Break readiness on the same pull request

A Kubernetes readiness probe controls whether a Pod can receive service traffic. The green run proves the healthy response works. The next experiment proves the pull request turns red when that response becomes unavailable.

  • Return to the successful pr run from earlier.
  • Copy the green check result into the AC-5 pasted-result cell in docs/test-plan.md.
  • Copy the measured minutes from $GITHUB_STEP_SUMMARY into the same AC-5 cell.
  • Save docs/test-plan.md.
  • Create the temporary experiment branch by running:
git switch -c planted-red

Why Use a Temporary Branch?

The planted-red branch holds the deliberate failure and its later reversal. The existing ci-workflow pull request remains the only pull request in the project.

  • In main.go, locate the /readyz handler containing fmt.Fprintln(w, "ready").
  • Change that handler so it writes HTTP status 503 before its response body.
  • Save main.go.
  • Inspect the temporary readiness change by running:
git diff -- main.go

What Should the Diff Contain?

The diff should be limited to main.go. It should show that /readyz now returns status 503.

  • Stage only the readiness experiment by running:
git add main.go

What Does Staging Do?

Staging selects the main.go change for the experiment commit. No workflow or evidence file enters this commit.

  • Create the signed-off experiment commit by running:
git commit -s -m "Plant readiness failure"

Why Sign Off the Experiment?

The signed-off commit follows the same contributor rule as the rest of the repository. Its message also makes the deliberate failure easy to identify before the later revert.

  • Publish the temporary branch by running:
git push -u origin planted-red

What Does This Push Create?

This creates the remote planted-red branch. It does not create another pull request.

  • Move the experiment commit onto the existing pull request head by running:
git push origin planted-red:ci-workflow

How Does the Existing Pull Request Update?

The refspec pushes the current planted-red commit to the remote ci-workflow branch. GitHub reruns pr on the same pull request.

This hosted run creates a fresh kind cluster. Expect the check to take several minutes while the image and cluster are prepared.

Before you watch the check, where do you think the first visible failure will appear?

  • Watch the existing pull request check by running:
gh pr checks ci-workflow --watch

What Does the Red Run Prove?

The command follows the checks attached to the pull request whose head is ci-workflow. The failing readiness response prevents the Deployment from becoming Available.

You will see the pr check fail. Inspect its failed run log. You should find Wait for readiness as the failed step.

The red result is the intended outcome. A unit test can exercise the handler. This run proves the Deployment readiness gate blocks the cluster path.

The local model may take a few moments to classify the failed log. Its answer stays on your laptop.

  • Run the laptop-only explainer against the latest failed workflow run:
go run ./cmd/explain -run latest-failed

What Does the Local Explainer Do?

  • The program retrieves the latest failed pr.yml log with the authenticated GitHub CLI.
  • It sends the failed log to qwen3:1.7b through the local Ollama endpoint.
  • It restricts the answer to one of the eleven workflow step names.

You will see step: Wait for readiness. The model names the failed step. It does not approve the change or decide whether the control passed.

  • Paste the red Wait for readiness evidence into the AC-6 pasted-result cell in docs/test-plan.md.
  • Paste step: Wait for readiness into the same AC-6 cell.
  • Save docs/test-plan.md.

Did the Local Explainer Miss the Step?

Confirm that Ollama from setup is still running. Confirm that qwen3:1.7b remains available locally.

If the program cannot find a failed run, confirm that the red pr check finished before trying again. Help me diagnose my laptop-only failed-step explainer.

Restore the pull request and score the model

The red experiment has produced its evidence. The same branch history now needs a reversal that returns main.go to its released readiness response.

  • Revert the deliberate readiness commit by running:
git revert --no-edit HEAD

What Does the Revert Preserve?

The revert creates a new commit that reverses the 503 change. The experiment remains auditable while the final contents of main.go return to the healthy version.

  • Push the restored head onto the existing pull request by running:
git push origin planted-red:ci-workflow

What Does the Restored Push Trigger?

The push updates the same ci-workflow head ref. GitHub starts another pr run against the restored service.

The cluster build can take several minutes again. Before you watch it, do you expect readiness to stop the job this time?

  • Watch the restored pull request check by running:
gh pr checks ci-workflow --watch

What Does the Green Return Prove?

The check runs the same cluster path with the healthy /readyz response restored. A green result closes the deliberate red experiment.

The same pr check is green again. You have proved the readiness control can fail on demand before recovering on the same pull request.

  • Close the temporary branch experiment by running these commands:
git switch ci-workflow
git merge --ff-only planted-red
git push origin --delete planted-red
git branch -d planted-red

What Does the Branch Cleanup Do?

  • The first command returns your local workspace to ci-workflow.
  • The fast-forward merge records the experiment and its revert in the local branch history.
  • The remote deletion removes the temporary planted-red branch from GitHub.
  • The local deletion removes the temporary branch from your laptop.

The final model check uses five stored failure logs. Before you run it, how many step names do you expect the local model to match?

Five local classifications take longer than one explanation. Give the model a few minutes to finish every fixture.

  • Measure the five-fixture classifier score by running:
go run ./cmd/explain -score testdata/failures

What Does the Score Measure?

  • Each stored log represents one known workflow failure.
  • The expected mappings define the correct failed step for each fixture.
  • The final count measures how often the local model names the expected step.

You will see five fixture results followed by score: N of 5 failing steps named. This is a measured classification score. It is not a pass or merge decision.

  • Paste the observed score into the AC-7 pasted-result cell in docs/test-plan.md.
  • Label the AC-7 score Measured.
  • Confirm that AC-3 through AC-7 contain evidence in their original order.
  • Confirm that AC-6 records the closed red experiment and deleted planted-red branch.
  • Save docs/test-plan.md.

Did a Fixture Return a Miss?

Keep the observed score exactly as measured. Do not rewrite a fixture or expected mapping to improve the number.

Confirm that all five fixture files still match their supplied contents. Help me investigate a missed failure fixture without changing the measured evidence.

You have proved the guard, readiness gate, restoration path, and laptop-only classifier with observed evidence. Next up, you will complete the handoff before merging the one pull request and creating release v0.1.0.

Release and Hand Off

The ci-workflow pull request is green again. Your deliberate readiness failure proved that the real Kubernetes cluster can expose a deployment problem before the same pull request returns to green.

A green check records one technical outcome. The contribution remains incomplete until the handoff records the evidence within its stated limits.

The upstream draft must remain private. The merge then closes issue 1 before release v0.1.0 preserves the result in the private repository.

In this step, get ready to:
  • Complete the README review and the unposted upstream draft.
  • Merge the reviewed contribution so issue 1 closes.
  • Create the release and verify the final private handoff.
Complete the handoff documents

A handoff separates observed evidence from future claims. Your README.md records what happened in this project.

The docs/upstream.md draft proposes what someone could share later. It stays unposted unless you choose to publish it after this project.

  • Switch back to README.md from earlier.
  • Complete the System used answer with your actual laptop environment.
  • Complete the Did it hold here? answer.
  • Name the real-cluster behavior that your unit tests could not exercise.
  • Name one remaining limit from docs/limits.md in the same answer.

Where did the cluster add evidence?

The real kind cluster exercised the complete in-cluster path from image loading through the smoke request.

The project did not cover managed cloud identity. It also left managed ingress and a registry path outside its scope.

  • Write what you expected under the first after-action review question.
  • Write what happened under the second after-action review question.
  • Write what changed under the third after-action review question.
  • Write what remains under the final after-action review question.

Keep the upstream status accurate

The upstream document is a learner-authored draft. It records a possible proposal without claiming that anyone accepted or merged it.

  • Switch back to docs/upstream.md from earlier.
  • Write a proposed title under Title.
  • Describe the problem under Problem.
  • Enter your observed workflow timings under Step timings.
  • Describe the readiness experiment under Known case.
  • Describe the laptop explainer under Local fallback.
  • Write your recommendation beneath the final Recommendation: label.
  • Keep the completed draft unposted.
  • Search each Answer: marker in README.md.
  • Confirm that learner-authored text appears beneath every required marker.
  • Review each required section in docs/upstream.md.
  • Confirm that every section contains your draft text.

That is the handoff narrative complete. Your evidence now has a clear scope plus an honest upstream status.

Merge the reviewed contribution

A signed-off Git commit records your authorship attestation. This commit carries the completed handoff documents into the existing pull request.

  • Stage README.md in the source control panel you used earlier.
  • Stage docs/upstream.md in the same source control panel.
  • Use Complete the project handoff when Git asks for the commit message.
  • Record the staged handoff files in a signed-off commit by running this command:
git commit -s

What does this command do?

Git records the staged handoff files as one commit. The -s option adds your Signed-off-by trailer.

  • Review the source control panel after the commit completes.

You should see no pending changes for README.md or docs/upstream.md.

Commit did not complete?

Confirm that both handoff files were staged. If Git opened your configured editor, save the commit message before closing that editor.

If Git reports an identity problem, return to the name and email configuration you verified during setup.

Ask for help with the signed-off handoff commit.

  • Push the new ci-workflow commit from the source control panel you used earlier.

You should see the outgoing change disappear after the remote branch receives the commit.

  • Predict whether the pr check remains green after the documentation-only commit.
  • Watch the existing pull request check by running this command:
gh pr checks ci-workflow --watch

What does this check do?

The command asks GitHub CLI to find the pull request whose head branch is ci-workflow. The watch option keeps the terminal attached until the checks finish.

You should see the check named pr finish green. That confirms the safe workflow still passes with the completed handoff documents.

No green check yet?

Wait for the hosted run to start before rerunning the same command. Confirm that the current pull request still uses ci-workflow as its head branch.

If the check fails, inspect the failed step before merging. Keep the workflow restored to its SHA-pinned state with top-level contents: read.

Ask for help interpreting the pull request check.

Merging moves the reviewed commit into main. It also closes issue 1 because the pull request body contains Closes #1.

  • Use the repository's available merge method if GitHub CLI asks you to select one.
  • Confirm the merge when GitHub CLI asks for approval.
  • Merge the existing pull request by running this command:
gh pr merge ci-workflow

What happens during the merge?

GitHub CLI selects the pull request for ci-workflow. The completed contribution then becomes part of main.

The closing keyword in the pull request body links the merge to issue 1.

  • Return to issue 1 in the private repository from earlier.
  • Confirm that issue 1 now shows a closed status.

The contribution is now merged. The same merge has closed the one issue used for this project.

Merge blocked?

Confirm that the pr check is green. Repository policy can block a merge while a required check is pending or failing.

Confirm that your GitHub account has permission to merge this private repository.

Ask for help diagnosing the blocked merge.

Release and verify the handoff

A release gives the reviewed tree a stable version label. The label makes the final handoff easy to identify without changing the safe workflow.

  • Use v0.1.0 as the release title if the command asks for one.
  • Write a short summary of the safe pull request workflow if the command asks for release notes.
  • Create the release by running this command:
gh release create v0.1.0

What does this release command do?

The command creates release v0.1.0. If the tag does not exist, GitHub creates it from the default branch.

The default branch is main, which now contains the merged handoff commit.

  • Return to the repository's release page after the command completes.

You should see release v0.1.0 listed for the merged project.

Release creation failed?

Confirm that the pull request merged into main before retrying. Check whether a release or tag named v0.1.0 already exists.

Ask for help creating the release safely.

The final verification checks the privacy boundary before you record AC-8. It also confirms that the release belongs to the intended repository.

  • Predict whether the repository visibility query returns PRIVATE.
  • Verify the repository visibility by running this command:
gh repo view kind-ci-template --json visibility --jq .visibility

What does this privacy check do?

The command reads the repository's visibility field. The query prints only that field so the privacy result is easy to capture.

You should see PRIVATE. This proves the release did not change the repository visibility.

Visibility is missing or incorrect?

Confirm that GitHub CLI remains authenticated for the account that owns kind-ci-template.

If the output differs from PRIVATE, stop before recording AC-8.

Ask for help verifying repository privacy.

  • Return to the kind-ci-template GitHub Project from earlier.
  • Confirm that its visibility remains Private.
  • Return to the pull request history in the private repository.
  • Confirm that the repository contains one merged pull request from ci-workflow.
  • Review the built-in terminal history from the validation work.
  • Confirm that the exact branch names were main, ci-workflow, and planted-red.
  • Confirm that the temporary planted-red branch remains deleted.

What must AC-8 prove?

  • The one pull request is merged.
  • Issue 1 is closed.
  • Release v0.1.0 exists.
  • The required README answers and unposted upstream draft are complete.
  • The repository and Project remain private.
  • Switch back to docs/test-plan.md from earlier.
  • Add one final evidence row beneath AC-7 for AC-8.
  • Paste the observed close-out evidence into the new row.
  • Save docs/test-plan.md.

You have completed the release handoff. The repository now shows one merged contribution plus one closed issue plus release v0.1.0 inside the original privacy boundary.

  • Check your elapsed project time.
  • Stop work if the timer has reached minute 59.
  • Record the current step if any close-out task remains unfinished.

Secret mission

Catch a Permission Escalation

A safe workflow needs a guard that rejects permission escalation. Change one permission to test the guard. Use the refusal as evidence while keeping the released tree unchanged.

Clean Up Your Resources

Clean Up Your Resources

The hosted runner that created your disposable kind cluster has finished. This project now has no running infrastructure cost.

Resources you used:

  • One private GitHub repository named kind-ci-template. Its contributor trail includes issue 1. It includes the merged pull request. It includes the GitHub Actions logs. It includes release v0.1.0.
  • One private GitHub Project named kind-ci-template.
  • One local Ollama model named qwen3:1.7b.
  • One local folder named kind-ci-template.

Keep everything running

No action is needed. Choose this option if you want the released evidence ready for future reference.

  • Keep the kind-ci-template repository private.
  • Keep the kind-ci-template Project private.
  • Retain qwen3:1.7b on your laptop.
  • Keep the local kind-ci-template folder.

Pause - I'll come back to this later

Shut down the local Ollama service to free the memory used by active inference. Keep the private evidence available for your return.

  • Return to Ollama from setup.
  • Stop its local service if it is running.
  • Leave the private repository unchanged.
  • Leave the private Project unchanged.
  • Keep the local kind-ci-template folder.

You're set for a clean pause. Your project evidence remains available without active local inference.

Delete - I don't want to use this again

Remove the stored GitHub resources first. Remove the local model and project folder after your evidence is safe.

Save Your Evidence First

Deletion permanently removes your project evidence. A copy outside the project folder preserves anything you still need.

  • Save your required evidence outside the local kind-ci-template folder.
  • Return to the kind-ci-template repository on GitHub.
  • Go to the repository settings page.
  • Use the repository deletion control to delete kind-ci-template.
  • Confirm that GitHub no longer lists the repository.

What Repository Deletion Removes

Deleting kind-ci-template also removes issue 1. It removes the merged pull request.

It removes the Actions logs. It removes release v0.1.0.

  • Return to the private kind-ci-template Project on GitHub.
  • Go to the Project settings page.
  • Use the Project deletion control to delete kind-ci-template.
  • Confirm that GitHub no longer lists the Project.
  • Return to Ollama from setup.
  • Use its local model removal option to remove qwen3:1.7b.
  • Return to the local model list from setup.
  • Confirm that qwen3:1.7b is absent.

The local project folder is the final resource. Choose your operating system to remove it.

macOS

  • Press Cmd+Space to open the search bar.
  • Type Finder.
  • Press Enter to open Finder.
  • Select your home folder in the Finder sidebar.
  • Delete the kind-ci-template folder.
  • Confirm that Finder no longer lists the folder.

Windows

  • Press the Windows key to open the search bar.
  • Type File Explorer.
  • Press Enter to open File Explorer.
  • Select your user folder in the navigation pane.
  • Delete the kind-ci-template folder.
  • Empty the Recycle Bin.
  • Confirm that File Explorer no longer lists the folder.

Linux

  • Open your application launcher.
  • Open your system file manager.
  • Select your home folder.
  • Delete the kind-ci-template folder.
  • Confirm that the file manager no longer lists the folder.

That is the full teardown complete. Your project resources are gone.

Nice Work!

Nice Work!

You did it! Your private kind-ci-template repository now runs a safe pull request check on a real kind cluster through GitHub Actions. The released v0.1.0 tree is restored to its safe state.

You've learned how to:

  • Built an auditable design baseline with five documents in the first commit. Wrote the learner-owned .github/workflows/pr.yml as the project's pull request workflow.
  • Enforced least privilege with top-level contents: read. Applied SHA pinning to every workflow action. Captured unpinned: actions/checkout@v7 when the guard rejected a mutable action tag.
  • Used a planted readiness probe failure to move check pr from green to red at Wait for readiness. Returned the same pull request to green. Used local Ollama with qwen3:1.7b to name failed steps. Recorded the five-fixture score as Measured. Released v0.1.0 after merging the pull request. Kept the repository and GitHub Project private.
  • Secret Mission: Proved the workflow guard catches a permission escalation. Captured permissions: contents is write after changing only contents: read to contents: write. Restored .github/workflows/pr.yml so the released tree stayed unchanged.

Ready to quiz yourself?