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