Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: cd

on:
push:
branches: [main]

jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest

env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}

steps:
- name: Check out code
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.26.0"

- name: Install goose
run: go install github.com/pressly/goose/v3/cmd/goose@latest

- name: Build production binary
run: ./scripts/buildprod.sh

- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_CREDENTIALS }}

- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2
with:
project_id: notely-501812

- name: Build and push Docker image
run: gcloud builds submit --tag us-central1-docker.pkg.dev/notely-501812/notely-ar-repo/notely:latest .

- name: Run database migrations
run: ./scripts/migrateup.sh

- name: Deploy to Cloud Run
run: gcloud run deploy notely --image us-central1-docker.pkg.dev/notely-501812/notely-ar-repo/notely:latest --region us-central1 --allow-unauthenticated --project notely-501812 --max-instances=4
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: ci

on:
pull_request:
branches: [main]

jobs:
tests:
name: Tests
runs-on: ubuntu-latest

steps:
- name: Check out code
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.26.0"

- name: Run tests
run: go test ./... -cover

- name: Install gosec
run: go install github.com/securego/gosec/v2/cmd/gosec@latest

- name: Run gosec
run: gosec ./...

style:
name: Style
runs-on: ubuntu-latest

steps:
- name: Check out code
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25.1"

- name: Check formatting
run: test -z $(go fmt ./...)

- name: Install staticcheck
run: go install honnef.co/go/tools/cmd/staticcheck@latest

- name: Run staticcheck
run: staticcheck ./...
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ out
.env
learn-cicd-starter
notely
CLOUD_BUILD_SETUP.md
56 changes: 56 additions & 0 deletions LEARNINGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Learnings — Learn CI/CD (Notely)

Notes from Boot.dev’s Learn CI/CD course, applied to this Notely Go app.

## Continuous Integration (CI)

- **Trigger on PRs, not only on merge.** The `ci` workflow runs on `pull_request` to `main`, so broken tests or style issues are caught before merge.
- **Separate concerns into parallel jobs.** `tests` and `style` run as independent jobs on `ubuntu-latest`. Failures are clearer, and the pipeline finishes faster when they run in parallel.
- **Pin the toolchain.** `actions/setup-go` with an explicit `go-version` keeps local and CI builds aligned.
- **Tests belong in CI.** `go test ./... -cover` runs on every PR. A failing test (intentionally or not) blocks the pipeline — that feedback loop is the point.
- **Style is automated, not optional.**
- `go fmt`: `test -z $(go fmt ./...)` fails if any file would be reformatted.
- `staticcheck`: catches bugs and smell that the compiler misses.
- **Security scanning in CI.** `gosec ./...` looks for common Go security issues. Treat findings as build failures, then fix them (don’t just silence the tool).
- **Status badges.** A badge in the README (e.g. for `ci.yml`) makes pipeline health visible without opening the Actions tab.

## Continuous Deployment (CD)

- **Deploy on push to `main`.** The `cd` workflow assumes `main` is the release branch: merge → build → ship.
- **Build for the target platform.** `scripts/buildprod.sh` cross-compiles with `CGO_ENABLED=0 GOOS=linux GOARCH=amd64` so the binary runs in a Linux container even if you develop on macOS.
- **Keep the image thin.** The Dockerfile is a slim Debian image that only adds the prebuilt `notely` binary and CA certs — no Go toolchain in the runtime image.
- **Migrations before (or with) deploy.** Run Goose (`./scripts/migrateup.sh`) in CD so the schema is ready before the new Cloud Run revision serves traffic.
- **Cap scale early.** `--max-instances=4` on Cloud Run limits surprise cost while learning.

## Secrets & config

- **Never commit secrets.** `.env` is gitignored. CI/CD reads `DATABASE_URL` and `GCP_CREDENTIALS` from GitHub Actions secrets.
- **Same app, different envs.** Locally, missing `DATABASE_URL` means “no DB mode.” In CD, the secret must be set or migrations and CRUD fail.
- **Service account JSON in Actions.** `google-github-actions/auth` with `credentials_json` from a secret authenticates `gcloud` without interactive login.

## Google Cloud pieces

- **Artifact Registry** stores the Docker image (`…/notely-ar-repo/notely:latest`).
- **Cloud Build** builds and pushes from the repo (`gcloud builds submit --tag …`).
- **Cloud Run** runs the container (`gcloud run deploy … --allow-unauthenticated` for a public demo).
- **IAM matters.** The default Compute Engine service account needs roles like Cloud Build builder and Storage object viewer on the Cloud Build bucket — otherwise you get errors such as `Permission 'storage.objects.get' denied`. Fixing IAM is part of making CD work, not optional ops trivia. See `CLOUD_BUILD_SETUP.md` for the exact bindings used here.

## Workflow habits that stuck

1. Open a PR → watch CI (tests, fmt, staticcheck, gosec).
2. Merge to `main` → CD builds Linux binary → Cloud Build image → migrate DB → deploy Cloud Run.
3. Prefer small, reversible steps: add a CI step, confirm it fails correctly, then make it pass.
4. Document one-off GCP/IAM fixes so the next deploy doesn’t start from “why is permission denied?”

## Stack at a glance

| Layer | Choice |
| --- | --- |
| App | Go + Chi, Turso/libSQL, Goose migrations |
| CI | GitHub Actions (`ci.yml`) |
| CD | GitHub Actions (`cd.yml`) → GCP |
| Runtime | Cloud Run + Artifact Registry |

## Takeaway

CI is the automated gate on every change; CD is the automated path from a green `main` to a running service. Most of the friction was not YAML syntax — it was making tests honest, keeping secrets out of git, and giving GCP service accounts the right permissions end to end.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
![CI status](https://github.com/gcoria/learn-cicd-starter/actions/workflows/ci.yml/badge.svg)

# learn-cicd-starter (Notely)

This repo contains the starter code for the "Notely" application for the "Learn CICD" course on [Boot.dev](https://boot.dev).
Expand All @@ -21,3 +23,5 @@ go build -o notely && ./notely
*This starts the server in non-database mode.* It will serve a simple webpage at `http://localhost:8080`.

You do *not* need to set up a database or any interactivity on the webpage yet. Instructions for that will come later in the course!

Gasta's version of Boot.dev's Notely app
3 changes: 2 additions & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import (

var ErrNoAuthHeaderIncluded = errors.New("no authorization header included")

// GetAPIKey -
// GetAPIKey

func GetAPIKey(headers http.Header) (string, error) {
authHeader := headers.Get("Authorization")
if authHeader == "" {
Expand Down
20 changes: 20 additions & 0 deletions internal/auth/get_api_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package auth

import (
"net/http"
"testing"
)

func TestGetAPIKey(t *testing.T) {
header := http.Header{}
header.Set("Authorization", "ApiKey test")

apiKey, err := GetAPIKey(header)
if err != nil {
t.Fatalf("Failed to get API key: %v", err)
}
if apiKey != "test" {
t.Fatalf("Expected API key to be 'test', got '%s'", apiKey)
}

}
5 changes: 4 additions & 1 deletion json.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,8 @@ func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) {
return
}
w.WriteHeader(code)
w.Write(dat)
_, err = w.Write(dat)
if err != nil {
log.Printf("Error writing JSON: %s", err)
}
}
28 changes: 23 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package main
import (
"database/sql"
"embed"
"errors"
"io"
"log"
"net/http"
"os"
"strconv"
"time"

"github.com/go-chi/chi"
"github.com/go-chi/cors"
Expand All @@ -27,12 +30,23 @@ var staticFiles embed.FS
func main() {
err := godotenv.Load(".env")
if err != nil {
log.Printf("warning: assuming default configuration. .env unreadable: %v", err)
if errors.Is(err, os.ErrNotExist) {
log.Println("warning: .env file not found, using environment defaults")
} else {
log.Printf("warning: .env unreadable, using environment defaults: %v", err)
}
}

port := os.Getenv("PORT")
if port == "" {
log.Fatal("PORT environment variable is not set")
port = "8080"
}
portNum, err := strconv.Atoi(port)
if err != nil {
log.Fatalf("invalid PORT: %v", err)
}
if portNum < 1 || portNum > 65535 {
log.Fatalf("invalid PORT: port must be between 1 and 65535, got %d", portNum)
}

apiCfg := apiConfig{}
Expand Down Expand Up @@ -89,10 +103,14 @@ func main() {

router.Mount("/v1", v1Router)
srv := &http.Server{
Addr: ":" + port,
Handler: router,
Addr: ":" + strconv.Itoa(portNum),
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}

log.Printf("Serving on port: %s\n", port)
log.Printf("Serving on port: %d", portNum)
log.Fatal(srv.ListenAndServe())
}
2 changes: 1 addition & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</head>

<body class="section">
<h1>Notely</h1>
<h1>Welcome to Notely</h1>

<div id="userCreationContainer" class="section">
<input id="nameField" type="text" placeholder="Enter your name">
Expand Down