sethvargo

Stars and mentions

Last 30 days, Oct 5 – Nov 3

GitHub stars1
mentions2
01234Oct 5Oct 19Nov 3
UTC days · repositories sethvargo owns1 repositories

Package downloads

No package download history available yet.

Top repositories

sethvargo’s repositories ranked by new forks, as on trending.

Loading

Contributes to

Repositories outside sethvargo’s account where sethvargo opened or reviewed pull requests in the past 365 days.

Who noticed sethvargo

Everyone who starred or forked sethvargo’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

2posts
Nov 40 posts
  1. What Are GitHub Actions Lockfiles, and Do They Replace SHA Pinning? Your SDK release workflow runs when someone pushes a version tag. Its steps read actions/checkout@v4, actions/setup-node@v4, and actions/attest-build-provenance@v1. Each @v4 or @v1 is a git ref in another team's repository. The commit it points to can change after your team reviewed the workflow, and nothing in your repository records which commit was reviewed. The usual fix is to pin every action to a full commit SHA, as the Lullabot architecture decision record requires. Teams keep those pins current with Renovate, Dependabot, pinact, or ratchet, and frizbee converts tags to SHAs. That fixes the lines you can see. Andrew Nesbitt pointed out the gap: a SHA pin locks only the outer action, and a helper action it calls by tag can still change. GitHub's workflow dependency lockfile moves pinning into one machine-generated file per repository. For each onboarded workflow, it records the ref, the resolved commit, and the source repository's identity, transitive actions included. For repositories that adopt it, GitHub says SHA pinning in workflow files is no longer necessary. The guarantee stops at known edges: the dependencies inside reusable workflows you call, container images pulled by tag, and the safety of the pinned code. Reviewers still read every lock diff. For one release workflow on github.com, a bounded pilot is a reasonable next step, unless you run GitHub Enterprise Server or depend mostly on reusable workflows you do not control. TL;DR - A lockfile at .github/workflows/actions.lock records each onboarded workflow's dependencies, their commits, and their source repositories' IDs, transitive actions included. - For repositories that adopt a lockfile, GitHub says inline SHA pinning is no longer necessary. Keep SHAs wherever no lockfile reaches. - Generating the file, onboarding a workflow, running --verify in CI, and the Require lockfile policy are four separate controls. Only the policy blocks runs of workflows nobody onboarded. - Your lockfile does not control the dependencies inside a reusable workflow you call, and container images pulled by tag are not locked. - The feature and its pre-1.0 CLI are in preview, and GHES is not supported. Pilot one workflow before you require locking. What changed, and what is still preview On October 14, 2026, GitHub put workflow lockfiles into public preview for GitHub Enterprise Cloud (including data residency), Team, and Free plans. Each of GitHub's lockfile pages, starting with the concept page, says the feature is in public preview and subject to change. The announcement now explicitly lists Team and Free, with an editor's note correcting plan availability. The setup guide still labels the feature GitHub Enterprise Cloud. Use the updated announcement for preview eligibility, and confirm which policy controls are available for your target repository. The gh-actions-lock README says the tool is pre-1.0, that its file format, flags, and behavior may change between releases, and that GitHub Enterprise Server is not supported. The test examples below describe v0.2.0, published October 12, 2026, as tested on October 15, 2026. As of October 23, 2026, the latest published release is v0.3.0, released October 22 at 16:08 UTC. It changes local-action handling, adds container-image warnings, and tightens verification. The historical examples are retained unchanged; we have not rerun them with v0.3.0. What the lockfile records Each repository gets one lockfile at .github/workflows/actions.lock. You can onboard workflows to it individually or all at once. The concept page says the file should never be edited by hand. The gh actions-lock extension generates it, which requires write access. The file has two sections. workflows lists the dependencies each onboarded workflow uses. dependencies holds one entry per dependency, with three kinds of field: - ref is the tag or branch the workflow names. - commit is the code that will run, written sha1-.... - owner_id and repo_id let GitHub confirm at run time that the repository behind the name is still the one you locked. Transitive actions appear as a uses: list under the parent entry, as in the lockfile in the extension's own repository. That file has no integrity or content hash field, only the commit (our reading; GitHub does not state it). Once a workflow is onboarded, every action it uses must be in the lockfile. The README describes job-start checks of source-repository identity, the pinned commit, and full-version tags. The released CLI distinguishes a rename or transfer that preserves the repository ID, which produces a warning, from a different repository behind the name, which produces a blocking finding. Its v0.2.0 README says a failed check blocks the run. This describes the documented checks; malformed files and CLI/runtime differences need the separate caveats below. A test run on one release workflow We onboarded the release workflow from the opening in a test run with gh-actions-lock v0.2.0 (gh 2.102.0, Linux). We ran the release binary directly. We did not exercise hosted run-time enforcement, the Require lockfile policy, or Dependabot. The original release.yml: name: release on: push: tags: ['v*'] permissions: contents: read id-token: write attestations: write jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci && npm pack - uses: actions/attest-build-provenance@v1 with: subject-path: '*.tgz' GitHub's how-to installs the extension with gh extension install github/gh-actions-lock. Onboarding one workflow takes its path: gh actions-lock .github/workflows/release.yml It exited 0 and printed: Scanning 1 workflow Planning pins Pinned 3 actions (+ 2 transitive) across 1 workflow actions/checkout@v4.4.0 (11d5960) └─ .github/workflows/release.yml actions/setup-node@v4.4.0 (49933ea) └─ .github/workflows/release.yml actions/attest-build-provenance@v1.4.4 (ef24412) └─ .github/workflows/release.yml Transitive dependencies (from composite actions): actions/attest-build-provenance@36fa7d0 via actions/attest-build-provenance@v1.4.4 actions/attest@67422f5 via actions/attest-build-provenance@v1.4.4 It also rewrote the workflow: @v4 became @v4.4.0, and @v1 became @v1.4.4. The two transitive entries come from inside the composite actions/attest-build-provenance; no workflow line names them. The excerpt below is machine-generated output from the test run, copied verbatim. Each # … marks lines we cut. # This file is machine-generated by `gh actions-lock`. # Do not edit by hand; run `gh actions-lock` to update. # Docs: https://gh.io/actions-lockfile version: 'v0.0.3' workflows: '.github/workflows/release.yml': - 'actions/attest-build-provenance@v1.4.4' - 'actions/checkout@v4.4.0' - 'actions/setup-node@v4.4.0' dependencies: # … 'actions/attest-build-provenance@v1.4.4': ref: 'v1.4.4' commit: 'sha1-ef244123eb79f2f7a7e75d99086184180e6d0018' owner_id: 44036562 repo_id: 760702757 uses: - 'actions/attest-build-provenance@36fa7d009e22618ca7cd599486979b8150596c74' - 'actions/attest@67422f5511b7ff725f4dbd6fb9bd2cd925c65a8d' 'actions/attest@67422f5511b7ff725f4dbd6fb9bd2cd925c65a8d': ref: 'v1.4.1' commit: 'sha1-67422f5511b7ff725f4dbd6fb9bd2cd925c65a8d' owner_id: 44036562 repo_id: 760701061 # … With the lockfile committed, gh actions-lock --verify printed "All 1 workflow valid" and exited 0. We then appended - uses: actions/upload-artifact@v4 without relocking, and --verify exited 1: Scanning 1 workflow 1 of 1 workflow failed ✗ Not pinned actions/upload-artifact@v4 used in workflow but not pinned in lockfile (actions/upload-artifact@v4) see: https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions ↳ .github/workflows/release.yml Re-run without --verify to apply fixes. The offline --verify-local also exited 1, with "1 of 1 workflow failed: 1 not-pinned." Its flag help says it only checks that every action has an entry, not that the entries are trustworthy. That makes it a fit for a pre-commit hook, while GitHub's docs use --verify in CI. Direct, transitive, and reusable workflows: where the boundary sits The concept page draws the boundary at the repository: its lockfile covers its onboarded workflows and transitive actions, but the actions inside a called reusable workflow belong to the defining repository's lockfile. Your release workflow's direct steps land in your file, and so do the actions inside its composite actions. The dependencies inside a reusable workflow it calls do not. The lockfile of the repository that defines that reusable workflow covers them, if that repository has one. Our test matched the dependency boundary but recorded less than the concept page describes. That page says the lockfile pins the reusable workflows your workflows depend on, too. We onboarded a workflow whose only dependency was a call to slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0. The tool reported "All 1 workflow valid" and left the file unchanged, and the caller's lockfile recorded '.github/workflows/release.yml': [], with no entry for the call. The how-to recommends onboarding repositories that hold shared workflows first. In v0.2.0, resolvable same-repository local uses: ./… actions are rewritten to uses: $/…, which always resolves to the running commit, though that release's README also lists local-path actions as a short-term gap. The v0.3.0 README says local ./ actions are never followed or locked. A local action that uses remote actions now blocks fix and verify by default: explicitly migrate it with --migrate-local-actions, or accept warning-only treatment with --ignore-local-actions. The latter leaves that local-action dependency path unlocked. The linked concept and setup pages do not explain container-image coverage. The CLI source warns that a docker://image:tag step falls outside lockfile coverage because image tags can move. Do lockfiles replace inline SHA pinning? For repositories that adopt one, GitHub says yes. The concept page says SHA pinning in workflow files "is no longer necessary." The secure use reference keeps full-length SHA pinning as the guidance for repositories that cannot adopt a lockfile. The condition applies per workflow. Keep full-length SHA pins on workflows you have not onboarded and on remote job-level reusable-workflow calls in v0.2.0, even when the callee repository has its own lockfile. That callee lockfile governs the actions inside the called workflow, not the caller's job-level ref. A GitHub maintainer confirms that job-level calls are not pinned. Keep separate image-digest pins on docker:// dependencies. The SHA policy keeps working. GitHub's page on governing workflow execution says you can leave "Require actions to be pinned to a full-length commit SHA" enabled. For locked dependencies, GitHub checks the locked commit. During a staged rollout, the policy still enforces SHA pinning for workflows that have not adopted the lockfile. Onboarding does change existing pins. In our test, actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 became actions/checkout@v4.4.0 on a default run, with the commit moved into the lockfile. With --no-narrow, the SHA stayed in the workflow file, which is the fix a maintainer gave in issue #130. If your organization allows only selected actions, the how-to warns that allowlist entries pinned to a tag stop matching. Use a wildcard or the full commit SHA instead. Inline SHA pinning and a lockfile, side by side | Criterion | Inline full-length SHA pinning | Lockfile (onboarded workflow) | | --- | --- | --- | | Where the pin lives | Each uses: line in each workflow file | One actions.lock per repository; workflow lines carry a version tag unless you use --no-narrow | | Transitive actions | Whatever the composite action's author wrote | Recorded under the parent entry, except inside reusable workflow calls | | Repository identity | Not recorded | owner_id and repo_id, checked at job start | | What a reviewer sees | A changed SHA on each edited line | One diff with ref, commit, IDs, and transitive edges | | Enforcement | Full-length SHA policy | Job-start checks, --verify as a required check, Require lockfile policy, SHA policy on the locked commit | | Update automation | Renovate, Dependabot, pinact, or ratchet | Dependabot regenerates lock entries; Renovate documents experimental regeneration of an existing lockfile. Neither onboards new workflows | | Container images by tag | Not covered by action pins | Not locked | | Availability | GitHub's guidance for repositories that cannot adopt a lockfile | Public preview on Enterprise Cloud (including data residency), Team, and Free per the updated announcement; no GHES. Confirm target-repository policy availability | File, onboarding, CI check, and policy are four different controls A lockfile in the repository does not prove that every workflow is governed. | Control | What it does | Availability | On failure | | --- | --- | --- | --- | | Generating the file | Pins each resolvable action and writes the lockfile | People with write access, through the gh extension | All or nothing: if it hits an error it cannot fix, it changes no workflow and no lockfile entry | | Onboarding a workflow | Adds it to workflows; every dependency must then be locked | Individually or all at once | Documented job-start checks block failures; malformed-file fail-closed behavior requires the Require lockfile policy. We did not test hosted enforcement | | --verify in CI | Read-only CLI validation for coverage and pin checks; use it as a required check, not as proof of identical hosted enforcement | Any CI job that runs the CLI | Exits 1 (our test); blocks merges only as a required check | | Require lockfile policy | Blocks runs of targeted workflows that use actions but are not onboarded | Public repositories, and private repositories on Team or Enterprise; Evaluate mode only on GitHub Enterprise | Blocks runs in Active mode; Evaluate reports to Policy insights; we did not test this | Until the policy targets it, a workflow nobody onboarded runs with whatever refs its file contains. The enforcement guide covers both the policy rollout and the verify job. The workflow-policy page lists public repositories and private repositories on Team or Enterprise; private Free repositories are not listed. Malformed lockfiles are a separate case. A GitHub maintainer says the Require lockfile policy supplies fail-closed behavior when the file cannot be reliably read. Onboarding and a CLI check alone are not equivalent to an active runtime policy. CLI/runtime parity also needs a version boundary. In issue #174, the maintainer reported a backend bypass repair on October 16. On October 21, the maintainer said the CLI fix was on main and planned for the next release. The historical v0.2.0 verification code has the full-SHA ancestry-check limitation. That repair has now shipped: v0.3.0 rejects fork-only impostor commits and checks locked commits against the history of their recorded tag or branch. Its release notes also describe incomplete-lockfile failures for older lock graphs, exact-tag/commit mismatch errors, failures when GitHub cannot answer a pin check, and refusal to write cyclic or incomplete lock graphs. Follow the release-specific repair instructions and review the resulting diff before adopting it. We checked the release notes and v0.3.0 verification source; we did not reproduce hosted enforcement or rerun the CLI with this version. What a reviewer approves on a lock update The enforcement guide sets the condition: "A lockfile is only a security control if someone reads the diff." On each lock pull request, check: - Each changed commit, which is the code your release will run. The how-to treats it like any dependency version bump. - Each changed owner_id or repo_id, which the how-to calls rare and worth investigating before you merge. - New entries in a uses: list, which are transitive actions nobody typed into a workflow. - The workflows section, to see whether a workflow dropped out. A normal run trusts refs such as main or v3 from the lockfile and does not re-resolve them. If upstream force-pushed or deleted a locked commit, --relock errors. Accepting the new commit takes gh actions-lock --relock --accept-moved, and that change deserves its own review. For onboarded workflows, Dependabot regenerates the matching lockfile entry in the same pull request as the version update. It does not onboard workflows; it reports that onboarding is required. Beyond having Dependabot manage GitHub Actions dependencies, no configuration is needed. GitHub notes that this maintains coverage rather than extending it. Renovate also documents experimental regeneration of an existing actions.lock after action updates; workflows not yet onboarded are skipped. Neither tool onboards new workflows automatically. The cited guides do not specify a minimum Dependabot version. The case for staying with SHA pins and Dependabot The objection: "We already pin to SHAs and have Dependabot; this is a preview that adds another file and another failure mode." SHA pins plus Dependabot already fix and update every direct reference. The preview adds a pre-1.0 CLI, workflow rewrites, allowlist changes, CodeQL alerts, and early policy bugs. In the proposal thread, one commenter wrote in May 2026 that they would not use it instead of SHA pinning until it worked with Dependabot or Renovate. For direct references the objection holds, because an inline SHA and a lock entry fix the same commit. Our inference from the documented behavior is that lockfiles address three gaps left by inline SHAs, within the coverage and enforcement limits above: - Transitive actions. - Repository-name reuse that points to a different repository identity. - A run-time check that covered dependencies are locked, with the active Require lockfile policy supplying fail-closed behavior for malformed files. Dependabot now regenerates lock entries, and Renovate documents experimental regeneration for existing lockfiles, which addresses the update-integration objection. --no-narrow keeps SHAs in the workflow file. The pre-1.0 CLI, allowlist changes, and CodeQL alerts still argue for testing on one workflow first. Early-adopter reports These come from the issue tracker, not from GitHub's documented behavior. - Issue #130 (closed): fix mode rewrote commit pins into tags. The maintainer pointed to --no-narrow. - Issue #131 (open): after a lockfile rewrite, CodeQL flags the new tag references as unpinned. - Issue #168 (closed October 21): the report describes how a locked Docker container action, or a docker:// step inside a locked composite action, still pulls the image its action.yml names, usually by tag. Moving that tag changes the image without a lockfile diff. v0.3.0 now emits non-blocking container-image warnings for docker:// steps, job containers and services, and remote Docker actions when images lack a SHA-256 digest, including expression-selected images. Those warnings do not pin images. The no-warning observation is the issue author's historical report, not a description of the latest release. - Issue #169 (closed): with the SHA-pinning policy on, version-tagged actions run through a reusable workflow were rejected despite a valid lockfile. A GitHub maintainer said a backend fix should address it, and the issue is closed. What a lockfile does not establish The changelog says locking "does not determine whether the dependency itself is safe," so review dependencies before you lock them. The secure use reference still says to audit the source code of every action you use. The remaining boundaries combine the CLI's documented limits with our inference about what a commit lock can govern. The lock records commits without a content hash, while the earlier third-party gh-actions-lockfile records integrity hashes. Code an action downloads at run time is outside the lock, a point raised in the proposal thread. So are images pulled by tag, the runner image, workflow permissions, and secret handling. Checklist: pilot one SDK or API release workflow 1. Inventory every uses: line: direct and composite actions, reusable workflows and their repositories, local ./ actions, and image-based actions. 2. Onboard the repositories that hold reusable workflows you control first. Keep the caller's remote job-level reusable-workflow refs pinned to full-length SHAs even when those repositories have lockfiles. Record repositories you do not control as out of scope. 3. Onboard only the release workflow. Add --no-narrow if you want SHAs kept in the file. We expect that to make the pilot easier to back out, but we did not test a rollback. 4. Review every commit, owner_id, repo_id, and transitive entry in the generated lockfile. 5. Move tag-based allowlist entries to a wildcard or full SHA, and decide how to triage CodeQL alerts. 6. Require CODEOWNERS review of .github/workflows/actions.lock, with a named owner. 7. Make a gh actions-lock --verify CI job a required check. For pre-commit, the enforcement guide uses gh actions-lock --verify-local --no-fix. 8. Keep Dependabot on GitHub Actions dependencies, and keep the full-length SHA policy enabled. 9. On GitHub Enterprise, run the Require lockfile policy in Evaluate mode, scoped to this workflow. GitHub advises switching to Active only after you have dependable lockfile-maintenance processes for every targeted workflow. 10. Exit test over the next few releases: each release ran onboarded, --verify failed only on real drift, every lock diff had a named reviewer, and a Dependabot update arrived with its lock entry. Defer the pilot if any of these apply: - You run GitHub Enterprise Server. - You depend mostly on reusable workflows you do not control. - You rely on Docker-image-based actions that you need pinned. - Nobody owns lock diffs. If you defer, keep SHA pins with Dependabot and revisit after the CLI reaches 1.0. To start, follow GitHub's guide to locking action dependencies in a repository's workflows, then the guide to enforcing lockfiles when you are ready for the policy. FAQ Will a lockfile break my existing SHA-pinned actions? The commit you pinned stays. A default run rewrites the line to its version tag and moves the commit into the lockfile; --no-narrow keeps the SHA in the workflow. Tag-based allowlist entries stop matching, and CodeQL may flag the rewritten tags. Can I keep the full-length commit SHA policy enabled? Yes. For locked dependencies, GitHub evaluates the policy against the locked commit. Workflows that are not onboarded still need full-length SHAs in the workflow file. Will Dependabot continue to update my actions? For onboarded workflows, Dependabot regenerates the matching lockfile entry in its version-update pull request. It does not onboard new workflows. Does my lockfile cover the reusable workflows I call? Not their dependencies. The actions inside a reusable workflow are covered by the lockfile of the repository where it is defined, so GitHub recommends onboarding shared-workflow repositories first. GitHub's concept page says reusable workflows themselves are pinned, but in our v0.2.0 test the caller's lockfile recorded no entry for the call. Keep remote job-level calls SHA-pinned even when the called repository has its own lockfile.

  2. Building MarginCMS, part 2: A Go server generated from the spec In part 1 I wrote the OpenAPI contract for MarginCMS and planned the backend. In this part the Go service comes to life: a server generated from the spec, config from environment variables, stub handlers that fail properly, and a Docker Compose setup that runs the API, the docs and a mock server with one command. It also includes a CORS bug where my server logged every request and the browser still showed nothing. That one gets its own section. Why generate the server at all? The contract is the only thing my wife's frontend and my backend share. If the Go handlers are written by hand, they'll drift from the spec sooner or later: a renamed field, a missing status code, a query parameter parsed as the wrong type. With a generated server, the handlers implement an interface that comes straight from the spec. When the spec changes, I regenerate, and the compiler tells me exactly what's out of date. Step 1: Choose the server and generate it from the spec Commit: api: generate Go server and types from the OpenAPI spec chi I picked chi as the router: - I've used it before, so it's one less new thing. - It's a thin layer on top of net/http. Handlers are plain http.Handlers, and anything from the standard library works with it. - It has a good community and plenty of middleware and packages, including the CORS package that shows up later in this post. oapi-codegen oapi-codegen turns the bundled spec into Go code. I generate four files, each from its own small config: | Config | Generates | | --- | --- | | models.yaml | Request and response types | | server.yaml | A chi server and a strict server interface | | client.yaml | A typed Go client, useful for tests later | | server_urls.yaml | Constants for the server URLs in the spec | The strict server is the important part. Instead of func(w http.ResponseWriter, r *http.Request), each operation becomes a method with typed input and typed output: func (s server) GetHealth(ctx context.Context, request api.GetHealthRequestObject) (api.GetHealthResponseObject, error) The generated code parses the path, query, headers and body, calls my method, and writes the response. My code never touches http.Request. The return type is an interface that the generated per-status response types implement, so returning a response the spec doesn't define takes deliberate effort instead of happening by accident. Empty string or no field at all? A few years ago I worked with code generated from proto3 messages with plain string fields. Without the optional keyword (generally available only since protobuf 3.15) or wrapper types like StringValue, proto3 can't tell the difference between a client sending "title": "" and not sending title at all. Both arrived as an empty string. For a PATCH endpoint that's a real problem: "clear the excerpt" and "don't touch the excerpt" look the same. oapi-codegen handles this the Go way. Optional fields become pointers: type PostUpdate struct { Body *string `json:"body,omitempty"` Excerpt *string `json:"excerpt,omitempty"` Slug *string `json:"slug,omitempty"` Status *PostStatus `json:"status,omitempty"` Tags *[]string `json:"tags,omitempty"` Title *string `json:"title,omitempty"` } nil means "not sent", and a pointer to "" means "set it to empty" (for excerpt and body; title has minLength: 1). That's exactly what the PATCH semantics need. One caveat: an explicit "excerpt": null also decodes to nil, so "not sent" and "sent as null" look the same. That's fine here because none of these fields are nullable. If I ever need the difference, oapi-codegen's nullable-type output option generates nullable.Nullable[T] for it. Tools pinned with go tool My first version installed the generator with go install ...@latest. That works on my machine today, but it means anyone cloning the repo next year gets a different generator than the one that produced the committed code. Since Go 1.24, go.mod can track tools the same way it tracks dependencies: tool ( github.com/air-verse/air github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen ) Now go tool oapi-codegen always runs the exact version in go.mod, and the root Makefile uses it: openapi-generate-go: cd api && go tool oapi-codegen --config=../openapi/oapi-codegen/server.yaml ../openapi/dist/openapi.bundled.yaml The trade-off is that the tools' dependencies show up in go.mod as indirect requirements. They don't end up in the API binary, but the file gets longer. I'm fine with that in exchange for one source of truth for versions. Step 2: Config, stubs and a health check Commit: api: add server skeleton with config, stub handlers and health check Config from the environment Config comes from environment variables, mapped onto structs with go-envconfig. Each package owns its own config struct, and the top-level config just composes them: // internal/server/config.go type Config struct { Addr string `env:"SERVER_ADDR, default=:8080"` ShutdownTimeout time.Duration `env:"SERVER_SHUTDOWN_TIMEOUT, default=30s"` ReadHeaderTimeout time.Duration `env:"SERVER_READ_HEADER_TIMEOUT, default=5s"` // ... } // internal/config/config.go type Config struct { Server *server.Config } When the database and auth packages arrive, they'll add their own Config next to their code instead of one giant struct growing in a single file. There's also a test that loads the config with no SERVER_* variables set and compares it with the expected defaults. It sounds trivial, but it's already caught me twice: once when I added the CORS and timeout fields, and once when I lowered a timeout default. Defaults are part of the behaviour, so they deserve a test. It also makes me look at .env.example every time a default changes, so the code and the docs don't drift apart. main.go main.go stays small: load the config, build the generated handler, wrap it, and run an http.Server with timeouts and graceful shutdown: ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() cfg := config.New(ctx) serverInterface := api.NewStrictHandlerWithOptions(server.New(), server.NewServerMiddlewares(), server.NewServerOptions()) handler := api.HandlerWithOptions(serverInterface, api.ChiServerOptions{BaseURL: "/api/v1"}) On SIGINT or SIGTERM the server stops accepting new connections and gives in-flight requests up to SERVER_SHUTDOWN_TIMEOUT to finish. 501 for everything that isn't built yet The generated interface has 17 methods, and I've implemented one. The stubs for the rest started out as panic("implement me"). Go's HTTP server recovers from the panic, but the client gets a dropped connection and my log gets a stack trace. Not great when the docs page has a "Try it" button. 501 Not Implemented is the honest answer. The catch is that the strict server only lets a method return the responses the spec defines, and the spec has no 501. It doesn't need one, though: any error a method returns goes to a configurable ResponseErrorHandlerFunc. So every stub returns a sentinel error: var ErrNotImplemented = errors.New("not implemented") func (s server) ListPosts(ctx context.Context, request api.ListPostsRequestObject) (api.ListPostsResponseObject, error) { return nil, ErrNotImplemented } and the error handler maps it to a status code and writes a problem+json body using the Problem type generated from the spec: func responseErrorHandler(w http.ResponseWriter, r *http.Request, err error) { status := http.StatusInternalServerError if errors.Is(err, ErrNotImplemented) { status = http.StatusNotImplemented } else { log.Printf("%s %s %s", r.Method, r.URL.Path, err) } writeProblem(w, status) } A nice side effect: real errors returned from handlers now also come back as a clean 500 in the same format, and the internal error message is logged instead of being sent to the client. Request-decoding errors still get oapi-codegen's default plain-text 400 for now. $ curl http://localhost:8080/api/v1/posts {"status":501,"title":"Not Implemented","type":"about:blank"} Health check GET /api/v1/healthz returns {"status":"OK"}. That's a stub for now. Once there's a database, it'll check that the app's dependencies are reachable and healthy, so Docker and any future deployment can tell a running process from a working one. Step 3: One command for the whole dev environment Commit: tooling: add Docker Compose dev environment docker compose up starts three services: | Service | Port | What it is | | --- | --- | --- | | api | 8080 | The Go API, rebuilt on every save by Air | | apidocs | 8081 | redocly preview: the API docs with a "Try it" console | | apimock | 8082 | Prism, serving mock responses from the spec | The api container mounts the source code and runs go tool air, so Air is pinned in go.mod like the generator. Two named volumes keep the Go module cache and the build cache (which holds the compiled Air binary) between container restarts. Without them, every recreated container downloaded all modules and compiled Air from scratch before serving a single request. Do I still need Prism? While setting this up, I noticed that the Redocly preview also has a built-in mock server behind its "Try it" console. So is Prism redundant? Not for us. The Redocly mock is great for trying endpoints from the docs page. Prism is a standalone server on a fixed port that the React app can use as its backend while my endpoints are still returning 501. It also validates requests against the spec and supports the Prefer header, so the frontend can ask for a specific response, like Prefer: code=412 for the edit-conflict case. That's what the frontend needs, so Prism stays. Step 4: The request that arrived and never came back Commit: api: add CORS middleware With everything running, I opened the docs on localhost:8081, pointed the "Try it" console at my real API on localhost:8080, and called the health check. The API logged the request. The docs page showed no response at all. Part one: CORS The server did its job. The browser threw the response away. A different port is a different origin, so a page on :8081 calling :8080 makes a cross-origin request. A plain GET without custom headers is a "simple" request, so the browser sends it straight away without a preflight. My handler runs and returns 200. But the response has no Access-Control-Allow-Origin header, so the browser refuses to let the page read it. The devtools console says so, if you think to look there. The fix is the go-chi/cors middleware. One detail matters: it has to run before chi's routing, so I wrap the whole handler at the net/http level instead of adding it to the strict server's middleware list. A preflight OPTIONS request doesn't match any generated route (chi answers 405 or 404 itself), so per-operation middleware never sees it. handler = cors.Handler(cors.Options{ AllowedOrigins: cfg.Server.AllowedOrigins, AllowedHeaders: cfg.Server.AllowedHeaders, AllowedMethods: cfg.Server.AllowedMethods, AllowCredentials: cfg.Server.AllowCredentials, })(handler) Part two: * means "all methods" in the spec, but not in go-chi/cors I added CORS, set everything to * in my local env to get going, and still got nothing. Calling the API with curl and an Origin header showed the middleware was running, since the response had Vary: Origin, but there was still no Access-Control-Allow-Origin. That surprised me, because * is a valid value for the Access-Control-Allow-Methods header: for requests without credentials, it means "all methods". The protocol was fine; the library was the problem. Reading the go-chi/cors source explained it. In its options, * is a wildcard for AllowedOrigins and AllowedHeaders, but not for AllowedMethods. There, * is compared literally against the request method, as if it were a method called *. On top of that, go-chi/cors checks the method on the actual request too, not only on the preflight, which the CORS spec doesn't ask for. My GET was a simple request with no preflight, and GET wasn't in my list, so it got no CORS headers at all. A preflighted request wouldn't have fared better: the preflight checks the requested method against the same list, and on a mismatch it still answers 200 OK, just without any CORS headers. Even a library that passed * through wouldn't have saved this config, because I also had AllowCredentials: true. For requests with credentials (cookies, TLS client certificates or HTTP authentication), browsers treat * in Access-Control-Allow-Methods and Access-Control-Allow-Headers as a literal name. That setting had a problem of its own, which is part three. Listing the methods explicitly fixed it: SERVER_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS" Part three: don't ship the permissive version While I was there, I noticed a config that only worked by accident. AllowedOrigins: * with AllowCredentials: true makes go-chi/cors send Access-Control-Allow-Origin: * next to Access-Control-Allow-Credentials: true, a combination browsers reject for credentialed requests. MarginCMS uses bearer tokens and no cookies, so it doesn't need credentials at all, and there's no reason to let every origin in either. The defaults are now strict: AllowedOrigins []string `env:"SERVER_ALLOWED_ORIGINS, default=http://localhost:8081"` AllowedMethods []string `env:"SERVER_ALLOWED_METHODS, default=GET,POST,PUT,PATCH,DELETE,OPTIONS"` AllowedHeaders []string `env:"SERVER_ALLOWED_HEADERS, default=Authorization,Content-Type"` AllowCredentials bool `env:"SERVER_ALLOW_CREDENTIALS, default=false"` If it works locally with *, that's a sign to tighten it, not a sign that you're done. What I'd tell myself before starting - When the server says 200 and the client sees nothing, the browser is the one dropping the response. Start debugging there. - Read the source of small middleware packages. go-chi/cors is a few hundred lines, and five minutes of reading beat an hour of guessing. - Pin your code generators. Generated code is only reproducible if the generator version is. - Test your defaults. It's the cheapest test you'll write and it keeps config and docs honest. Next steps 1. Add a linter. golangci-lint with a config that's strict from day one, while there's still almost no code to fix. 2. Start on auth with mock data. Login, token refresh, logout and GET /me, backed by an in-memory user for now, so the auth flow and its tests exist before the database does. In part 3 the linter came first and turned into a dependency hygiene problem: every Go tool moved into its own module, then came a strict golangci-lint config and request validation against the spec. Auth moved to part 4. --- Originally published on my blog, where the comments live. The code for this post is on GitHub.

Achievements

Awards earned by the repositories sethvargo owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.