A small URL shortener service written in Go.
Status: active rewrite from Python/FastAPI to Go. The repository history was reset on
mainto start fresh; seeCONTRIBUTING.mdfor the workflow.
log/slog, net/http, etc.pgx/v5.go-redis/v9.//go:embed.The toolchain assumes a Unix-like host — Linux, macOS, or
WSL2 on Windows. The Justfile recipes use bash with
set -euo pipefail, the npm scripts shell out to mkdir/cp,
and the integration suite drives a local Docker daemon. Native
Windows (cmd / PowerShell) is not supported; if you're on
Windows, develop inside WSL2.
Prerequisites:
go.mod (toolchain go1.26.x); newer is fine, older
isn't.web/. Required only when building the web assets
locally; the Dockerfile brings its own Node stage.golangci-lint v2 — auto-installed by just lint at
the version pinned in the Justfile, so a manual install is
optional.git with at least the project's full tag history (git fetch --tags); the build embeds a git describe version string
via LDFLAGS, and the changelog generator walks the tag graph.Optional but useful:
jq — for inspecting SBOMs and JSON API responses.psql / redis-cli — for poking at the local
stack outside the test harness.The repository is the single source of truth for tool versions:
Justfile pins GOLANGCI_LINT_VERSION and TRIVY_VERSION, the
Dockerfile pins Go and Node ARGs, and CI overrides via env vars
defined in .github/workflows/ci.yaml. There are no global
installs of any of these tools required — running a recipe
will install what it needs into $(go env GOPATH)/bin on demand.
just init # install husky/commitlint dev dependencies
just web-install # install npm deps for the Svelte + Vite + Tailwind toolchain
just web-build # build the SPA into web/dist/ (consumed by //go:embed)
just web-dev # vite dev server with HMR (proxies API + redirect to :8080)
just web-check # svelte-check + tsc strict type-check
just build # build ./bin/url-shortener (depends on web-build)
just test # run unit tests with -race -v -cover
just test-integration # bring up test-profile infra, migrate, run -tags=integration tests
just lint # run golangci-lint (auto-installs the pinned version)
just govulncheck # run govulncheck against the latest Go vuln database
just trivy-image # build the Docker image and scan it with Trivy (HIGH/CRITICAL)
docker compose --profile=dev up --wait -d # bring up the full local dev stack (db + redis + server on 5432/6379/8080)
just compose-smoke # smoke-check a running stack: operational endpoints + shorten/redirect cycle
docker compose --profile=dev down -v # tear down the dev stack
docker compose --profile=test down -v # tear down the test-profile stack (db-test + redis-test on 5433/6380)
The compose.yaml defines two stacks side by side: the dev profile
(db, redis, server) for local dev on standard ports, and the test
profile (db-test, redis-test) on alternate ports (5433, 6380) with
their own volumes. Both profiles must be selected explicitly with
--profile=... -- a bare docker compose up starts nothing. Running the
integration suite while a dev stack is up is therefore safe; the two
never collide. The CI compose-smoke job drives the dev profile end
to end on every PR, so anything that breaks up --wait locally also
breaks CI.
The HTML UI is embedded in the binary via //go:embed, so the compiled
assets in web/static/ must exist at go build time. They're treated as
build artifacts (gitignored): just build always runs just web-build
first so a fresh checkout works without ceremony, and the multi-stage
Dockerfile has a dedicated node stage that produces them before the
Go builder runs.
./bin/url-shortener --help # list all subcommands
./bin/url-shortener version # print version / commit / build date
./bin/url-shortener version --json
./bin/url-shortener config # print resolved config (secrets redacted)
./bin/url-shortener run # start the HTTP server (graceful shutdown on SIGINT/SIGTERM)
./bin/url-shortener migrate up # apply pending database migrations
./bin/url-shortener migrate down # roll back the most recent migration
./bin/url-shortener migrate status
./bin/url-shortener migrate up --database-url postgres://... # override URL_SHORTENER_DATABASE_URL
./bin/url-shortener healthcheck # probe /healthz; used by the docker HEALTHCHECK
All configuration comes from environment variables prefixed with
URL_SHORTENER_ (12-factor style). Defaults are tuned for production; the
local compose.yaml overrides them for development.
| Variable | Default | Description |
|---|---|---|
URL_SHORTENER_ENV |
prod |
dev or prod. Influences log-format default. |
URL_SHORTENER_ADDR |
:8080 |
TCP listen address. |
URL_SHORTENER_BASE_URL |
http://localhost:8080 |
Public origin used when building short-link URLs. |
URL_SHORTENER_LOG_LEVEL |
info |
One of debug, info, warn, error. |
URL_SHORTENER_LOG_FORMAT |
text in dev, json in prod |
text (human-readable) or json (structured). |
URL_SHORTENER_DATABASE_URL |
(empty) | Required. Postgres connection string. Redacted when printed. |
URL_SHORTENER_REDIS_URL |
(empty) | Required. Redis connection string. Redacted when printed. |
URL_SHORTENER_AUTO_MIGRATE |
false |
When true, run applies migrations before serving. Convenient for local dev / single-replica CI; production deployments should leave this off and run migrate up as a separate step. |
URL_SHORTENER_CODE_LENGTH |
7 |
Length of auto-generated short codes (base62). Must be in [4, 64]. |
URL_SHORTENER_DB_MAX_CONNS |
(pgx default: max(4, NumCPU)) | Upper bound on simultaneous Postgres connections. Set above the default to absorb burst load without queueing requests on the pool. |
URL_SHORTENER_DB_MIN_CONNS |
(pgx default: 0) | Idle connections kept warm. Useful to amortize TLS/handshake cost on bursty workloads. |
URL_SHORTENER_DB_MAX_CONN_LIFETIME |
(pgx default: 1h) | Hard cap on a connection's age. Forces rotation through floating-IP failovers and clears DB-side connection-state drift. Accepts Go duration syntax (e.g. 30m, 2h). |
URL_SHORTENER_DB_MAX_CONN_IDLE_TIME |
(pgx default: 30m) | How long a connection may sit unused before being closed. |
URL_SHORTENER_DB_HEALTH_CHECK_PERIOD |
(pgx default: 1m) | How often pgx scans the pool for stale connections. |
URL_SHORTENER_TLS_CERT_FILE |
(empty) | Path to a PEM-encoded server certificate. When set together with URL_SHORTENER_TLS_KEY_FILE, the server speaks HTTPS on URL_SHORTENER_ADDR; otherwise it stays on plain HTTP (the right choice when fronted by a TLS-terminating reverse proxy). Both must be set together -- one without the other is a startup error. See Deployment. |
URL_SHORTENER_TLS_KEY_FILE |
(empty) | Path to the PEM-encoded private key matching URL_SHORTENER_TLS_CERT_FILE. |
URL_SHORTENER_TRUSTED_PROXIES |
(empty) | Comma-separated CIDR list (e.g. 127.0.0.1/32,10.0.0.0/8). When a request's RemoteAddr falls inside one of these ranges the server walks X-Forwarded-For to find the original client IP; otherwise XFF is ignored. Empty leaves no proxy trust configured -- safe default for direct-to-internet deployments. See Reverse-proxy deployment (Caddy). |
URL_SHORTENER_RATE_LIMIT_RPS |
0 |
When > 0, enables an in-memory per-IP rate limiter on POST /api/v1/links at the given steady-state requests-per-second budget. 0 (the default) disables rate limiting; deployments fronted by an upstream limiter typically leave this off. Per-IP keying uses URL_SHORTENER_TRUSTED_PROXIES for XFF resolution. |
URL_SHORTENER_RATE_LIMIT_BURST |
(2 × RPS, min 1) | Token-bucket capacity for the rate limiter. 0 means "derive from URL_SHORTENER_RATE_LIMIT_RPS". Ignored when RATE_LIMIT_RPS=0. |
URL_SHORTENER_CORS_ALLOWED_ORIGINS |
(empty) | Comma-separated allow-list of cross-origin browser callers (e.g. https://app.example.com,https://status.example.com). Each entry must be * or an absolute scheme://host[:port] URL -- typos like example.com (no scheme) are rejected at startup. Empty (the default) leaves CORS off, which is correct for the same-origin SPA + API deployment this project ships. |
Pool tunables are zero by default, in which case pgx's own defaults apply.
Production deployments behind a fronting proxy (PgBouncer, RDS Proxy)
typically want DB_MAX_CONNS raised to match the pool's per-replica
backend cap and DB_MAX_CONN_LIFETIME lowered to a few minutes.
Run url-shortener config to print the fully resolved configuration with
passwords replaced by REDACTED.
The full HTTP contract is described by an OpenAPI 3.1 document at
api/openapi.yaml. The same document is embedded
into the binary and served at runtime, alongside two interactive
viewers:
GET /api/v1/openapi.json -- canonical machine-readable form.GET /api/v1/openapi.yaml -- the original source for humans / tools
that prefer YAML.GET /api/v1/docs -- Swagger UI
with try-it-out enabled. Vendored from swagger-ui-dist; works
offline (no CDN).GET /api/v1/redoc -- Redoc for
read-only reference browsing. Vendored from redoc; works offline.CI lints the spec on every push (just lint-openapi, backed by
Spectral with the project
ruleset at .spectral.yaml).
POST /api/v1/links
Content-Type: application/json
{
"target_url": "https://example.com/...",
"code": "optional",
"expires_at": "2026-05-01T00:00:00Z"
}
Response 201 Created for a freshly minted code, 200 OK when an existing
row was reused (see Deduplication below):
{
"code": "a1B2c3D",
"short_url": "https://your.host/r/a1B2c3D",
"target_url": "https://example.com/...",
"created_at": "2026-04-30T06:48:00Z",
"click_count": 0,
"expires_at": "2026-05-01T00:00:00Z"
}
expires_at is omitted from the response for permanent links (instead
of rendered as null), so a one-key check distinguishes them. The
field is RFC3339 in both directions; absent or null on the request
means "never expires". click_count is the lifetime hit count for
the redirect endpoint, bumped fire-and-forget on each successful
/r/:code so it never delays the 302.
| Endpoint | Purpose |
|---|---|
POST /api/v1/links |
Create a link. Auto-generates a base62 code, or accepts a user one. |
GET /api/v1/links/:code |
Fetch link metadata as JSON. |
DELETE /api/v1/links/:code |
Soft-delete a link. Returns 204 on success, 404 thereafter; no body. See below. |
GET /r/:code |
302 redirect to the link's target_url. Read-through Redis cache. |
GET /api/v1/openapi.json |
OpenAPI 3.1 document (machine-readable JSON, embedded at build time). |
GET /api/v1/openapi.yaml |
OpenAPI 3.1 document (original YAML source). |
GET /api/v1/docs |
Swagger UI (interactive, try-it-out). Self-contained, vendored. |
GET /api/v1/redoc |
Redoc (read-only reference doc). Self-contained, vendored. |
Validation: target_url must be http/https, have a host, and be at most
2048 characters. User-supplied codes must match [0-9A-Za-z]{4,64}.
expires_at, when supplied, must be in the future (a 30s grace window
absorbs honest client/server clock skew). Status codes: 400 for
malformed JSON, 409 for a duplicate user-supplied code, 422 for
validation failures, 404 for unknown codes, 410 Gone for a
retired code (expired or soft-deleted; returned by both
GET /api/v1/links/:code and GET /r/:code -- distinct from 404
so clients can tell a once-valid code from one that never existed).
Soft-delete (DELETE /api/v1/links/:code) flips an internal
deleted_at tombstone; the row stays in Postgres so audit columns
(created_at, click_count, ...) survive. Subsequent
GET /r/:code returns 410 Gone, the cache entry is invalidated
server-side, and the recent-links feed and dedup lookup both stop
seeing the row. The first DELETE returns 204 No Content; a second
DELETE against the same code returns 404 not_found -- the API is
semantically idempotent (the row stays deleted) but distinguishes
the two responses, mirroring how most REST APIs handle DELETE.
Error responses carry a stable code field alongside the
human-readable error so clients can branch on the failure without
parsing the message:
{ "error": "code already in use", "code": "code_taken" }
| HTTP | code |
When |
|---|---|---|
| 400 | invalid_json_body |
Request body is not parseable JSON. |
| 422 | validation_failed |
Input failed a validation rule (URL, code, or expiry). |
| 409 | code_taken |
The user-supplied short code is already in use. |
| 404 | not_found |
The code does not exist (either malformed or unknown). |
| 410 | link_expired |
The link existed but its expires_at has passed. |
| 410 | link_deleted |
The link existed but was soft-deleted via DELETE. |
| 500 | internal_error |
Any other server-side failure; details are logged only. |
The string values are part of the public API contract and will not change once published; new codes may be added.
Auto-generated codes are idempotent on the (normalized) target URL: a
second POST of the same destination returns the existing row with
200 OK instead of minting a new code. URLs are normalized before lookup
and storage:
:80 for http, :443 for https)/ path is removedUser-supplied codes always create a new row, even when the target is
already present elsewhere -- two codes can legitimately point at the same
destination. Dedup is best-effort under concurrent writes (no unique
constraint on target_url).
Dedup is also suppressed whenever expiry is involved on either side: a
request carrying expires_at always mints a fresh code (so an
ephemeral request never silently extends a permanent row's lifetime),
and the dedup lookup excludes rows that themselves have a non-null
expires_at (so a permanent request never reuses an expiring row).
The binary serves a single-page Svelte app at /:
POST /api/v1/links and
the result / error panels render in place without a navigation.id DESC order. Each row carries a click-count badge, an inline
expiry badge, and a Delete button.GET /api/v1/links/:code every 5s and refreshes
its click-count + expiry view in place. A row that 404s or 410s
is dropped on the next refresh of the parent list.GET /api/v1/links?before=<cursor> and
appends rows; pagination ends when next_cursor is null.Static assets are served from the embedded web/dist/ filesystem:
/ — SPA shell (index.html, Cache-Control: no-cache)/assets/* — Vite-hashed JS / CSS bundles (immutable, 1y)/static/* — vendored Swagger UI + Redoc bundles for the
/api/v1/docs and /api/v1/redoc viewers (must-revalidate, 1h)The HTTP server exposes three operational endpoints:
| Endpoint | Purpose | Behaviour |
|---|---|---|
/healthz |
Liveness probe | Always returns 200 + {"status":"ok"} while the process is responsive. No dependencies. |
/readyz |
Readiness probe | Pings every registered dependency. Returns 200 when all are healthy, 503 otherwise. |
/version |
Build metadata | Returns {"version":"...","commit":"...","date":"..."} baked into the binary at build time. |
/readyz pings Postgres and Redis -- both are mandatory runtime
dependencies (config.Validate rejects an empty
URL_SHORTENER_DATABASE_URL or URL_SHORTENER_REDIS_URL at startup).
Each check has its own line in the JSON body so operators can see
which dependency is unhappy.
The binary supports two TLS-aware deployment shapes: terminating TLS itself with a static cert + key, or speaking plain HTTP behind a TLS-terminating reverse proxy (Caddy, nginx, Traefik, ...). Pick one; they are mutually exclusive.
Set URL_SHORTENER_TLS_CERT_FILE and URL_SHORTENER_TLS_KEY_FILE to
PEM-encoded paths. Both must be set together -- a half-configured
pair is a startup error. The server then listens HTTPS on
URL_SHORTENER_ADDR and does not bind a plain-HTTP redirect listener
(callers are expected to use https:// URLs); a connection to the
HTTP scheme on the same port will fail at the TLS handshake. The
underlying crypto/tls defaults apply (TLS 1.2 minimum, modern
cipher suites, HTTP/2 enabled).
For local development, the just dev-certs recipe
generates a host-trusted cert + key under dev/certs/ (gitignored)
via mkcert. The first run
also installs mkcert's root CA into the host trust stores so
browsers and curl accept the cert without -k. Then either run
the binary directly:
just dev-certs
URL_SHORTENER_TLS_CERT_FILE=dev/certs/cert.pem \
URL_SHORTENER_TLS_KEY_FILE=dev/certs/key.pem \
URL_SHORTENER_ADDR=:8443 \
URL_SHORTENER_BASE_URL=https://localhost:8443 \
URL_SHORTENER_DATABASE_URL=postgres://... \
URL_SHORTENER_REDIS_URL=redis://... \
./bin/url-shortener run
or use the tls compose profile, which mounts ./dev/certs/ into a
distroless container and exposes the app on :8443:
just dev-certs # one-time per host
docker compose --profile=tls up --wait -d
curl https://localhost:8443/healthz # {"status":"ok"}
docker compose --profile=tls down -v
The tls profile shares the db and redis services with dev (it
just swaps server for server-tls), so the two profiles must not
run simultaneously -- they bind the same Postgres / Redis host ports.
For production, the more common pattern is to terminate TLS on a reverse proxy and forward plain HTTP to the app on a private network. The proxy handles the certificate lifecycle (ACME, OCSP, renewal), the app stays focused on its own concerns, and the same image works behind any proxy choice.
Two pieces are needed on the app side:
URL_SHORTENER_TLS_CERT_FILE / URL_SHORTENER_TLS_KEY_FILE
unset so the server stays on plain HTTP.URL_SHORTENER_TRUSTED_PROXIES to the CIDR(s) the proxy
reaches the app from. The server then walks X-Forwarded-For
from those peers to recover the real client IP (used by the
request log's remote_ip field and by c.RealIP() everywhere).
X-Forwarded-For from peers outside the list is ignored, which
prevents header spoofing from any client that can talk directly
to the app.Minimal Caddyfile mirroring the dev compose stack:
example.com {
# Caddy obtains and renews the cert from Let's Encrypt
# automatically on the first request to :443.
reverse_proxy 127.0.0.1:8080 {
# Caddy adds X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host
# to the upstream request by default; no extra header_up
# block is needed.
}
}
App-side env to match (Caddy on the same host, talking to the app over the loopback interface):
URL_SHORTENER_ADDR=:8080
URL_SHORTENER_BASE_URL=https://example.com
URL_SHORTENER_TRUSTED_PROXIES=127.0.0.1/32,::1/128
If Caddy runs in a different container on a Docker bridge network,
swap the loopback CIDR for the bridge subnet (e.g.
172.16.0.0/12,127.0.0.1/32 covers the default Docker IP ranges).
For Kubernetes, set it to the cluster's pod-CIDR plus the node-CIDR
the ingress controller forwards from.
The same shape works for nginx (proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;) and Traefik (XFF set automatically).
What matters on the app side is the URL_SHORTENER_TRUSTED_PROXIES
list -- the proxy choice is otherwise opaque.
Directories marked (present) already exist on main; the rest are added in
upcoming phases of the rewrite plan.
cmd/url-shortener/ binary entry point (present)
internal/
cli/ cobra commands (run, migrate, version, config, healthcheck) (present)
config/ viper-based env config loader (present)
buildinfo/ version / commit / date set via -ldflags (present)
server/ echo setup, middleware, lifecycle (present)
handlers/ operational, json links api, html web ui (present)
shortener/ short-code generation (present)
store/ pgx-based repository (present)
cache/ redis client wrapper (present)
migrate/ goose runner over embedded SQL (present)
migrations/ goose .sql migrations (//go:embed) (present)
web/ Svelte SPA + Go embed glue (present)
web/src/ Svelte components + API + helpers (TS) (present)
web/public/static/ vendored Swagger UI + Redoc bundles (build artifact)
web/dist/ vite production build (//go:embed target) (build artifact)
.dagger/ dagger module (Go SDK)
Pushing a v* semver tag triggers .github/workflows/release.yaml and
publishes:
ghcr.io/vancanhuit/url-shortener for
linux/amd64 + linux/arm64. Image tags match the git tag verbatim:
pushing v1.2.3 publishes :v1.2.3, and stable (non-prerelease)
tags also move :latest. Pre-releases like v1.2.3-beta1
publish only :v1.2.3-beta1 and don't move :latest.url-shortener_<version>_<os>_<arch>.tar.gz for
linux + darwin on both architectures, attached to the matching
GitHub Release alongside both per-archive <archive>.tar.gz.sha256
files and an aggregate SHA256SUMS. Verify a single archive with
sha256sum -c <archive>.tar.gz.sha256, or all of them at once with
sha256sum -c SHA256SUMS.The Release body is generated by git-cliff
from the conventional-commit history between the previous and current
semver tags, configured in cliff.toml. Preview locally
with just changelog (defaults to "since latest tag"); the rendered
markdown is written to dist/CHANGELOG.md (gitignored) and also
echoed to stdout. Install git-cliff once via the snippet at the top
of the recipe in the Justfile.
See CONTRIBUTING.md for the tag-and-push flow that produces them.
The CI workflow publishes a dev image on every push to main so you
can pull the bleeding edge without waiting for a release tag:
| Tag | When updated | Use case |
|---|---|---|
:edge |
every push to main |
floating dev pointer |
:main-<short_sha> |
every push to main |
pin to a specific commit |
Each push to main also uploads a binaries artifact to the workflow
run for users who deploy the binary directly rather than the image:
binaries-main-<short_sha> -- url-shortener_<version>_<os>_<arch>.tar.gz
for linux/darwin x amd64/arm64, plus per-archive
.tar.gz.sha256 files and an aggregate SHA256SUMS. 30-day
retention, indexed by the same short-sha as the matching :main-<short_sha>
image so the two stay in lockstep.Pull-request runs do not push to GHCR. Instead they upload two artifacts to the workflow run that you can grab from the Actions UI:
binaries-pr-<N> -- url-shortener_<version>_<os>_<arch>.tar.gz
for all four platforms, plus per-archive .tar.gz.sha256 files
and an aggregate SHA256SUMS. 7-day retention.oci-image-pr-<N> -- a single multi-arch OCI tarball; load it with
docker load -i url-shortener-oci.tar. 7-day retention.The binary embeds a git describe version string of the form
<latest_tag>-<N>-g<sha> (or just <sha> when no tags exist yet),
so url-shortener version always identifies which commit produced
a given build, regardless of where it came from.
Every image published to GHCR — tagged releases, the :edge
floating tag, and the per-commit :main-<sha> tags — ships
with two in-toto attestations stored next to the manifest:
max mode that pins
the image digest to the workflow run, the source repo, and the
commit SHA that produced it.Fetch them with docker buildx:
docker buildx imagetools inspect ghcr.io/vancanhuit/url-shortener:v1.2.3 \
--format '{{ json .SBOM }}' # or .Provenance
# Pipe the SBOM straight to a vulnerability scanner:
docker buildx imagetools inspect ghcr.io/vancanhuit/url-shortener:v1.2.3 \
--format '{{ json .SBOM.SPDX }}' | trivy sbom -
Pull-request OCI tarballs (oci-image-pr-<N>) carry the same
attestations, so reviewers can run the same commands against a
loaded image.
Branch- and tag-protection rules live in
.github/rulesets/ as native GitHub repository
rulesets, one JSON file per ruleset. The
sync-rulesets workflow
walks every file in that directory and applies it via gh api on
every push that touches the JSON, so the files in the repo are the
single source of truth. Drift introduced through the GitHub UI is
reverted on the next sync run.
main.json (target: branch)Applies to the default branch (~DEFAULT_BRANCH) and enforces:
commitlint, go (build / test / lint),
govulncheck, trivy (image scan), go (integration),
compose smoke test, binaries, docker image, analyze (go).
PR branches must be up-to-date with main before merging.allowed_merge_methods on the
pull_request rule); plain merge commits would also be rejected
by required_linear_historymainsemver-tag.json (target: tag)Applies to every tag matching refs/tags/v* (i.e. semver release tags
created by the release.yaml workflow) and enforces:
git push --delete origin v1.2.3 is rejectednon_fast_forward) — an existing tag
cannot be re-pointed at a different commitThe workflow needs a token with Administration: write on this repo;
the default GITHUB_TOKEN deliberately lacks that scope. Steps for
a repo admin:
vancanhuit/url-shortener
with Repository permissions > Administration: Read and write.RULESETS_TOKEN repository secret.sync rulesets workflow once via "Run workflow" so
the initial ruleset is applied. After that it runs automatically
whenever anything under .github/rulesets/ changes on main.# List all active rulesets on the repo:
gh api /repos/vancanhuit/url-shortener/rulesets
# Inspect the live `main` ruleset and diff it against the committed JSON.
# `jq -S` normalizes object-key order on both sides; the JSON is also
# crafted to round-trip the API's server-side defaults (e.g. the
# `pull_request` rule's `required_reviewers: []`), so a clean apply
# produces an empty diff.
gh api "/repos/vancanhuit/url-shortener/rulesets/$(\
gh api /repos/vancanhuit/url-shortener/rulesets \
--jq '.[] | select(.name=="main") | .id')" \
| jq -S '{name, target, enforcement, conditions, bypass_actors, rules}' \
| diff -u <(jq -S '{name, target, enforcement, conditions, bypass_actors, rules}' \
.github/rulesets/main.json) -
A non-empty diff means someone changed the ruleset through the UI;
either re-run the sync rulesets workflow (to revert) or update the
JSON in a PR (to adopt the change).
The ruleset rejects unsigned commits on main, so every PR must be
signed before it can merge. GPG, SSH, and S/MIME signatures all
satisfy GitHub's verification check; the snippets below cover GPG on
Linux because that's what this repo's maintainer uses. For other
platforms or methods see GitHub's docs on
signing commits.
Note: the email on your GPG UID must match a verified email on your GitHub account, otherwise the signature is mathematically valid but GitHub still shows Unverified. Check your verified emails at Settings -> Emails.
# 1. Generate an ed25519 signing key (prompts for a passphrase;
# `gpg-agent` caches it for the rest of the session).
gpg --quick-generate-key "$(git config --global user.name) <$(git config --global user.email)>" \
ed25519 sign 2y
# 2. Configure git to sign every commit / tag / rebase output.
KEY_ID=$(gpg --list-secret-keys --keyid-format=long --with-colons "$(git config --global user.email)" \
| awk -F: '/^sec/ { print $5; exit }')
git config --global gpg.format openpgp
git config --global user.signingkey "$KEY_ID"
git config --global commit.gpgsign true
git config --global tag.gpgsign true
git config --global rebase.gpgsign true
# 3. Make sure pinentry can prompt on the current TTY.
grep -q 'GPG_TTY' ~/.zshrc 2>/dev/null \
|| echo 'export GPG_TTY=$(tty)' >> ~/.zshrc
export GPG_TTY=$(tty)
# 4. Print the armored public key and paste it into
# https://github.com/settings/gpg/new
gpg --armor --export "$KEY_ID"
Verify a fresh commit shows up as Verified on the GitHub UI before merging anything that's gated by the ruleset:
git checkout -b test/gpg-signing-sanity
git commit --allow-empty -m "chore: gpg signing sanity check"
git log --show-signature -1 # should print "Good signature from ..."
git push -u origin test/gpg-signing-sanity
gh pr view --web # the commit must show "Verified"
gh pr close --delete-branch
If a PR branch was opened before signing was set up, the existing commits are unsigned and the merge will be rejected. Re-sign in place and force-push:
# Single-commit branch -- amend, then force-push.
git commit --amend --no-edit -S
git push --force-with-lease
# Multi-commit branch -- rebase --exec re-creates each commit signed.
# (`rebase.gpgsign=true` from the setup above also signs commits
# created by a plain `git rebase main`, but --exec is explicit.)
git rebase --exec 'git commit --amend --no-edit -S' main
git push --force-with-lease
error: gpg failed to sign the data ... Inappropriate ioctl for device
— GPG_TTY is unset in the current shell. Re-run
export GPG_TTY=$(tty); the snippet above also persists it.
Passphrase prompt on every commit — gpg-agent's default
cache TTL is short. Bump it in ~/.gnupg/gpg-agent.conf:
default-cache-ttl 28800
max-cache-ttl 86400
Then gpgconf --kill gpg-agent so the next commit picks up the
new TTL.
gpg: Can't check signature: No public key on older commits
— harmless; those were signed by GitHub's web-flow key,
which is just not in your local keyring. Import it once if the
warning bothers you:
curl -sS https://github.com/web-flow.gpg | gpg --import
Squash-merge — the merge commit is signed by GitHub's
web-flow key, so required_signatures is satisfied even when
squashing. Squash-merge keeps working unchanged.
Automated via Dependabot,
configured in .github/dependabot.yml. Five
ecosystems, weekly Monday cadence, grouped per ecosystem so a typical
week produces one PR per group rather than N separate PRs:
| Ecosystem | Source | Group name |
|---|---|---|
gomod |
go.mod |
gomod-minor-and-patch |
github-actions |
.github/workflows/*.yaml (SHA-pinned) |
actions-minor-and-patch |
npm (root) |
package.json (husky + commitlint) |
npm-root-minor-and-patch |
npm (web SPA) |
web/package.json |
npm-web-minor-and-patch |
docker |
Dockerfile FROM lines (golang, node, distroless) |
docker-minor-and-patch |
Major-version bumps stay outside the groups and arrive as individual
PRs; that's deliberate, since major releases (e.g. actions/checkout
v5 dropping Node 16, Go module APIs changing) often need targeted
review against the test suite. Commit messages use the chore(deps)
/ chore(deps-dev) prefix to satisfy commitlint.
main rulesetDependabot PRs flow through the same gates as any other PR:
ci.yaml jobs
(go (build / test / lint), compose smoke test, etc.) run on
every Dependabot PR; merging is blocked until they all pass.dependabot[bot] GPG key (verified by
GitHub); the squash-merge commit on main is signed by GitHub's
web-flow key (also verified). required_signatures is satisfied
end-to-end.main advances, so strict_required_status_checks_policy: true
doesn't strand them.compose.yaml imagesDependabot's docker ecosystem only scans Dockerfile, not
compose.yaml. The postgres and redis image tags in
compose.yaml therefore need a manual bump; check
them quarterly or whenever a major version of either ships. The
release-binary --version of postgres can be confirmed against the
official tags page before
a bump PR.
To be added.