Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot c4c2cf3af4 quak backup writes each original's EXIF, XMP and dimensions into its JSON (closes #167)
check / check (push) Successful in 2m14s
Each file's JSON gains imageMetadata, what extractImageMetadata finds in
the stored original (for a live photo, its image), or the reason the
read failed in imageMetadataError; a failed read fails neither the file
nor the run. A video is not read. An original is read when the run
stores it or when its JSON has neither field; otherwise the field is
carried over from that JSON, so a run does not read every original
again. The hand-built JPEG fixtures move to test/exif-jpeg.ts so the
backup tests can use them.

Judgement call: an original with nothing to record gets imageMetadata
{} instead of no field, so it is not read again on every run.

Model: opus-5-5
2026-10-06 05:52:09 +00:00
21 changed files with 180 additions and 505 deletions
+42 -75
View File
@@ -1,86 +1,53 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with # Mirrors .gitignore, with one deliberate exception: .gitignore itself stays
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross # in the build context, because prettier 3 reads it as a default ignore file
# `/` and an unprefixed pattern is anchored at the context root. Every # and dropping it would change what the lint phase's prettier check sees.
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
# #
# Matching is case-sensitive, so secrets use character ranges rather # .git is deliberately NOT excluded: the build derives the version it stamps
# than an ALL-CAPS twin, which would still miss `Server.Key`. # from it (script/version). It is sent without its config, which holds the
# # clone's remote URL and any credential in it, and which the build stage, the
# Extend with this repo's own host-built artifacts, written anchored: # final image, would otherwise carry. git describe does not need it.
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and .git/config
# deletes the package directory from the context.
# .git is sent without its config. Without a VERSION build argument the # OS
# stage that compiles runs `git describe --tags --always` on .git, which .DS_Store
# does not need .git/config; that file can hold a credential, such as a Thumbs.db
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent. # Editors
# Anchored because it occurs once where agents run at the repo root. *.swp
# KNOWN GAP: a repo running agents in subdirectories still ships *.swo
# `services/api/.claude/` and must add its own anchored entry. *~
.claude *.bak
.idea/
.vscode/
*.sublime-*
# Environment files. `*.env` covers bare `.env` and the `prod.env` # Node
# convention. Re-include a committed template with a negation if the node_modules
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates # TypeScript / build artifacts
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. dist
**/*.[pP][eE][mM] build
**/*.[kK][eE][yY] *.tsbuildinfo
**/*.[pP]12 coverage
**/*.[pP][fF][xX] .nyc_output/
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in. # Vitest
**/node_modules .vitest-cache/
# OS metadata. # Environment / secrets
**/.DS_Store .env
**/Thumbs.db .env.*
*.pem
# Editor state: never a build input, and it churns COPY. *.key
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# TypeScript / build artifacts: the image compiles its own.
/dist
/build
/*.tsbuildinfo
/coverage
/.nyc_output
/.vitest-cache
# Compiled binary (built by make build-bin); around 100 MB # Compiled binary (built by make build-bin); around 100 MB
/bin/quak bin/quak
# quak runtime data (in case anyone runs the CLI from inside the repo) # quak runtime data (in case anyone runs the CLI from inside the repo)
/.quak .quak/
# Local per-developer tool state, including agent worktrees. Correctness,
# not context size: a worktree copied in here has its own test/ tree, which
# vitest globs alongside the real one, so the containerised suite runs N+1
# times over and still reports success.
.claude/
+10 -32
View File
@@ -11,41 +11,9 @@ Thumbs.db
.vscode/ .vscode/
*.sublime-* *.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node # Node
node_modules/ node_modules/
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# TypeScript / build artifacts # TypeScript / build artifacts
dist/ dist/
build/ build/
@@ -56,8 +24,18 @@ coverage/
# Vitest # Vitest
.vitest-cache/ .vitest-cache/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Compiled binary (built by make build-bin) # Compiled binary (built by make build-bin)
bin/quak bin/quak
# quak runtime data (in case anyone runs the CLI from inside the repo) # quak runtime data (in case anyone runs the CLI from inside the repo)
.quak/ .quak/
# Local per-developer tool settings and scratch state, including the
# worktrees agents check out under this directory
.claude/
+3
View File
@@ -1,2 +1,5 @@
node_modules/ node_modules/
yarn.lock yarn.lock
dist/
build/
coverage/
-2
View File
@@ -58,8 +58,6 @@ COPY --from=test /app/package.json /dev/null
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN script/bootstrap RUN script/bootstrap
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /app
COPY . . COPY . .
+42 -41
View File
@@ -137,15 +137,16 @@ alpine. We provide:
- `script/lint` — run eslint and a prettier check, by building the `lint` phase - `script/lint` — run eslint and a prettier check, by building the `lint` phase
of the `Dockerfile`; requires docker (see Linting and testing below) of the `Dockerfile`; requires docker (see Linting and testing below)
- `script/fmt` — format all files with prettier (writes) - `script/fmt` — format all files with prettier (writes)
- `script/fmt-check` — check formatting on the host (read-only) - `script/fmt-check` — check formatting on the host (read-only); standalone, and
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own not called by `script/check` or `script/precommit`, because `script/lint`
extension) already checks formatting in the container
- `script/check` — run all checks: `test`, `lint` (our own extension)
- `script/docker` — build the image, tagged via `script/projectname` - `script/docker` — build the image, tagged via `script/projectname`
- `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`, then - `script/cibuild` — build the image (what CI runs); its last stage depends on
the image build the `lint` and `test` phases, so this one build lints, tests and compiles
- `script/precommit` — run by the git pre-commit hook (our own extension); runs - `script/precommit` — run by the git pre-commit hook (our own extension); runs
`script/lint` and `script/fmt-check` but deliberately not the tests, so the `script/lint`, which checks both lint and formatting, but deliberately not the
TDD red-phase commit can land tests, so the TDD red-phase commit can land
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it extension); `make hooks` shims to it
@@ -161,19 +162,22 @@ script runs the tools on the host: docker is required, and that also works where
the docker daemon is remote and bind mounts are impossible. the docker daemon is remote and bind mounts are impossible.
The last stage of the `Dockerfile` compiles the package, and it copies a file The last stage of the `Dockerfile` compiles the package, and it copies a file
from each phase, so it cannot be built unless lint and the tests pass. The image from each phase, so it cannot be built unless lint and the tests pass. That is
build in `script/cibuild` therefore runs lint and the tests a second time, after why `script/cibuild` is a single `docker build`: it runs lint and the tests once
`script/check` has run them. each and then compiles.
Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree
Docker would otherwise serve the lint and test steps from cache, nothing would Docker would otherwise serve the lint and test steps from cache, nothing would
run, and the build would still exit 0. run, and the build would still exit 0.
`script/fmt-check` runs prettier on the host. Its verdict matches the `lint` The formatting check is part of the `lint` phase, not a step beside it, so
phase's: prettier is pinned to an exact version, installed from `yarn.lock` `script/check` and `script/precommit` do not call `script/fmt-check` as well;
under `--frozen-lockfile` in both places, and reads `.gitignore` as its default that would run prettier a second time over the same tree for the same verdict.
ignore file — which is why `.dockerignore` keeps `.gitignore` in the build `script/fmt-check` remains as a standalone entrypoint for asking the formatting
context. question on the host. Its verdict matches the container's: prettier is pinned to
an exact version, installed from `yarn.lock` under `--frozen-lockfile` in both
places, and reads `.gitignore` as its default ignore file — which is why
`.dockerignore` keeps `.gitignore` in the build context.
### Version ### Version
@@ -255,10 +259,11 @@ All work on quak is test-driven. No exceptions.
history must still show tests landing before (or with) the matching history must still show tests landing before (or with) the matching
implementation. implementation.
8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which 8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which
runs `script/lint` and `script/fmt-check` but not the tests, and so not the runs `script/lint` — eslint and the prettier check, in the container — but
full `make check`. This is deliberate so the TDD red-phase commit (failing not the tests, and so not the full `make check`. This is deliberate so the
tests, no implementation yet) can land. CI executes `script/cibuild`, which TDD red-phase commit (failing tests, no implementation yet) can land. The
runs the tests, so a red branch still cannot reach `next`. `test` phase is part of the image build, which is what CI executes via
`script/cibuild`, so a red branch still cannot reach `next`.
## Design ## Design
@@ -415,24 +420,18 @@ Backoff is exponential with full jitter: the delay before retry _n_ is
`random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term `random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term
is the ceiling and the wait is drawn below it, so a client that lost many is the ceiling and the wait is drawn below it, so a client that lost many
parallel downloads to one CDN blip does not send them all again at the same parallel downloads to one CDN blip does not send them all again at the same
instant. The numbers, configurable through `ApiClientOptions.retry`: instant. Defaults, configurable through `ApiClientOptions.retry`:
| Option | Default | `quak backup` | Meaning | | Option | Default | Meaning |
| ------------- | ------- | ------------- | ----------------------------------- | | ------------- | ------- | ----------------------------------- |
| `attempts` | `4` | `10` | total calls, not retries | | `attempts` | `4` | total calls, not retries |
| `baseDelayMs` | `500` | `1000` | ceiling for the first retry's delay | | `baseDelayMs` | `500` | ceiling for the first retry's delay |
| `maxDelayMs` | `10000` | `60000` | upper bound on that ceiling | | `maxDelayMs` | `10000` | upper bound on that ceiling |
With the defaults a file that is going to fail gives up after at most three and With those defaults a file that is going to fail gives up after at most three
a half seconds of waiting. `quak backup` usually runs from cron with nobody and a half seconds of waiting. `sleep` and `random` are injectable through the
watching, so every request it makes uses the `quak backup` column instead, same option, which is how the test suite exercises the whole policy without
exported as `UNATTENDED_RETRY_OPTIONS`: a request that keeps failing gives up waiting.
after at most 243 seconds of waiting, and usually after about half that, since
each wait is drawn at random below its ceiling. Every other command uses the
defaults. A library user gets the same budget by passing
`UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` are
injectable through the same option, which is how the test suite exercises the
whole policy without waiting.
Two deadlines, renewed for each attempt: Two deadlines, renewed for each attempt:
@@ -1008,12 +1007,14 @@ documents:
before the implementation. Tests are the canonical API documentation and must before the implementation. Tests are the canonical API documentation and must
be commented thoroughly. `main` and `next` are always green. be commented thoroughly. `main` and `next` are always green.
- **Required checks before every commit:** `make lint` and `make fmt-check` must - **Required checks before every commit:** `make lint` must pass — that is
pass. `make lint` is eslint plus the prettier check, and it builds the `lint` eslint plus the prettier check, and it builds the `lint` phase of the
phase of the `Dockerfile`, so it needs docker. The pre-commit hook enforces `Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that.
exactly that. `make check` (which also runs the tests) must pass before `make check` (which also runs the tests) must pass before merging into `next`.
merging into `next`. Never invoke eslint or prettier directly; linting runs in `make fmt-check` is available for a host-side formatting check on its own, but
the container only. it is not a separate requirement: `make lint` already covers it, and running
both would check formatting twice. Never invoke eslint or prettier directly;
linting runs in the container only.
- **Formatting:** prettier with 4-space indents and `proseWrap: always` for - **Formatting:** prettier with 4-space indents and `proseWrap: always` for
markdown. Use `make fmt` to format. Use `yarn` not `npm`. markdown. Use `make fmt` to format. Use `yarn` not `npm`.
+44 -120
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-04 last_modified: 2026-09-08
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -104,14 +104,10 @@ style conventions are in separate documents:
`lint` phase and a `test` phase, with the final stage depending on both so the `lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image. brings up a development environment; for server repos it is the runtime image.
The gate phases and the build stage start from their pinned base images and Dockerfiles install development prerequisites by running `script/bootstrap`
install what those images lack either inline, as the canonical Go `Dockerfile` rather than duplicating installs inline; COPY `script/` and the dependency
below does for `git`, or by running `script/bootstrap`, as the `prompts` manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
repo's own `Dockerfile` does for its yarn packages. The development running it.
environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase no separate lint file. `script/lint` and `script/test` each build one phase
@@ -160,14 +156,11 @@ style conventions are in separate documents:
not evidence that anything ran: a sub-second build reporting success is a not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune` cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host. and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.** - **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile, hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo and the test phase is based on the Go image. The canonical Go repo
`Dockerfile`: `Dockerfile`:
```dockerfile ```dockerfile
@@ -180,9 +173,8 @@ style conventions are in separate documents:
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go # Test phase
# image ships and the alpine one does not. # golang:1.x-alpine, YYYY-MM-DD
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test FROM golang@sha256:... AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -199,29 +191,15 @@ style conventions are in separate documents:
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# The VERSION build arg when one is given, otherwise ARG VERSION=dev
# `git describe --tags --always` on the .git in the build context. With RUN CGO_ENABLED=0 go build -trimpath \
# .git present, a version that is still empty, dev or unknown fails the -ldflags="-s -w -X main.Version=${VERSION}" \
# build: git is missing or could not read the checkout. -o /app ./cmd/app/
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one # Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
@@ -243,41 +221,10 @@ style conventions are in separate documents:
(e.g. a web frontend compiled in a separate stage), the lint phase must (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting, install them - If the project requires CGO or system libraries for linting (e.g.
in the lint phase. The `golangci/golangci-lint` image is Debian-based and `vips-dev`), install them in the lint phase with `apk add`.
has no `apk`, so install with `apt-get` under the Debian package name - `ARG VERSION=dev` is declared in the stage that compiles and supplied by
(`libvips-dev`, where alpine says `vips-dev`), and delete the package `script/docker` and `script/cibuild`; no stage may call `git describe`.
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step. runs `script/cibuild` on push, and checks out the repo as its only other step.
@@ -286,12 +233,7 @@ style conventions are in separate documents:
carry the same guarantee, because its gate phases may come from the cache. The carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. A separate from a run of its own gates rather than from a cache entry.
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -344,19 +286,17 @@ style conventions are in separate documents:
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_ `-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the cache, so the target cannot report a pass it did not earn, and the rerun
tests. It leaves the build cache alone, so it costs the runtime of the suite reproduces a failure instead of replaying it. It leaves the build cache
and no recompilation. alone, so it costs the runtime of the suite and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a Note that this is a second, independent cache, stacked below the Docker
passing result in its cache directory (`GOCACHE`), and when the same tests layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
run again on unchanged code it prints that result, marked `(cached)`, addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
without running them. That matters on a developer's machine, where this it does not guarantee `go test` inside that step does any work, because the
target runs and the directory lasts from one run to the next. The `test` `GOCACHE` baked into earlier image layers survives into the re-executed
phase of the `Dockerfile` needs no `-count=1`: its base image holds no step. They are two separate defects requiring two separate fixes, and a fix
result for this repo's tests and nothing before its `go test` step runs a for one must not be recorded as covering the other.
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example: Python example:
@@ -400,7 +340,7 @@ style conventions are in separate documents:
— which is more dangerous than a short file with no secret patterns at all, — which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.claude`, and the repo's own host-built root-anchored entries unprefixed: `.git`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is `cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
@@ -425,13 +365,12 @@ style conventions are in separate documents:
directory, so a repo running agents in subdirectories still ships directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there. `services/api/.claude/` and must add its own anchored entry there.
- **A plain `docker build .` of a clone stamps the version that - **Excluding `.git` means `git describe` cannot run inside any build stage, and
`git describe --tags --always` gives**, derived from the `.git` in the build it fails quietly there.** In a build stage there is no repository, so
context as the canonical `Dockerfile` above shows. Without its failure check, `git describe` writes nothing to stdout, `-X main.Version=` comes out empty,
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty the binary reports no version at all, and the build still exits 0. Compute the
and the build would still exit 0. `script/docker` and `script/cibuild` pass version on the host and thread it in as a build arg. `script/docker` and
the version they compute on the host; it takes precedence. They do this `script/cibuild` do this, byte-identically across repos:
byte-identically across repos:
```sh ```sh
# Own line: a failing command substitution inside an argument does not # Own line: a failing command substitution inside an argument does not
@@ -448,7 +387,7 @@ style conventions are in separate documents:
fallback is applied — a live check that fires on a build from an export with fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard the scripts stay byte-identical. One consequence for CI: the standard
@@ -487,18 +426,12 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages `test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
image image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
directive must not name a newer Go minor version than the one golangci-lint only pin, since no repo installs golangci-lint on the host: bumping the
was built with, or golangci-lint refuses to lint it: this release lints version means changing it and nothing else.
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by - **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
@@ -522,11 +455,6 @@ style conventions are in separate documents:
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -639,10 +567,10 @@ style conventions are in separate documents:
settings. settings.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
and language-specific config). Everything else goes in a subdirectory. language-specific config). Everything else goes in a subdirectory. Canonical
Canonical subdirectory names: subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in body is a single call into `internal/` or `pkg/`, no project logic in
@@ -673,7 +601,3 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
-16
View File
@@ -34,22 +34,6 @@ declares one.
field is taken from that JSON. The hand-built JPEGs moved to field is taken from that JSON. The hand-built JPEGs moved to
`test/exif-jpeg.ts`, beside the HEIC. `test/exif-jpeg.ts`, beside the HEIC.
- 2026-10-06: `quak backup` retries a failed request for longer than the other
commands do (issue 165). `src/retry.ts` exports `UNATTENDED_RETRY_OPTIONS`
beside the unchanged default: 10 attempts, a 1 s base delay and a 60 s cap, so
a request that keeps failing waits at most 243 s before it gives up.
`bin/quak.ts` loads the backup's session with them, so its refresh, ML data
and downloads all use them. What is retried and the backoff formula are
unchanged.
- 2026-10-06: The files this repository copies from `sneak/prompts` are copied
again from its commit `dd4027b` (issue 171). `.gitignore` and `.dockerignore`
keep out more secret files, and this repository's build artifacts follow the
copied content. `script/check` runs `script/fmt-check` again, `script/cibuild`
runs `script/bootstrap` and `script/check` before the image build,
`script/fmt` and `script/fmt-check` find the pinned yarn under nvm, and the
image's last stage marks `/app` safe for git.
- 2026-10-06: `quak backup` writes the account and album records - 2026-10-06: `quak backup` writes the account and album records
`backup-metadata` writes (issue 166): `account.json` with the account's `backup-metadata` writes (issue 166): `account.json` with the account's
`email` and `userID`, and in each album's JSON its `ownerID`, `isShared`, `email` and `userID`, and in each album's JSON its `ownerID`, `isShared`,
+1 -14
View File
@@ -23,7 +23,6 @@ import { run as runCommand } from "../src/cli-run.js";
import { loadSession } from "../src/cli-session.js"; import { loadSession } from "../src/cli-session.js";
import { Client } from "../src/client.js"; import { Client } from "../src/client.js";
import { VERSION } from "../src/index.js"; import { VERSION } from "../src/index.js";
import { UNATTENDED_RETRY_OPTIONS } from "../src/retry.js";
const paths = envPaths("quak", { suffix: "" }); const paths = envPaths("quak", { suffix: "" });
@@ -130,20 +129,8 @@ program
) )
.argument("<dir>", "Output directory") .argument("<dir>", "Output directory")
.option("--json", "Print result as JSON instead of human-readable summary") .option("--json", "Print result as JSON instead of human-readable summary")
// A backup usually runs from cron with nobody watching, so every request
// it makes retries for longer than the other commands' requests do.
.action((dir: string, opts: { json?: boolean }) => .action((dir: string, opts: { json?: boolean }) =>
run( run(backupCommand(context(), dir, opts)),
backupCommand(
{
...context(),
loadSession: (path) =>
loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }),
},
dir,
opts,
),
),
); );
const helper = program const helper = program
+7 -5
View File
@@ -1,8 +1,11 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint). Our own extension to
# extension to scripts-to-rule-them-all. test and lint are Docker # scripts-to-rule-them-all. Both are Docker phases. Must not modify any
# phases; fmt-check is native, because a formatter writes the working # files.
# tree. Must not modify any files. #
# script/fmt-check is not called here, unlike the template: the lint
# phase already runs `prettier --check .`, so calling it would run
# prettier a second time over the same tree for the same verdict.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -10,7 +13,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
} }
main "$@" main "$@"
+7 -7
View File
@@ -1,7 +1,8 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. It bootstraps first: a CI runner # script/cibuild: run the CI build. The image's last stage depends on the
# checks out and runs this and nothing else, and script/fmt-check runs # lint and test phases, so this one build runs eslint, prettier and the
# the formatter on the host, which a pristine checkout cannot do. # suite once each and then compiles. Unlike the template it does not run
# script/check first, which would run lint and the tests a second time.
# --no-cache for the same reason as script/docker: the gate phases the # --no-cache for the same reason as script/docker: the gate phases the
# final stage depends on are RUN steps, and a cached one is a check that # final stage depends on are RUN steps, and a cached one is a check that
# did not run. # did not run.
@@ -12,12 +13,11 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over # empty constant. The version resolved here goes in as the VERSION
# the version a build stage derives from the .git in the context. # build arg, which takes precedence over what the build would derive
# from the .git in its context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache \
+3 -2
View File
@@ -12,8 +12,9 @@ main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over # empty constant. The version resolved here goes in as the VERSION
# the version a build stage derives from the .git in the context. # build arg, which takes precedence over what the build would derive
# from the .git in its context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache \
+1 -20
View File
@@ -4,28 +4,9 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
run_yarn run prettier --write . yarn run prettier --write .
} }
main "$@" main "$@"
+1 -20
View File
@@ -4,28 +4,9 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
run_yarn run prettier --check . yarn run prettier --check .
} }
main "$@" main "$@"
+5 -5
View File
@@ -2,17 +2,17 @@
# script/precommit: run by the git pre-commit hook; fails the commit if # script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. # checks fail. Our own extension to scripts-to-rule-them-all.
# #
# Runs lint and fmt-check but deliberately NOT the tests, so the TDD # Runs lint but deliberately NOT the tests, so the TDD red-phase commit
# red-phase commit (failing tests, no implementation yet) can land. CI # (failing tests, no implementation yet) can land. CI runs
# runs script/cibuild, which runs the tests, and so catches any branch # script/cibuild, whose image build includes the test phase, and so
# that ships red. # catches any branch that ships red. The lint phase includes the
# prettier check, so a badly formatted tree still fails the commit.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
} }
main "$@" main "$@"
-1
View File
@@ -27,7 +27,6 @@ export {
isRetryable, isRetryable,
isSafeToReplay, isSafeToReplay,
resolveRetryOptions, resolveRetryOptions,
UNATTENDED_RETRY_OPTIONS,
withRetry, withRetry,
type ResolvedRetryOptions, type ResolvedRetryOptions,
type RetryOptions, type RetryOptions,
-10
View File
@@ -37,16 +37,6 @@ export const DEFAULT_RETRY_OPTIONS: ResolvedRetryOptions = {
random: Math.random, random: Math.random,
}; };
// For a run nobody is watching, such as `quak backup` from cron. A request that
// keeps failing waits at most 243 s in all before it gives up, usually about
// half that, since each wait is drawn at random below its ceiling.
export const UNATTENDED_RETRY_OPTIONS: ResolvedRetryOptions = {
...DEFAULT_RETRY_OPTIONS,
attempts: 10,
baseDelayMs: 1_000,
maxDelayMs: 60_000,
};
export const resolveRetryOptions = ( export const resolveRetryOptions = (
opts?: RetryOptions, opts?: RetryOptions,
): ResolvedRetryOptions => ({ ): ResolvedRetryOptions => ({
-71
View File
@@ -1,71 +0,0 @@
/**
* Tests for the retry options `bin/quak.ts` loads the saved session with:
* `quak backup` gets the unattended ones, every other command the default.
*
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
* replaced by one that records the options it is given and reports no session,
* so the command stops before it makes any request.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import type { ApiClientOptions } from "../../src/api/client.js";
import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js";
const loaded = vi.hoisted(() => [] as (ApiClientOptions | undefined)[]);
vi.mock("../../src/cli-session.js", () => ({
loadSession: (_path: string, apiOptions?: ApiClientOptions) => {
loaded.push(apiOptions);
return null;
},
}));
const argv = process.argv;
const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-"));
// Run `quak <args>` to completion. The "Not logged in" message and the exit
// are swallowed.
const quak = async (...args: string[]): Promise<void> => {
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
process.argv = ["node", "quak", ...args];
vi.resetModules();
await import("../../bin/quak.js");
};
afterEach(() => {
process.argv = argv;
loaded.length = 0;
vi.restoreAllMocks();
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("bin/quak.ts session loading", () => {
it("loads the session for backup with the unattended retry options", async () => {
await quak("backup", dir);
// `vi.resetModules()` gave `bin/quak.ts` its own copy of
// `src/retry.ts`, whose `sleep` is a different function, so the
// options are compared by their numbers.
const { attempts, baseDelayMs, maxDelayMs } = UNATTENDED_RETRY_OPTIONS;
expect(loaded).toEqual([
{
retry: expect.objectContaining({
attempts,
baseDelayMs,
maxDelayMs,
}),
},
]);
});
it("loads the session for another command with the default options", async () => {
await quak("collections");
expect(loaded).toEqual([undefined]);
});
});
-18
View File
@@ -12,10 +12,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { init, toBase64 } from "../../src/crypto/index.js"; import { init, toBase64 } from "../../src/crypto/index.js";
import { Client, type ClientSnapshot } from "../../src/client.js"; import { Client, type ClientSnapshot } from "../../src/client.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import {
DEFAULT_RETRY_OPTIONS,
UNATTENDED_RETRY_OPTIONS,
} from "../../src/retry.js";
const validSnapshot = (): ClientSnapshot => { const validSnapshot = (): ClientSnapshot => {
const kp = sodium.crypto_box_keypair(); const kp = sodium.crypto_box_keypair();
@@ -180,20 +176,6 @@ describe("loadSession", () => {
}); });
}); });
it("gives the restored client the retry options it is passed", () => {
const path = join(dir, "retry.json");
writeFileSync(path, JSON.stringify(validSnapshot()));
const retryOptions = (client: Client | null) =>
client!.getApiClient().getRetryOptions();
expect(
retryOptions(
loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }),
),
).toEqual(UNATTENDED_RETRY_OPTIONS);
expect(retryOptions(loadSession(path))).toEqual(DEFAULT_RETRY_OPTIONS);
});
it("says the file is corrupt when it is not JSON", () => { it("says the file is corrupt when it is not JSON", () => {
const path = join(dir, "truncated.json"); const path = join(dir, "truncated.json");
writeFileSync(path, '{"email": "user@exa'); writeFileSync(path, '{"email": "user@exa');
+2 -2
View File
@@ -1,7 +1,7 @@
/** /**
* `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same * `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same
* values as the hand-built JPEG in `exif-jpeg.ts`, for the tests of `exif()`, * values as the hand-built JPEG in `library/content-library.test.ts`, for the
* `backup-metadata --exif` and the image metadata `quak backup` records. * tests of `exif()` and `backup-metadata --exif`.
* *
* It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3), * It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3),
* with libheif 1.23.0 and exiftool 13.55: * with libheif 1.23.0 and exiftool 13.55:
+12 -13
View File
@@ -29,19 +29,18 @@ const patterns = (name: string): string[] =>
const dockerignore = patterns(".dockerignore"); const dockerignore = patterns(".dockerignore");
describe(".dockerignore", () => { describe(".dockerignore", () => {
// Everything here is either generated, enormous, or secret. `.claude` is // Everything here is either generated, enormous, or secret. `.claude/` is
// the correctness one: see the header comment and issue #25. The leading // the correctness one: see the header comment and issue #25.
// `/` anchors an entry at the root of the context.
it.each([ it.each([
".claude", ".claude/",
"/.quak", ".quak/",
"/bin/quak", "bin/quak",
"**/node_modules", "node_modules",
"/coverage", "coverage",
"/dist", "dist",
"/.vitest-cache", ".vitest-cache/",
"/.nyc_output", ".nyc_output/",
"/*.tsbuildinfo", "*.tsbuildinfo",
])("keeps %s out of the build context", (pattern) => { ])("keeps %s out of the build context", (pattern) => {
expect(dockerignore).toContain(pattern); expect(dockerignore).toContain(pattern);
}); });
@@ -58,7 +57,7 @@ describe(".dockerignore", () => {
// The build stage is the final image, so a .git/config sent in would // The build stage is the final image, so a .git/config sent in would
// ship the clone's remote URL and any credential in it. // ship the clone's remote URL and any credential in it.
it("sends .git without its config", () => { it("sends .git without its config", () => {
expect(dockerignore).toContain("**/.git/config"); expect(dockerignore).toContain(".git/config");
}); });
// BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a // BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a
-31
View File
@@ -43,7 +43,6 @@ import {
isRetryable, isRetryable,
isSafeToReplay, isSafeToReplay,
resolveRetryOptions, resolveRetryOptions,
UNATTENDED_RETRY_OPTIONS,
withRetry, withRetry,
} from "../../src/retry.js"; } from "../../src/retry.js";
import { ApiError, TruncatedStreamError } from "../../src/errors.js"; import { ApiError, TruncatedStreamError } from "../../src/errors.js";
@@ -640,33 +639,3 @@ describe("retry defaults", () => {
} }
}); });
}); });
describe("unattended retry options", () => {
it("allow ten attempts, a 1 s base delay and a 60 s cap", () => {
// The numbers the README documents for `quak backup`.
expect(UNATTENDED_RETRY_OPTIONS.attempts).toBe(10);
expect(UNATTENDED_RETRY_OPTIONS.baseDelayMs).toBe(1_000);
expect(UNATTENDED_RETRY_OPTIONS.maxDelayMs).toBe(60_000);
});
it("give up on a request that keeps failing after at most 243 s of waiting", async () => {
// `random: () => 1` makes every wait its ceiling, the worst case.
const { sleep, delays } = recordingSleep();
let calls = 0;
await expect(
withRetry(
() => {
calls++;
return Promise.reject(new ApiError("HTTP 503", 503));
},
{ ...UNATTENDED_RETRY_OPTIONS, sleep, random: () => 1 },
),
).rejects.toThrow("HTTP 503");
expect(calls).toBe(10);
expect(delays).toEqual([
1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000, 60_000, 60_000,
]);
expect(delays.reduce((sum, ms) => sum + ms, 0)).toBe(243_000);
});
});