Seagull in CI

On this page

On SQLite:

  • go tool sgl runs the sgl that go.mod's tool directive pins (see ../go.mod and workflow.yml)
  • generate --check exits 1 when the committed code is stale; adding --diff prints what would change
  • vet --format github prints ::warning and ::error workflow commands; --format sarif prints SARIF 2.1.0
  • migrate up and validate run against file:ci.db, a SQLite file the job throws away

Generated code is committed next to the queries it comes from, so a pull request can change one and forget the other. A CI job closes that gap with three checks: the committed code matches what sgl generate would write, the queries have no problems, and the migrations still apply. This example is those checks, run first as a terminal session and then as a GitHub Actions workflow you can copy.

Pinning sgl

The job runs go tool sgl, not an sgl found on the path. go get -tool seagull.sh/seagull/cmd/sgl adds a tool line to go.mod, and go tool sgl then runs exactly the version go.mod pins, on your machine and in CI alike. Upgrading sgl becomes an ordinary dependency change, reviewed like any other.

The change under review

The project is a tasks table whose titles are unique. The pull request edits CreateTask so that adding a title that already exists does nothing instead of failing:

change/tasks.sql

-- sgl.query(CreateTask, one)
INSERT INTO tasks (title) VALUES (?) ON CONFLICT (title) DO NOTHING RETURNING id, title, done;

It does not run sgl generate, so internal/store still holds the old SQL. The edit also brings a subtle problem of its own: when the title is taken, the insert is skipped, RETURNING yields no row, and a one query then returns sql.ErrNoRows. Seagull warns about exactly this with Q046. Adding optional: true to the header makes the method return (row, false, nil) on a conflict instead, as Optional one shows.

The session

go tool sgl generate --check generates in memory and compares the result with the files on disk, writing nothing. Before the change it reports that the code is up to date and exits 0.

cp change/tasks.sql db/queries/tasks.sql plays the part of the pull request.

go tool sgl generate --check --diff now fails. It first prints the Q046 warning, since generating checks every query, then a unified diff of what sgl generate would change in internal/store/tasks.sql.go, then the files it would modify. The exit status is 1 because the code is stale; the warning alone would not fail it. The diff lands in the job log, so a reviewer sees the effect of the change without checking out the branch.

go tool sgl vet --format github prints each diagnostic as a GitHub Actions workflow command, ::warning file=...,line=...,col=...,title=Q046::.... GitHub turns these into annotations on the lines of the pull request. Without --strict a warning still exits 0.

go tool sgl vet --format sarif prints the same findings as SARIF 2.1.0: a rule for each code, with its description and a link to its documentation, and a result for each finding. GitHub code scanning reads SARIF, and so do many other tools.

go tool sgl migrate up --dsn file:ci.db applies the migrations to a SQLite file the job throws away, so a migration that does not run fails here rather than in production. go tool sgl migrate validate --dsn file:ci.db then checks that each applied migration still matches its file.

Here is the session as recorded. Each $ line is a command, the lines after it are what it printed, and # exit N is its exit status.

# sgl is a tool of this module (the `tool seagull.sh/seagull/cmd/sgl` line in go.mod),
# so CI runs the version go.mod pins. The committed code matches the queries:
$ go tool sgl generate --check
tasks: generated code is up to date
# exit 0

# A pull request changes a query but forgets to run sgl generate.
$ cp change/tasks.sql db/queries/tasks.sql
# exit 0

# --check fails the build; --diff shows the reviewer what generate would change.
$ go tool sgl generate --check --diff
db/queries/tasks.sql:1:1: warning[Q046]: query "CreateTask" uses command "one" with ON CONFLICT DO NOTHING and RETURNING; a conflict skips the insert so RETURNING yields no row and the call returns the driver no-rows sentinel (sql.ErrNoRows, or pgx.ErrNoRows for native pgx). Add optional: true to return (row, false, nil), use command "exec"/"execrows", or handle errors.Is with that sentinel.
   |
 1 | -- sgl.query(CreateTask, one)
   | ^
--- a/internal/store/tasks.sql.go
+++ b/internal/store/tasks.sql.go
@@ -4,7 +4,7 @@
 
 import "context"
 
-const createTaskSQL = `INSERT INTO tasks (title) VALUES (?1) RETURNING id, title, done;`
+const createTaskSQL = `INSERT INTO tasks (title) VALUES (?1) ON CONFLICT (title) DO NOTHING RETURNING id, title, done;`
 
 type CreateTaskRow struct {
 	ID    int64  `json:"id"`
sgl: database "tasks": generated code in internal/store is out of date; run `sgl generate`
  modify internal/store/tasks.sql.go
# exit 1

# The change also has a warning. GitHub Actions turns these lines into annotations on
# the pull request.
$ go tool sgl vet --format github
::warning file=db/queries/tasks.sql,line=1,col=1,title=Q046::query "CreateTask" uses command "one" with ON CONFLICT DO NOTHING and RETURNING; a conflict skips the insert so RETURNING yields no row and the call returns the driver no-rows sentinel (sql.ErrNoRows, or pgx.ErrNoRows for native pgx). Add optional: true to return (row, false, nil), use command "exec"/"execrows", or handle errors.Is with that sentinel.
# exit 0

# SARIF carries the same findings to GitHub code scanning and other tools.
$ go tool sgl vet --format sarif
{
  "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
  "version": "2.1.0",
  "runs": [
    {
      "tool": {
        "driver": {
          "name": "sgl",
          "version": "dev",
          "informationUri": "https://seagull.sh",
          "rules": [
            {
              "id": "Q046",
              "shortDescription": {
                "text": "A conflict that skips the row, with RETURNING on a one query"
              },
              "fullDescription": {
                "text": "A `one` query combines a conflict clause that skips the row, such as `ON CONFLICT DO NOTHING`, MariaDB's `INSERT IGNORE` or SQLite's `INSERT OR IGNORE`, with `RETURNING`. When the conflict occurs the insert is skipped and no row is returned, so the generated method returns the driver's no-rows error (`sql.ErrNoRows`, or `pgx.ErrNoRows` for pgx) on that path.\n\nAdd `optional: true` to the header to receive `(row, false, nil)` instead, use `exec` or `execrows`, or handle the no-rows error explicitly with `errors.Is`:\n\n```sql\n-- sgl.query(CreateTag, one, optional: true)\nINSERT INTO tags (name) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id;\n```"
              },
              "helpUri": "https://seagull.sh/docs/reference/diagnostics#q046",
              "defaultConfiguration": {
                "level": "warning"
              }
            }
          ]
        }
      },
      "results": [
        {
          "ruleId": "Q046",
          "level": "warning",
          "message": {
            "text": "query \"CreateTask\" uses command \"one\" with ON CONFLICT DO NOTHING and RETURNING; a conflict skips the insert so RETURNING yields no row and the call returns the driver no-rows sentinel (sql.ErrNoRows, or pgx.ErrNoRows for native pgx). Add optional: true to return (row, false, nil), use command \"exec\"/\"execrows\", or handle errors.Is with that sentinel."
          },
          "locations": [
            {
              "physicalLocation": {
                "region": {
                  "startLine": 1,
                  "startColumn": 1
                },
                "artifactLocation": {
                  "uri": "db/queries/tasks.sql"
                }
              }
            }
          ]
        }
      ]
    }
  ]
}
# exit 0

# Apply the migrations to a throwaway database, then check the files still match what
# was applied.
$ go tool sgl migrate up --dsn file:ci.db
up 1 init
1 applied
# exit 0
$ go tool sgl migrate validate --dsn file:ci.db
migrations are valid
# exit 0

The workflow

workflow.yml runs the same checks on every pull request and every push to main. Copy it to .github/workflows/ in your repository.

workflow.yml

# Copyright 2026 PolitePixels Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This project stands against fascism, authoritarianism, and all forms of
# oppression. We built this to empower people, not to enable those who would
# strip others of their rights and dignity.

name: sgl

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  security-events: write

jobs:
  sgl:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod

      - name: Generated code is up to date
        run: go tool sgl generate --check --diff

      - name: Queries have no problems
        run: go tool sgl vet --strict --format github

      - name: Migrations apply and validate
        run: |
          go tool sgl migrate up --dsn file:ci.db
          go tool sgl migrate validate --dsn file:ci.db

      - name: Write SARIF for code scanning
        if: always()
        run: go tool sgl vet --format sarif > sgl.sarif || true

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: sgl.sarif

The vet step adds --strict, so in CI the Q046 warning fails the job; drop the flag if you would rather warnings only annotate. The SARIF steps run with if: always(), and || true keeps a finding from stopping the upload, so code scanning gets the report even when an earlier step failed. Uploading SARIF needs the security-events: write permission the workflow asks for.

The CLI reference lists the other flags: --dry-run lists the files that would change without printing a diff, and --database limits a run to one database of sgl.yaml.

Try it

Copy change/tasks.sql over the query file, add optional: true to its header and run the checks again: vet --strict is clean, but generate --check still fails until you run sgl generate and commit what it writes.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/tooling/042_ci_pipeline && go run seagull.sh/seagull/cmd/sgl vet

The files

Every file of the example, as it is in the Seagull repository.

sgl.yaml

# Copyright 2026 PolitePixels Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This project stands against fascism, authoritarianism, and all forms of
# oppression. We built this to empower people, not to enable those who would
# strip others of their rights and dignity.

version: 1

databases:
  - name: tasks
    dialect: sqlite
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store

db/migrations/0001_init.up.sql

CREATE TABLE tasks (
    id    INTEGER PRIMARY KEY,
    title TEXT    NOT NULL UNIQUE,
    done  INTEGER NOT NULL DEFAULT 0
);

db/migrations/0001_init.down.sql

DROP TABLE tasks;

db/queries/tasks.sql

-- sgl.query(CreateTask, one)
INSERT INTO tasks (title) VALUES (?) RETURNING id, title, done;

-- sgl.query(ListTasks, many)
SELECT id, title, done FROM tasks ORDER BY id;

change/tasks.sql

-- sgl.query(CreateTask, one)
INSERT INTO tasks (title) VALUES (?) ON CONFLICT (title) DO NOTHING RETURNING id, title, done;

-- sgl.query(ListTasks, many)
SELECT id, title, done FROM tasks ORDER BY id;

workflow.yml

# Copyright 2026 PolitePixels Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This project stands against fascism, authoritarianism, and all forms of
# oppression. We built this to empower people, not to enable those who would
# strip others of their rights and dignity.

name: sgl

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  security-events: write

jobs:
  sgl:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod

      - name: Generated code is up to date
        run: go tool sgl generate --check --diff

      - name: Queries have no problems
        run: go tool sgl vet --strict --format github

      - name: Migrations apply and validate
        run: |
          go tool sgl migrate up --dsn file:ci.db
          go tool sgl migrate validate --dsn file:ci.db

      - name: Write SARIF for code scanning
        if: always()
        run: go tool sgl vet --format sarif > sgl.sarif || true

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: sgl.sarif

internal/store/models.go

// Code generated by sgl. DO NOT EDIT.

package store

type Tasks struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	Done  int64  `json:"done"`
}

internal/store/querier.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"context"
	"database/sql"
)

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

type dbtxWrapper interface {
	WrapDBTX(inner any) any
}

type Queries struct {
	reader  DBTX
	writer  DBTX
	wrapper dbtxWrapper
}

func New(db DBTX) *Queries {
	wrapper, _ := db.(dbtxWrapper)
	return &Queries{reader: db, writer: db, wrapper: wrapper}
}

func NewWithReplica(writer DBTX, reader DBTX) *Queries {
	wrapper, _ := writer.(dbtxWrapper)
	return &Queries{reader: reader, writer: writer, wrapper: wrapper}
}

func (queries *Queries) WithTx(transaction *sql.Tx) *Queries {
	if queries == nil {
		return &Queries{reader: transaction, writer: transaction, wrapper: nil}
	}
	db := queries.wrapDBTX(transaction)
	return &Queries{reader: db, writer: db, wrapper: queries.wrapper}
}

func (queries *Queries) wrapDBTX(db DBTX) DBTX {
	if queries.wrapper == nil {
		return db
	}
	wrapped, ok := queries.wrapper.WrapDBTX(db).(DBTX)
	if !ok {
		return db
	}
	return wrapped
}

func (queries *Queries) RunInTx(ctx context.Context, db *sql.DB, fn func(*Queries) error) error {
	transaction, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer transaction.Rollback()
	if err = fn(queries.WithTx(transaction)); err != nil {
		return err
	}
	return transaction.Commit()
}

internal/store/tasks.sql.go

// Code generated by sgl. DO NOT EDIT.

package store

import "context"

const createTaskSQL = `INSERT INTO tasks (title) VALUES (?1) RETURNING id, title, done;`

type CreateTaskRow struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	Done  int64  `json:"done"`
}

func (queries *Queries) CreateTask(ctx context.Context, title string) (CreateTaskRow, error) {
	var row CreateTaskRow
	err := queries.writer.QueryRowContext(ctx, createTaskSQL, title).Scan(&row.ID, &row.Title, &row.Done)
	if err != nil {
		return CreateTaskRow{}, err
	}
	return row, nil
}

const listTasksSQL = `SELECT id, title, done FROM tasks ORDER BY id;`

type ListTasksRow struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	Done  int64  `json:"done"`
}

func (queries *Queries) ListTasks(ctx context.Context) ([]ListTasksRow, error) {
	rows, err := queries.reader.QueryContext(ctx, listTasksSQL)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []ListTasksRow
	if rows.Next() {
		var row ListTasksRow
		scanArgs := []any{&row.ID, &row.Title, &row.Done}
		for more := true; more; more = rows.Next() {
			row = ListTasksRow{}
			if err := rows.Scan(scanArgs...); err != nil {
				return nil, err
			}
			results = append(results, row)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

transcript.txt

# sgl is a tool of this module (the `tool seagull.sh/seagull/cmd/sgl` line in go.mod),
# so CI runs the version go.mod pins. The committed code matches the queries:
$ go tool sgl generate --check
tasks: generated code is up to date
# exit 0

# A pull request changes a query but forgets to run sgl generate.
$ cp change/tasks.sql db/queries/tasks.sql
# exit 0

# --check fails the build; --diff shows the reviewer what generate would change.
$ go tool sgl generate --check --diff
db/queries/tasks.sql:1:1: warning[Q046]: query "CreateTask" uses command "one" with ON CONFLICT DO NOTHING and RETURNING; a conflict skips the insert so RETURNING yields no row and the call returns the driver no-rows sentinel (sql.ErrNoRows, or pgx.ErrNoRows for native pgx). Add optional: true to return (row, false, nil), use command "exec"/"execrows", or handle errors.Is with that sentinel.
   |
 1 | -- sgl.query(CreateTask, one)
   | ^
--- a/internal/store/tasks.sql.go
+++ b/internal/store/tasks.sql.go
@@ -4,7 +4,7 @@
 
 import "context"
 
-const createTaskSQL = `INSERT INTO tasks (title) VALUES (?1) RETURNING id, title, done;`
+const createTaskSQL = `INSERT INTO tasks (title) VALUES (?1) ON CONFLICT (title) DO NOTHING RETURNING id, title, done;`
 
 type CreateTaskRow struct {
 	ID    int64  `json:"id"`
sgl: database "tasks": generated code in internal/store is out of date; run `sgl generate`
  modify internal/store/tasks.sql.go
# exit 1

# The change also has a warning. GitHub Actions turns these lines into annotations on
# the pull request.
$ go tool sgl vet --format github
::warning file=db/queries/tasks.sql,line=1,col=1,title=Q046::query "CreateTask" uses command "one" with ON CONFLICT DO NOTHING and RETURNING; a conflict skips the insert so RETURNING yields no row and the call returns the driver no-rows sentinel (sql.ErrNoRows, or pgx.ErrNoRows for native pgx). Add optional: true to return (row, false, nil), use command "exec"/"execrows", or handle errors.Is with that sentinel.
# exit 0

# SARIF carries the same findings to GitHub code scanning and other tools.
$ go tool sgl vet --format sarif
{
  "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
  "version": "2.1.0",
  "runs": [
    {
      "tool": {
        "driver": {
          "name": "sgl",
          "version": "dev",
          "informationUri": "https://seagull.sh",
          "rules": [
            {
              "id": "Q046",
              "shortDescription": {
                "text": "A conflict that skips the row, with RETURNING on a one query"
              },
              "fullDescription": {
                "text": "A `one` query combines a conflict clause that skips the row, such as `ON CONFLICT DO NOTHING`, MariaDB's `INSERT IGNORE` or SQLite's `INSERT OR IGNORE`, with `RETURNING`. When the conflict occurs the insert is skipped and no row is returned, so the generated method returns the driver's no-rows error (`sql.ErrNoRows`, or `pgx.ErrNoRows` for pgx) on that path.\n\nAdd `optional: true` to the header to receive `(row, false, nil)` instead, use `exec` or `execrows`, or handle the no-rows error explicitly with `errors.Is`:\n\n```sql\n-- sgl.query(CreateTag, one, optional: true)\nINSERT INTO tags (name) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id;\n```"
              },
              "helpUri": "https://seagull.sh/docs/reference/diagnostics#q046",
              "defaultConfiguration": {
                "level": "warning"
              }
            }
          ]
        }
      },
      "results": [
        {
          "ruleId": "Q046",
          "level": "warning",
          "message": {
            "text": "query \"CreateTask\" uses command \"one\" with ON CONFLICT DO NOTHING and RETURNING; a conflict skips the insert so RETURNING yields no row and the call returns the driver no-rows sentinel (sql.ErrNoRows, or pgx.ErrNoRows for native pgx). Add optional: true to return (row, false, nil), use command \"exec\"/\"execrows\", or handle errors.Is with that sentinel."
          },
          "locations": [
            {
              "physicalLocation": {
                "region": {
                  "startLine": 1,
                  "startColumn": 1
                },
                "artifactLocation": {
                  "uri": "db/queries/tasks.sql"
                }
              }
            }
          ]
        }
      ]
    }
  ]
}
# exit 0

# Apply the migrations to a throwaway database, then check the files still match what
# was applied.
$ go tool sgl migrate up --dsn file:ci.db
up 1 init
1 applied
# exit 0
$ go tool sgl migrate validate --dsn file:ci.db
migrations are valid
# exit 0