Compare commits

..
3 Commits
Author SHA1 Message Date
mathiasandClaude Sonnet 5.5 c4233e3e05 fix(test): store tests take an OS-assigned free port, not a PID-derived one
CI / Lint / Test / Vet (push) Successful in 36s
CI / Build & Import (push) Successful in 11s
CI / Deploy via GitOps (push) Skipped
PID%1000 collided with a concurrent job on the shared CI host (run on
e946709: process already listening on port 54000). freePort asks the OS;
Start is retried up to 3 times if the port is taken in between.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
2026-10-10 10:56:24 +02:00
mathiasandClaude Sonnet 5.5 e946709e82 docs(claude): add Deploying section — a push does not deploy, bump the infra manifest
CI / Build & Import (push) Skipped
CI / Lint / Test / Vet (push) Failing after 40s
CI / Deploy via GitOps (push) Skipped
Migrated from cwd-keyed auto-memory (ADR-0002, local-dev#36).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 08:28:04 +02:00
mathiasandClaude Opus 5.5 a447523749 ci: move template expressions out of run: shells (signals#47)
CI / Lint / Test / Vet (push) Successful in 34s
CI / Build & Import (push) Successful in 11s
CI / Deploy via GitOps (push) Successful in 6s
"Derive image tags" expanded ${{ github.ref }} into the run: body. A
ref name may contain $(...) and the expansion happens before the shell
parses the script, so it now arrives via env: REF instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:33:15 +02:00
4 changed files with 84 additions and 19 deletions
+3 -1
View File
@@ -53,10 +53,12 @@ jobs:
- name: Derive image tags
id: meta
env:
# signals#47: a ref name can carry $(...); pass it via env, not ${{ }}.
REF: ${{ github.ref }}
run: |
SHA=$(git rev-parse --short HEAD)
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
REF="${{ github.ref }}"
if [[ "$REF" == refs/tags/v* ]]; then
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
fi
+17
View File
@@ -27,6 +27,23 @@ docs it indexes.
- **Language is Go (ADR-001).** Do not introduce Python or a second language. If you think you
need one, that's a new ADR with a real justification, not a default.
## Deploying: a push does not deploy
Tapir runs on the koala k3s cluster under Flux GitOps. Pushing here only builds the image;
`kubectl set image` is reverted by Flux drift correction within seconds. The manifest bump is
manual: Flux image-automation in `mathias/infra` covers only `supervisor`, not tapir (infra#111
is closed, but `k3s/flux/flux-system/image-automation.yaml` has no tapir policy).
1. CI (`.gitea/workflows/ci.yml`) builds and pushes `localhost:5000/tapir:<short-sha>` on the
main push.
2. In `mathias/infra`, bump `image:` in `k3s/apps/tapir/deployment.yaml` to that sha; commit to
`main` (infra's own rules and full `task check` apply).
3. On koala: `flux reconcile kustomization apps --with-source`, then
`kubectl -n tapir rollout status deploy/tapir`. Both need per-action approval in a session.
4. Confirm the running image is the new sha and the startup log shows the new code.
Runbook and why: brain `homelab/failures/flux-reverts-kubectl-set-image-deploy-via-git`.
## Things that look reusable but are NOT — read before "reusing"
These caused real mistakes that were caught and corrected; the corrections are load-bearing.
+19
View File
@@ -0,0 +1,19 @@
package store_test
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/require"
)
// TestMain's Postgres already holds a port. A second instance started in the
// same process must still come up, i.e. the port is not derived from something
// two starters share (the PID). Observes: startTestPostgres succeeds while
// another instance from this process is listening.
func TestStartTestPostgres_SecondInstanceStartsWhileFirstHoldsItsPort(t *testing.T) {
pg, _, rt, err := startTestPostgres(fmt.Sprintf("tapir-epg-store-second-%d", os.Getpid()))
require.NoError(t, err)
t.Cleanup(func() { _ = pg.Stop(); _ = os.RemoveAll(rt) })
}
+45 -18
View File
@@ -3,6 +3,7 @@ package store_test
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"testing"
@@ -25,27 +26,15 @@ var _ ports.Sink = (*store.Store)(nil)
var dsn string
func TestMain(m *testing.M) {
// Port + runtime/data dirs are per-process (PID-derived) so two concurrent
// `go test` invocations — e.g. a push-run and a tag-run firing together in CI —
// don't collide on a fixed port or a shared data dir (which silently failed
// both runs). CachePath is shared so the PG archive is downloaded once, not
// per process. Base 54000 keeps this package's range distinct from web's.
port := uint32(54000 + os.Getpid()%1000)
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-store-%d", os.Getpid()))
pg := embeddedpostgres.NewDatabase(
embeddedpostgres.DefaultConfig().
Port(port).
RuntimePath(rt).
DataPath(filepath.Join(rt, "data")).
BinariesPath(filepath.Join(rt, "bin")).
CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")),
)
if err := pg.Start(); err != nil {
// Runtime/data dirs are per-process (PID-named) and the port is OS-assigned,
// so concurrent `go test` runs on a shared CI host don't collide on either.
// CachePath is shared so the PG archive is downloaded once, not per process.
pg, port, rt, err := startTestPostgres(fmt.Sprintf("tapir-epg-store-%d", os.Getpid()))
if err != nil {
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
os.Exit(1)
}
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
code := m.Run()
@@ -56,6 +45,44 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
// freePort asks the OS for an unused TCP port. The port is released before
// return, so another process can grab it first; startTestPostgres retries.
func freePort() (uint32, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, fmt.Errorf("listen for free port: %w", err)
}
defer func() { _ = l.Close() }()
return uint32(l.Addr().(*net.TCPAddr).Port), nil
}
// startTestPostgres starts an embedded Postgres on an OS-assigned free port and
// returns it with that port and its runtime dir (caller removes it). A port
// taken between freePort and Start (shared CI host) is retried with a new one.
func startTestPostgres(name string) (*embeddedpostgres.EmbeddedPostgres, uint32, string, error) {
rt := filepath.Join(os.TempDir(), name)
var lastErr error
for range 3 {
port, err := freePort()
if err != nil {
return nil, 0, rt, err
}
pg := embeddedpostgres.NewDatabase(
embeddedpostgres.DefaultConfig().
Port(port).
RuntimePath(rt).
DataPath(filepath.Join(rt, "data")).
BinariesPath(filepath.Join(rt, "bin")).
CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")),
)
if lastErr = pg.Start(); lastErr == nil {
return pg, port, rt, nil
}
lastErr = fmt.Errorf("start on port %d: %w", port, lastErr)
}
return nil, 0, rt, lastErr
}
// uuids — fixed so tests are deterministic. user_id/video_id are UUID columns.
const (
userA = "11111111-1111-1111-1111-111111111111"