Compare commits
1
Commits
next
..
0311ba3805
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0311ba3805 |
@@ -13,6 +13,3 @@ indent_style = tab
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
# This repository's own sections, such as one for another language it
|
||||
# uses, go below this comment, and a re-vendor keeps them.
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
name: check
|
||||
on: [push]
|
||||
# Free the shared runner: a new push cancels only the same branch's older run.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
# Free the shared runner from a hung build.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
# script/cibuild needs no token, so none is left in .git/config.
|
||||
with:
|
||||
persist-credentials: false
|
||||
# All history and tags, so git describe finds the version tag.
|
||||
fetch-depth: 0
|
||||
- run: script/cibuild
|
||||
|
||||
+1
-5
@@ -27,7 +27,7 @@ node_modules/
|
||||
# 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 at the end of this file, for example `!.env.example`.
|
||||
# its own negation after these lines, for example `!.env.example`.
|
||||
*.[eE][nN][vV]
|
||||
.[eE][nN][vV].*
|
||||
.[eE][nN][vV][rR][cC]
|
||||
@@ -46,10 +46,6 @@ node_modules/
|
||||
[iI][dD]_[eE][dD]25519
|
||||
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
|
||||
# This repository's own entries, such as its build outputs, go below
|
||||
# this comment, and a re-vendor keeps them. Anchor a binary built at the
|
||||
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
|
||||
|
||||
# Go. /secret is the built binary, anchored so that it does not also match
|
||||
# the internal/secret/ package directory.
|
||||
*.log
|
||||
|
||||
@@ -25,8 +25,6 @@ linters:
|
||||
# silenced by disabling that name, not by enabling the successor.
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||
# Misses findings at random in v2.14.0; back once a pinned release fixes it
|
||||
- canonicalheader
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
|
||||
+11
-21
@@ -1,40 +1,33 @@
|
||||
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
||||
# on every build, which reruns every RUN step after it. The steps that
|
||||
# compile mount Go's build cache, which survives from build to build.
|
||||
|
||||
# Lint phase. The linter is invoked directly rather than through `make
|
||||
# lint` or `script/lint`, which are themselves a docker build.
|
||||
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
|
||||
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||
ARG CACHEBUST
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go vet ./...
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
RUN go vet ./...
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
# The same checks on the code as a macOS build compiles it, which a Linux
|
||||
# build never compiles. Cgo is off, because compiling cgo code for macOS
|
||||
# needs Apple's SDK headers.
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
GOOS=darwin CGO_ENABLED=0 go vet ./...
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
|
||||
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
|
||||
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||
# image ships and the alpine one does not. -count=1 because the mounted
|
||||
# cache also holds test results, which go test would otherwise replay.
|
||||
# image ships and the alpine one does not.
|
||||
# golang:1.24.13-trixie, 2026-02-04
|
||||
FROM golang@sha256:5835f052b784aa39f2fe9070def3568605c8bc3fcd810f10402066348b61e716 AS test
|
||||
ARG CACHEBUST
|
||||
ENV CGO_ENABLED=1
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
# Go's build cache goes in a cache mount, not the image layer, which would
|
||||
# take seconds longer to export. --no-cache, on every build in script/,
|
||||
# starts the mount empty; -count=1 keeps a build without it from taking
|
||||
# test results from there.
|
||||
RUN --mount=type=cache,id=sneak/secret/go-build-test,target=/root/.cache/go-build \
|
||||
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||
@@ -44,7 +37,6 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
# lint and test passed.
|
||||
# golang 1.24.13-alpine, 2026-03-10
|
||||
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||
ARG CACHEBUST
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
# script/build compiles with cgo, so it needs a C compiler too.
|
||||
@@ -61,8 +53,7 @@ COPY . .
|
||||
# .git present, a version that is still empty, dev or unknown fails the
|
||||
# build: git is missing or could not read the checkout.
|
||||
ARG VERSION
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
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; \
|
||||
@@ -74,7 +65,6 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
# Runtime stage, and the last one
|
||||
# alpine 3.23, 2026-03-10
|
||||
FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||
ARG CACHEBUST
|
||||
|
||||
RUN apk add --no-cache ca-certificates gnupg
|
||||
|
||||
|
||||
@@ -440,11 +440,10 @@ in a CI job, the command fails and names the variable to set.
|
||||
|
||||
A command that changes anything locks the state directory, through its file
|
||||
`lock`, and another such command waits until the lock is released.
|
||||
`secret init`, `secret vault create`, `secret unlocker add`, `secret mv` from
|
||||
one vault to another, and `secret add`, `secret import` and
|
||||
`secret generate secret` with `--force`, ask for the mnemonic or passphrase
|
||||
before they take the lock, so a prompt of theirs left open in one terminal holds
|
||||
up no command in another.
|
||||
`secret init`, `secret vault create`, `secret unlocker add`, and `secret add`,
|
||||
`secret import` and `secret generate secret` with `--force`, ask for the
|
||||
mnemonic or passphrase before they take the lock, so a prompt left open in one
|
||||
terminal holds up no command in another.
|
||||
|
||||
## Security Features
|
||||
|
||||
@@ -675,10 +674,6 @@ provide:
|
||||
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||
`script/precommit`
|
||||
|
||||
Every stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
|
||||
`FROM`. The `script/` builds pass a new `CACHEBUST` value on every build, which
|
||||
reruns every `RUN` step and keeps Go's build cache; `--no-cache` would empty it.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Authentication Methods**: Supports passphrase and PGP unlockers
|
||||
|
||||
+79
-156
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-10-15
|
||||
last_modified: 2026-10-04
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
@@ -54,39 +54,34 @@ style conventions are in separate documents:
|
||||
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||
(detected in that order; apt runs noninteractive). For node it uses the
|
||||
installed node only when its major version is the pinned one; otherwise (node
|
||||
missing, or another major, such as the node 24 the CI runner image ships) it
|
||||
installs the PINNED node version via nvm and installs yarn and the packages
|
||||
under it. nvm itself is installed first if missing, from a hash-verified
|
||||
GitHub release archive (never `curl | sh`), with bash installed as an explicit
|
||||
prerequisite since nvm requires bash. Only the major version is compared
|
||||
because the `Dockerfile` stages start from a node image whose exact version is
|
||||
not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned
|
||||
via `corepack prepare yarn@<version> --activate`. Never install "latest" or
|
||||
"lts"; always exact versions. `script/cibuild` runs the CI build: it changes
|
||||
to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the
|
||||
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||
installed node if present; otherwise it installs a PINNED node version via
|
||||
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||
release archive (never `curl | sh`), with bash installed as an explicit
|
||||
prerequisite since nvm requires bash. yarn is then pinned via
|
||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||
pristine checkout with nothing installed the run dies there, after the
|
||||
containerised gates have passed. **The bootstrap alone is not enough**: when
|
||||
`script/bootstrap` installs node and yarn under nvm it leaves neither on the
|
||||
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
|
||||
under another node. The host entrypoints that need yarn — `script/fmt` and
|
||||
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when
|
||||
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
|
||||
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
|
||||
pinned node. A runner carrying nothing but docker and git then gets through
|
||||
`script/check`. Four further scripts are our own extensions to the standard:
|
||||
`script/check` runs `script/test`, `script/lint` and `script/fmt-check`;
|
||||
`script/precommit` is what the git pre-commit hook runs, and it calls
|
||||
`script/check`; `script/install-precommit` installs the git pre-commit hook
|
||||
(the `make hooks` target shims to it); and `script/projectname` (literally
|
||||
that filename) simply outputs the project's name. Scripts that need the name
|
||||
call `script/projectname` — e.g. `script/docker` assembles its image tag from
|
||||
it — so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||
`script/precommit`, not in the hook itself. Model scripts are at
|
||||
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||
source nvm for the pinned node version before invoking it, exactly as
|
||||
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
||||
docker and git then gets through `script/check`. Four further scripts are our
|
||||
own extensions to the standard: `script/check` runs `script/test`,
|
||||
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
|
||||
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
|
||||
installs the git pre-commit hook (the `make hooks` target shims to it); and
|
||||
`script/projectname` (literally that filename) simply outputs the project's
|
||||
name. Scripts that need the name call `script/projectname` — e.g.
|
||||
`script/docker` assembles its image tag from it — so those scripts stay
|
||||
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
|
||||
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
|
||||
the hook itself. Model scripts are at
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||
must document the provided scripts in an **Entrypoints** section (see the
|
||||
README requirements below).
|
||||
@@ -123,10 +118,8 @@ style conventions are in separate documents:
|
||||
and nothing else:
|
||||
|
||||
```sh
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
--target lint --output type=cacheonly .
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
--target test --output type=cacheonly .
|
||||
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
||||
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
||||
```
|
||||
|
||||
**A stage that is not the last one in the file is built only when the final
|
||||
@@ -136,15 +129,10 @@ style conventions are in separate documents:
|
||||
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||
and tested nothing.
|
||||
|
||||
**The gate builds write no image.** With `--output type=cacheonly` the phase
|
||||
runs and a failing step fails the build, but the result is not exported.
|
||||
Nothing uses those images, and writing one out is slow: a Go test phase's
|
||||
image holds the toolchain. A build given neither `--output` nor `-t` writes
|
||||
an untagged image and leaves it dangling, on every developer host and every
|
||||
CI runner. `script/cibuild` and `script/docker` build the image that ships
|
||||
and tag it, so each build replaces the previous image; each assigns the tag
|
||||
on its own line before the build, so `set -e` stops it where
|
||||
`script/projectname` fails.
|
||||
**Every `docker build` in `script/` is tagged**, here and in
|
||||
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
||||
image behind on every invocation, on every developer host and every CI
|
||||
runner; a tagged one replaces the previous image.
|
||||
|
||||
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||
@@ -159,33 +147,22 @@ style conventions are in separate documents:
|
||||
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||
org, in both directions. A container has its own golangci-lint cache, its
|
||||
own `TMPDIR` and a digest-pinned binary, so neither is reachable.
|
||||
org, in both directions. A container has its own cache, its own `TMPDIR` and
|
||||
a digest-pinned binary, so neither is reachable.
|
||||
|
||||
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
|
||||
invalidates a `COPY` layer only when the copied content changes, so on an
|
||||
unchanged tree the check `RUN` is served from cache, nothing executes, and the
|
||||
build still exits 0. Every `docker build` in `script/` therefore passes
|
||||
`--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process
|
||||
id, so no two builds share a value, not even the three in one
|
||||
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
||||
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
||||
two gate phases and `script/fmt-check`, and builds no image of its own. Every
|
||||
stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
|
||||
`FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps
|
||||
are compared by content and need nothing. A stage without the `ARG` line is
|
||||
served from cache on an unchanged tree, so a repository re-vendors the scripts
|
||||
and adds the `ARG` lines in one commit. The scripts do not pass `--no-cache`:
|
||||
it reruns every step too, but it also empties every cache mount, and Go's
|
||||
build cache, which the canonical Go `Dockerfile` below mounts so that compiled
|
||||
packages survive between check builds, would start empty every time. A bare
|
||||
`docker build .` is 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` 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.
|
||||
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
||||
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
||||
the check `RUN` is served from cache, nothing executes, and the build still
|
||||
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
||||
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
||||
four, and there is no fifth — `script/check` runs the two gate phases and
|
||||
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
||||
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`
|
||||
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 lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||
@@ -194,42 +171,32 @@ style conventions are in separate documents:
|
||||
`Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
||||
# on every build, which reruns every RUN step after it. The steps that
|
||||
# compile mount Go's build cache, which survives from build to build.
|
||||
|
||||
# Lint phase
|
||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||
FROM golangci/golangci-lint@sha256:... AS lint
|
||||
ARG CACHEBUST
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
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
|
||||
# image ships and the alpine one does not. -count=1 because the mounted
|
||||
# cache also holds test results, which go test would otherwise replay.
|
||||
# image ships and the alpine one does not.
|
||||
# golang:1.x, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS test
|
||||
ARG CACHEBUST
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
RUN go test -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||
go test -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
# Build stage. Nothing is wanted from either phase above; the copies
|
||||
# are what make BuildKit build them first, so this stage cannot run
|
||||
# unless lint and test passed.
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS builder
|
||||
ARG CACHEBUST
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
RUN apk add --no-cache git
|
||||
@@ -245,8 +212,7 @@ style conventions are in separate documents:
|
||||
# .git present, a version that is still empty, dev or unknown fails the
|
||||
# build: git is missing or could not read the checkout.
|
||||
ARG VERSION
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
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; \
|
||||
@@ -259,7 +225,6 @@ style conventions are in separate documents:
|
||||
|
||||
# Runtime stage, and the last one
|
||||
FROM alpine@sha256:...
|
||||
ARG CACHEBUST
|
||||
COPY --from=builder /app /usr/local/bin/app
|
||||
ENTRYPOINT ["app"]
|
||||
```
|
||||
@@ -274,16 +239,6 @@ style conventions are in separate documents:
|
||||
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||
same two copies. A plain `docker build .` builds the last stage's chain
|
||||
and nothing else.
|
||||
- Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps
|
||||
it for root), and only on the steps that compile: `golangci-lint run`,
|
||||
both `go test` runs and `go build`. It keeps compiled packages between
|
||||
check builds, and Go reuses one only when all its inputs are unchanged, so
|
||||
it saves the compile without skipping a check. Nothing else is kept:
|
||||
golangci-lint's own cache would replay stored findings in place of a lint
|
||||
run, and Go builds from whatever files a kept module cache holds without
|
||||
checking them again (only `go mod verify` does), so a file changed there
|
||||
would go unnoticed. Go's build cache also holds test results, which is why
|
||||
both `go test` runs carry `-count=1`.
|
||||
- If the project uses `//go:embed` directives that reference build artifacts
|
||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||
create placeholder files so the embed directives resolve. Example:
|
||||
@@ -323,39 +278,20 @@ style conventions are in separate documents:
|
||||
`.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.
|
||||
A checkout whose `.git` is a file (a linked worktree, or a repository
|
||||
checked out as a submodule) is the exception: that file points to a git
|
||||
directory outside the build context, so the build cannot read the version
|
||||
and a plain `docker build .` fails; pass the version with
|
||||
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
|
||||
do.
|
||||
|
||||
- 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,
|
||||
with `persist-credentials: false`: `script/cibuild` needs no token, and
|
||||
without it the checkout leaves the job's token in `.git/config` for every
|
||||
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
|
||||
tags `git describe` needs: by default it clones shallow with no tags, and a
|
||||
tagged repository's CI build would stamp a bare short commit id. The
|
||||
workflow's `concurrency` block groups runs by workflow and branch
|
||||
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
|
||||
so a new push cancels the older run on the same branch, queued or running, and
|
||||
no other: runs for replaced commits do not hold up the shared runner.
|
||||
`script/cibuild` bootstraps, runs the gate phases, and then builds the image,
|
||||
so a successful run means every check passed; a bare `docker build .` does not
|
||||
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||
That script bootstraps, runs the gate phases, and then builds the image, so a
|
||||
successful run means every check passed; a bare `docker build .` does not
|
||||
carry the same guarantee, because its gate phases may come from the cache. The
|
||||
image build gets a `CACHEBUST` value of its own 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 from a run of its own gates rather than from a cache
|
||||
entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the
|
||||
shared runner after 20 minutes. That allows for the three Docker builds
|
||||
described above (the test phase, the lint phase, then the image), each held to
|
||||
the 5-minute Docker build limit below, plus the bootstrap. A separate 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.
|
||||
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
|
||||
from a run of its own gates rather than from a cache entry. A separate
|
||||
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
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
@@ -415,11 +351,12 @@ style conventions are in separate documents:
|
||||
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||
run again on unchanged code it prints that result, marked `(cached)`,
|
||||
without running them. The directory lasts from one run to the next on a
|
||||
developer's machine, where this target runs, and in the `test` phase of the
|
||||
`Dockerfile`, which mounts it to keep compiled packages between check
|
||||
builds, so the phase's `go test` runs carry `-count=1` too. A new
|
||||
`CACHEBUST` value (above) is what makes that step run on an unchanged tree.
|
||||
without running them. That matters on a developer's machine, where this
|
||||
target runs and the directory lasts from one run to the next. The `test`
|
||||
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
||||
result for this repo's tests and nothing before its `go test` step runs a
|
||||
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
||||
step run on an unchanged tree.
|
||||
|
||||
Python example:
|
||||
|
||||
@@ -447,12 +384,9 @@ style conventions are in separate documents:
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||
`node_modules/`, and the repo's own build outputs. Fetch the standard
|
||||
`.gitignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
a new repo. A repo's `.gitignore` is the standard file followed by the repo's
|
||||
own entries, such as its binaries; a re-vendor replaces the standard part and
|
||||
keeps those entries. These patterns are written to `.gitignore`'s own
|
||||
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||
setting up a new repo. These patterns are written to `.gitignore`'s own
|
||||
semantics, in which an unanchored pattern already matches at every depth; they
|
||||
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||
|
||||
@@ -500,15 +434,13 @@ style conventions are in separate documents:
|
||||
byte-identically across repos:
|
||||
|
||||
```sh
|
||||
# The version and the tag each get their own line: a failing command
|
||||
# substitution inside an argument does not trip `set -e`, so the inline
|
||||
# form degrades to an empty constant.
|
||||
# Own line: a failing command substitution inside an argument does not
|
||||
# trip `set -e`, so the inline form degrades to an empty constant.
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
[ -n "$version" ] || version="unknown"
|
||||
tag="$(script/projectname)"
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
docker build --no-cache \
|
||||
--build-arg VERSION="$version" \
|
||||
-t "$tag" .
|
||||
-t "$(script/projectname)" .
|
||||
```
|
||||
|
||||
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||
@@ -520,8 +452,8 @@ style conventions are in separate documents:
|
||||
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
|
||||
the scripts stay byte-identical. One consequence for CI: the standard
|
||||
checkout action clones shallow and fetches no tags, so the canonical
|
||||
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
|
||||
checkout action clones shallow and fetches no tags, so a repo that embeds a
|
||||
tag-derived version must set `fetch-depth: 0` on its checkout step.
|
||||
|
||||
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||
@@ -588,11 +520,6 @@ style conventions are in separate documents:
|
||||
function defined and never invoked has the same exit status and the same
|
||||
empty output as one that worked.
|
||||
|
||||
Node is the exception to the whole-token comparison: the canonical
|
||||
`script/bootstrap` compares only its major version, because the node 22
|
||||
alpine image the `Dockerfile` stages start from is not the exact pin, and
|
||||
nvm cannot install a prebuilt node on alpine.
|
||||
|
||||
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
|
||||
@@ -709,11 +636,7 @@ style conventions are in separate documents:
|
||||
Never edit existing migrations after release.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings: the standard file from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets
|
||||
tabs for `Makefile` and Go files, followed by the repo's own sections, such as
|
||||
one for another language it uses. A re-vendor replaces the standard part and
|
||||
keeps those sections.
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
# Status
|
||||
|
||||
pre-1.0. Open work is tracked on the issue tracker, which is authoritative.
|
||||
pre-1.0. No git tags. Open work is tracked on the issue tracker, which is
|
||||
authoritative.
|
||||
|
||||
# Next Step
|
||||
|
||||
@@ -17,200 +18,6 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-15: The comment on `commandWait` in `internal/cli/integration_test.go`
|
||||
no longer says `script/test` gives the package 30 seconds
|
||||
(https://git.eeqj.de/sneak/secret/issues/195). `script/test` sets no time
|
||||
limit; the test phase of the `Dockerfile` sets one with `go test -timeout`.
|
||||
The comment now names that place instead of a number.
|
||||
- 2026-10-15: The canonical files are re-vendored from `sneak/prompts` commit
|
||||
`e3f79c4` (https://git.eeqj.de/sneak/secret/issues/129), so Go's build cache
|
||||
survives between check builds and `make test` no longer compiles the standard
|
||||
library and every dependency with `-race` on every run. `script/lint`,
|
||||
`script/test`, `script/cibuild` and `script/docker` pass a new `CACHEBUST`
|
||||
value instead of `--no-cache`, and every stage of the `Dockerfile` declares
|
||||
`ARG CACHEBUST` first, so every `RUN` step still reruns on an unchanged tree.
|
||||
The steps that compile mount Go's build cache, and both `go test` runs keep
|
||||
`-count=1`, so no test result is reused. `script/lint` and `script/test` write
|
||||
no image. The CI workflow cancels a branch's older run, stops after 20
|
||||
minutes, fetches the tags and leaves no token behind. `script/bootstrap` uses
|
||||
the installed node only when its major version is the pinned one.
|
||||
- 2026-10-15: `make test` no longer fails on some runs in `internal/cli`
|
||||
(https://git.eeqj.de/sneak/secret/issues/192). `cli.Entry()` set `time.Local`
|
||||
to UTC and ended by wiping every memguard buffer, and a test ran it in the
|
||||
same process as the other tests. A timer left by an earlier test could read
|
||||
`time.Local` as it changed, which the race detector reported, and the wipe
|
||||
could destroy a buffer still in use, the likeliest cause of a crash of the
|
||||
test process on another run. `main()` in `cmd/secret` now sets `time.Local`
|
||||
before it calls `cli.Entry()`, and that test runs `cli.Entry()` in a process
|
||||
of its own.
|
||||
- 2026-10-15: `secret mv` from one vault to another unlocks both vaults before
|
||||
it takes the state directory lock
|
||||
(https://git.eeqj.de/sneak/secret/issues/189). It unlocked them while holding
|
||||
the lock, and unlocking can ask for a passphrase, so every other command that
|
||||
changes anything waited at that prompt. `moveSecretCrossVault` now checks the
|
||||
move, refusing an existing destination secret without `--force`, unlocks both
|
||||
vaults, and only then takes the lock. Under the lock, it checks that the
|
||||
destination vault still has the long-term key it unlocked, and
|
||||
`copySecretAndRemoveSource` checks the move again before it copies. A move
|
||||
within a vault takes the lock in `moveSecretWithinVault`, so `MoveSecret`
|
||||
takes none itself. The error for a vault created again with another key, which
|
||||
`secret unlocker add` shares, now ends "nothing was changed".
|
||||
- 2026-10-15: `secret init`, `secret vault create`, `secret unlocker add`, and
|
||||
`secret add`, `secret import` and `secret generate secret` with `--force` ask
|
||||
for the mnemonic or passphrase, or unlock the vault, before they take the
|
||||
state directory lock (https://git.eeqj.de/sneak/secret/issues/152). They did
|
||||
so while holding the lock, so every other command that changes anything waited
|
||||
at the prompt. Under the lock, `secret unlocker add` fails if the vault was
|
||||
removed, or created again with another key, and refuses a GPG key added
|
||||
meanwhile; a forced add of a secret created meanwhile lets go of the lock,
|
||||
gets the key and takes the lock again. `secret.CreatePGPUnlocker` takes the
|
||||
unlocked vault; `SetMnemonic` and `SetUnlockPassphrase` are gone.
|
||||
- 2026-10-15: A `secret mv` to another vault that copies the secret but cannot
|
||||
remove the source fails (https://git.eeqj.de/sneak/secret/issues/159). It
|
||||
printed a warning and the usual "Moved" line and exited 0. If the source
|
||||
secret's directory is still in place, the error says the copy holds all
|
||||
versions and the source remains. If the source left its vault and only the
|
||||
temporary directory it was moved into could not be deleted, the error says the
|
||||
secret is moved and names that directory, which the error of `RemoveDirAtomic`
|
||||
now does.
|
||||
- 2026-10-15: `secret encrypt` and `secret decrypt` with `--output` hold the
|
||||
result in memory and open the output file only once encryption or decryption
|
||||
has succeeded (https://git.eeqj.de/sneak/secret/issues/143). They emptied that
|
||||
file before reading any input, so a failed command left it empty, and
|
||||
encrypting a file onto itself gave the encryption of nothing. A failure to
|
||||
encrypt or decrypt now leaves `--output` untouched, and `--input` can name the
|
||||
same file. A failure while writing `--output`, such as a full disk, can still
|
||||
leave it empty or partly written; an error from closing it is reported.
|
||||
Without `--output` the result still goes to stdout as it is produced.
|
||||
- 2026-10-15: A command that uses the current vault fails when `currentvault` is
|
||||
empty or names no vault (https://git.eeqj.de/sneak/secret/issues/156).
|
||||
`secret list` reported an empty vault with exit status 0, and `secret add`
|
||||
failed on a missing directory. `ResolveVaultSymlink` now refuses an empty or
|
||||
invalid name, or one with no directory under `vaults.d`, with an error that
|
||||
wraps `ErrVaultNotFound`, names the file and says to run
|
||||
`secret vault select <name>`. `secret vault list` and `secret info` still run.
|
||||
- 2026-10-15: `secret` prints every time in UTC, whatever the machine's time
|
||||
zone (https://git.eeqj.de/sneak/secret/issues/169). `secret list`,
|
||||
`secret version list`, `secret unlocker list` and `secret info` printed times
|
||||
in the local time zone without naming it, so one vault showed different times
|
||||
on two machines. `Entry()` now sets `time.Local` to UTC before anything else,
|
||||
as policy 6 in `AGENTS.md` asks, so every time the program prints or writes is
|
||||
in UTC. `--json` times end in `Z`, and a new version's name takes the UTC
|
||||
date. The README says so.
|
||||
- 2026-10-15: `secret vault`, `secret unlocker`, `secret version` and
|
||||
`secret generate` fail when given a subcommand they do not have
|
||||
(https://git.eeqj.de/sneak/secret/issues/183). They printed their help and
|
||||
exited 0, so a mistyped command looked like one that worked. Each now exits 1
|
||||
with `unknown command "<name>" for "secret <parent>"`, and with no arguments
|
||||
still prints its help. Cobra checks a command's `Args` only when it has a
|
||||
`RunE`, so each gets `Args: cobra.NoArgs` and a `RunE` that prints its help;
|
||||
their help now shows a `secret <parent> [flags]` usage line.
|
||||
`secret vault help` fails as an unknown subcommand, while `secret help vault`
|
||||
and `secret vault --help` still work.
|
||||
- 2026-10-15: With `SB_UNLOCK_PASSPHRASE` set, `secret unlocker add passphrase`
|
||||
asks on the terminal for the new unlocker's passphrase and uses the variable
|
||||
to unlock the vault (https://git.eeqj.de/sneak/secret/issues/154). It gave the
|
||||
new unlocker the passphrase in the variable without asking. With no
|
||||
controlling terminal, the new unlocker takes the variable's passphrase and the
|
||||
output says so. `secret.ErrNoTerminal` is exported so that the command can
|
||||
tell a missing terminal from other failures to read, such as nothing typed or
|
||||
a confirmation that does not match, which still fail the command.
|
||||
- 2026-10-15: `secret init` and `secret vault create` stop with an error naming
|
||||
the file when a vault's `vault-metadata.json` cannot be read or parsed, and
|
||||
leave the state directory as it was
|
||||
(https://git.eeqj.de/sneak/secret/issues/140). `GetNextDerivationIndex` passed
|
||||
over such a vault, so the next vault made from the same mnemonic could get its
|
||||
derivation index and so the same long-term key. A vault directory with no
|
||||
metadata file is still skipped.
|
||||
- 2026-10-15: The README says that `secret add` drops one trailing newline from
|
||||
stdin, that `secret import` stores a file's bytes unchanged, and that
|
||||
`secret get` writes the value with no newline added
|
||||
(https://git.eeqj.de/sneak/secret/issues/158). It said none of this, so a PEM
|
||||
key piped into `secret add` came back one byte short with no hint why. A new
|
||||
test stores `a\n\n` through `add` and through `import` and checks that `add`
|
||||
kept two bytes and `import` three. No program code changes.
|
||||
- 2026-10-15: When gpg fails to decrypt, to encrypt or to look up a key's
|
||||
fingerprint, the error ends with what gpg wrote to stderr, trimmed
|
||||
(https://git.eeqj.de/sneak/secret/issues/146). It gave only gpg's exit status,
|
||||
so the user could not tell why. `cmd.Output` keeps gpg's stderr in the
|
||||
`*exec.ExitError` it returns, and the three errors now use it. When gpg wrote
|
||||
nothing to stderr the error is as before, and when gpg wrote several lines the
|
||||
error has them all.
|
||||
- 2026-10-15: `secret version list` no longer shows a version without
|
||||
`NOT_AFTER` as expired (https://git.eeqj.de/sneak/secret/issues/150). STATUS
|
||||
was `expired` for every version other than the current one, so after a promote
|
||||
the newest version, which has no `NOT_AFTER`, read `expired`. STATUS is now
|
||||
`current` for the current version, `expired` for another version whose
|
||||
`NOT_AFTER` has passed, and `inactive` for any other version. The README
|
||||
describes the three.
|
||||
- 2026-10-15: `secret unlocker list --json` for a vault with no unlockers, and
|
||||
`secret vault list --json` when `vaults.d` holds no vaults, print the empty
|
||||
list as `[]` instead of `null` (https://git.eeqj.de/sneak/secret/issues/148).
|
||||
Both lists were nil slices, which `encoding/json` writes as `null`; each is
|
||||
now made as an empty slice before it is filled. `vault.ListVaults` therefore
|
||||
returns an empty slice instead of nil for an empty `vaults.d`.
|
||||
- 2026-10-15: `secret add`, `secret import` and `secret generate secret` fail
|
||||
with an error, not memguard's panic, when a secret is too large for the
|
||||
locked-memory limit (https://git.eeqj.de/sneak/secret/issues/145). The value
|
||||
was held twice, so half the usual 8 MiB limit was enough to crash them.
|
||||
`secret` now raises the limit to the hard limit at startup, and each of the
|
||||
three commands refuses, before its first locked allocation, a limit that
|
||||
leaves fewer than 8 pages for its keys and buffers. The value is read a page
|
||||
at a time into a `memguard.Stream`, which keeps it encrypted, and copied once
|
||||
into locked memory after its size is checked against 100 MiB and the limit. It
|
||||
leaves the stream through `Next`, not `Read`, because `Read` re-encrypts a
|
||||
leftover piece and memguard hangs if that runs out of locked memory.
|
||||
- 2026-10-15: Total Size in `secret info` counts every file of each version: the
|
||||
value, the metadata and the version's two keys
|
||||
(https://git.eeqj.de/sneak/secret/issues/147). It added up each version's
|
||||
`data.age` and `metadata.age`, but a version keeps its value in `value.age`,
|
||||
so no secret's value was counted. A version directory that cannot be read adds
|
||||
nothing and gets a warning naming it.
|
||||
- 2026-10-15: `secret vault import` is gone, with its tests and its README entry
|
||||
(https://git.eeqj.de/sneak/secret/issues/139). It took only a vault without a
|
||||
long-term key, and `secret init` and `secret vault create` give every vault
|
||||
one, so it always failed; a mnemonic enters through those two commands. The
|
||||
help and the note of `secret generate mnemonic` named `secret import`, which
|
||||
imports a file as a secret, and now name `secret init` and
|
||||
`secret vault create`.
|
||||
- 2026-10-15: `secret version promote --help` says promote rewrites the
|
||||
`current` file, not a "current symlink"
|
||||
(https://git.eeqj.de/sneak/secret/issues/153). `current` is a plain file
|
||||
holding the version name, as the README already said, and the comment in
|
||||
`PromoteVersion` now says so too.
|
||||
- 2026-10-14: `secret init` and `secret vault create` refuse a vault that
|
||||
already exists before they ask for the mnemonic or the unlocker passphrase
|
||||
(https://git.eeqj.de/sneak/secret/issues/151). They asked first, and
|
||||
`secret init` printed "Initialized secrets manager at" before refusing. The
|
||||
check `vault.CreateVault` made before writing is now
|
||||
`vault.CheckNewVaultName`, which both commands call before the first prompt
|
||||
and `CreateVault` still calls. It validates the name too, so
|
||||
`secret vault create` with an invalid name also fails before the prompts.
|
||||
`secret init` prints the "Initialized" line only once the vault is created.
|
||||
- 2026-10-14: `secret get` fails when it cannot write the value
|
||||
(https://git.eeqj.de/sneak/secret/issues/155). `GetSecret` and
|
||||
`GetSecretWithVersion` discarded the result of writing it to stdout, so a full
|
||||
disk or a failing stdout left an empty or cut-short file and exit status 0.
|
||||
The write error is now returned, so the command prints it and exits 1. A
|
||||
stdout the shell closed before `secret` started still exits 0, because the Go
|
||||
runtime opens `/dev/null` in its place and the write there succeeds.
|
||||
- 2026-10-14: `secret list` and `secret list --json` give each secret's own
|
||||
creation and last-update times (https://git.eeqj.de/sneak/secret/issues/142).
|
||||
They gave the time of the listing for every secret, because
|
||||
`Secret.LoadMetadata` set both to the current time. It now reads file
|
||||
modification times, so listing still needs no passphrase. The last update is
|
||||
when the secret's `current` file was written. The creation is when its oldest
|
||||
version's `value.age` was written, not that version's directory, whose time
|
||||
changes when the next add rewrites its `metadata.age`. A move to another vault
|
||||
gives the copied files the times of the originals. `GetSecretObject` no longer
|
||||
loads the times, so `secret get` keeps its error for a missing `current` file.
|
||||
- 2026-10-14: `secret unlocker add pgp` without `--keyid` uses the `default-key`
|
||||
in `gpg.conf` (https://git.eeqj.de/sneak/secret/issues/141).
|
||||
`getDefaultGPGKey` took that value from `gpgconf --list-options gpg` as
|
||||
printed, with a leading `"` and percent-escaped, so gpg rejected the key ID.
|
||||
The new `parseDefaultGPGKey` drops the `"` and percent-decodes the rest with
|
||||
`url.PathUnescape`, which leaves `+` alone. A value that does not decode is an
|
||||
error, not a fallback to the first secret key.
|
||||
- 2026-10-14: A wrong passphrase, from `SB_UNLOCK_PASSPHRASE` or typed at the
|
||||
prompt, gives the error `wrong passphrase for unlocker <id>`
|
||||
(https://git.eeqj.de/sneak/secret/issues/157). It used to be age's "no
|
||||
|
||||
@@ -3,16 +3,10 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Every time secret prints or writes is in UTC, whatever TZ or the
|
||||
// system's time zone is set to. It is set here, before cli.Entry starts
|
||||
// any goroutine that could read time.Local while it changes.
|
||||
time.Local = time.UTC //nolint:gosmopolitan // this makes local time UTC
|
||||
|
||||
os.Exit(cli.Entry())
|
||||
}
|
||||
|
||||
+12
-51
@@ -3,7 +3,6 @@ package cli_test
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -17,67 +16,29 @@ import (
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
// envRunEntry, when set, makes this test binary run cli.Entry with its
|
||||
// own arguments and exit, instead of running the tests.
|
||||
envRunEntry = "SECRET_TEST_RUN_ENTRY"
|
||||
|
||||
// bufferWiped is what the test binary prints, run with envRunEntry set,
|
||||
// when cli.Entry wiped the buffer made before it ran.
|
||||
bufferWiped = "Entry wiped the buffer made before it ran"
|
||||
)
|
||||
|
||||
// With envRunEntry set, the test binary makes a buffer, runs cli.Entry,
|
||||
// says whether the buffer is wiped, and exits with Entry's exit code. This
|
||||
// is done in init, not in a test, so that TestMain does not run in that
|
||||
// process: it would build the secret binary again, and remove it at the end,
|
||||
// while the other tests run it.
|
||||
//
|
||||
//nolint:gochecknoinits // must run before TestMain, as above
|
||||
func init() {
|
||||
if os.Getenv(envRunEntry) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
buf := memguard.NewBufferFromBytes([]byte("key material"))
|
||||
code := cli.Entry()
|
||||
|
||||
if !buf.IsAlive() {
|
||||
fmt.Fprintln(os.Stderr, bufferWiped)
|
||||
}
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// Entry must return its exit code rather than exit, so that its deferred
|
||||
// memguard purge runs on the success and the error path alike. Entry wipes
|
||||
// every memguard buffer in the process, including those other tests are
|
||||
// using, so it runs in a process of its own: this test binary, run again
|
||||
// with envRunEntry set.
|
||||
// memguard purge runs on the success and the error path alike.
|
||||
//
|
||||
//nolint:paralleltest // sets os.Args, and Entry wipes every buffer in the process
|
||||
func TestEntryWipesBuffersAndReturnsExitCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
savedArgs := os.Args
|
||||
|
||||
t.Cleanup(func() { os.Args = savedArgs })
|
||||
|
||||
tests := []struct {
|
||||
args []string
|
||||
exitCode int
|
||||
}{
|
||||
{args: []string{"--help"}, exitCode: 0},
|
||||
{args: []string{"no-such-command"}, exitCode: 1},
|
||||
{args: []string{"secret", "--help"}, exitCode: 0},
|
||||
{args: []string{"secret", "no-such-command"}, exitCode: 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
//nolint:gosec // G204: test runs its own test binary
|
||||
cmd := exec.CommandContext(t.Context(), os.Args[0], tt.args...)
|
||||
buf := memguard.NewBufferFromBytes([]byte("key material"))
|
||||
os.Args = tt.args
|
||||
|
||||
cmd.Env = append(os.Environ(), envRunEntry+"=1")
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NotNil(t, cmd.ProcessState, "%v did not run: %v", tt.args, err)
|
||||
|
||||
assert.Equal(t, tt.exitCode, cmd.ProcessState.ExitCode(),
|
||||
"exit code for %v: %s", tt.args, output)
|
||||
assert.Contains(t, string(output), bufferWiped,
|
||||
"Entry left a buffer unwiped for %v", tt.args)
|
||||
assert.Equal(t, tt.exitCode, cli.Entry(), "exit code for %v", tt.args)
|
||||
assert.False(t, buf.IsAlive(), "Entry left a buffer unwiped for %v", tt.args)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,9 @@ const (
|
||||
testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// commandWait is how long a test lets the secret binary run before it
|
||||
// kills it and fails. It stays well under the time limit the test phase
|
||||
// of the Dockerfile gives the whole package (go test -timeout), so a
|
||||
// command that hangs fails the test with the test's own message instead
|
||||
// of Go's timeout panic.
|
||||
// kills it and fails. It stays well under the 30 seconds script/test
|
||||
// gives the whole package, so a command that hangs fails the test with
|
||||
// the test's own message instead of Go's timeout panic.
|
||||
commandWait = 10 * time.Second
|
||||
)
|
||||
|
||||
|
||||
@@ -627,10 +627,6 @@ func TestCommandsCheckAgainOnceLocked(t *testing.T) {
|
||||
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
otherMnemonic := memguard.NewBufferFromBytes([]byte("legal winner thank " +
|
||||
"year wave sausage worth useful legal winner thank yellow"))
|
||||
t.Cleanup(otherMnemonic.Destroy)
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(passphrase.Destroy)
|
||||
|
||||
@@ -662,26 +658,6 @@ func TestCommandsCheckAgainOnceLocked(t *testing.T) {
|
||||
},
|
||||
vault.ErrVaultNotFound,
|
||||
},
|
||||
{
|
||||
"unlocker add, vault created again from another mnemonic",
|
||||
func(cli *Instance) error {
|
||||
cli.Mnemonic, cli.UnlockPassphrase = mnemonic, passphrase
|
||||
|
||||
return cli.UnlockersAdd("passphrase", cli.cmd)
|
||||
},
|
||||
func(fs afero.Fs) error {
|
||||
err := fs.RemoveAll(testStateDir + "/vaults.d/work")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = vault.CreateVault(fs, testStateDir, "work",
|
||||
otherMnemonic, passphrase)
|
||||
|
||||
return err
|
||||
},
|
||||
errVaultCreatedAgain,
|
||||
},
|
||||
{
|
||||
"unlocker add pgp, key added",
|
||||
func(cli *Instance) error {
|
||||
|
||||
@@ -5,13 +5,11 @@ import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
@@ -94,8 +92,7 @@ func requireValue(t *testing.T, fs afero.Fs, vaultName, secretName string) {
|
||||
// vaults whose source could not be removed reported the secret moved and
|
||||
// succeeded. It must fail, and the copy and the source must both be there.
|
||||
// The state directory lock refuses the wrapped filesystem, so the test
|
||||
// calls copySecretAndRemoveSource, which a move between vaults runs once it
|
||||
// holds the lock.
|
||||
// calls moveSecret, which MoveSecret runs once it holds the lock.
|
||||
func TestCrossVaultMoveFailsWhenSourceRemains(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -106,12 +103,9 @@ func TestCrossVaultMoveFailsWhenSourceRemains(t *testing.T) {
|
||||
Fs: base,
|
||||
path: testStateDir + "/vaults.d/default/secrets.d/x",
|
||||
}, testStateDir)
|
||||
instance.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
source := vault.NewVault(instance.fs, testStateDir, "default")
|
||||
source.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
err := instance.copySecretAndRemoveSource(&cobra.Command{}, source, "x",
|
||||
vault.NewVault(instance.fs, testStateDir, "work"), "y", false)
|
||||
err := instance.moveSecret(&cobra.Command{}, "default:x", "work:y", false)
|
||||
require.ErrorIs(t, err, errPermissionDenied)
|
||||
require.ErrorContains(t, err, "secret 'default:x' is copied with all "+
|
||||
"1 version(s) to 'work:y', but the source remains")
|
||||
@@ -134,12 +128,9 @@ func TestCrossVaultMoveFailsWhenOldFilesRemain(t *testing.T) {
|
||||
fs := &removeAllFailFs{Fs: base, dir: sourceVaultDir}
|
||||
|
||||
instance := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
instance.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
source := vault.NewVault(fs, testStateDir, "default")
|
||||
source.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
err := instance.copySecretAndRemoveSource(&cobra.Command{}, source, "x",
|
||||
vault.NewVault(fs, testStateDir, "work"), "y", false)
|
||||
err := instance.moveSecret(&cobra.Command{}, "default:x", "work:y", false)
|
||||
require.ErrorIs(t, err, errPermissionDenied)
|
||||
require.ErrorContains(t, err, "secret 'default:x' is moved with all "+
|
||||
"1 version(s) to 'work:y', but its old files could not all be deleted")
|
||||
@@ -159,157 +150,3 @@ func TestCrossVaultMoveFailsWhenOldFilesRemain(t *testing.T) {
|
||||
|
||||
requireValue(t, base, "work", "y")
|
||||
}
|
||||
|
||||
// TestCrossVaultMoveUnlocksBeforeLocking is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/189: a move between two vaults
|
||||
// unlocked them while holding the state directory lock, and unlocking a
|
||||
// vault can ask for its passphrase, so every other command that changes
|
||||
// anything waited while the user answered. Here each vault has a passphrase
|
||||
// unlocker whose passphrase is the vault's name, and the move is given the
|
||||
// passphrase of only one of them. Run while the test holds the lock, it
|
||||
// must fail to unlock the other vault without waiting for the lock.
|
||||
//
|
||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||
func TestCrossVaultMoveUnlocksBeforeLocking(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
createCrossVaults(t, fs)
|
||||
|
||||
vaults, err := vault.ListVaults(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, name := range vaults {
|
||||
vlt := vault.NewVault(fs, testStateDir, name)
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(name))
|
||||
_, err = vlt.CreatePassphraseUnlocker(passphrase)
|
||||
passphrase.Destroy()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// The move is from "default" to "work"
|
||||
for _, tc := range []struct {
|
||||
// failing is the vault that the move cannot unlock, given the
|
||||
// passphrase of the vault named given
|
||||
failing, given string
|
||||
}{
|
||||
{"source", "work"},
|
||||
{"destination", "default"},
|
||||
} {
|
||||
t.Run(tc.failing, func(t *testing.T) {
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(tc.given))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
instance := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
instance.UnlockPassphrase = passphrase
|
||||
|
||||
release, err := vault.LockStateDir(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
// Also frees a waiting move if the test fails, so that it
|
||||
// releases the lock the other tests use
|
||||
defer release()
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
done <- instance.MoveSecret(&cobra.Command{},
|
||||
"default:x", "work:y", false)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||
require.ErrorContains(t, err,
|
||||
"failed to unlock "+tc.failing+" vault")
|
||||
case <-time.After(lockWait):
|
||||
t.Fatal("waited for the lock before unlocking the " +
|
||||
tc.failing + " vault")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossVaultMoveChecksAgainOnceLocked runs a move between vaults, given
|
||||
// the mnemonic, as TestCommandsCheckAgainOnceLocked runs the commands in its
|
||||
// table: once the move waits for the state directory lock, the test removes
|
||||
// the source secret or the destination vault, or creates the destination
|
||||
// vault again from another mnemonic, and the move must then fail and change
|
||||
// nothing.
|
||||
//
|
||||
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
||||
func TestCrossVaultMoveChecksAgainOnceLocked(t *testing.T) {
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
otherMnemonic := memguard.NewBufferFromBytes([]byte("legal winner thank " +
|
||||
"year wave sausage worth useful legal winner thank yellow"))
|
||||
t.Cleanup(otherMnemonic.Destroy)
|
||||
|
||||
move := func(cli *Instance) error {
|
||||
cli.Mnemonic = mnemonic
|
||||
|
||||
return cli.MoveSecret(cli.cmd, "work:test/secret", "other:moved", false)
|
||||
}
|
||||
|
||||
t.Run("source removed", func(t *testing.T) {
|
||||
requireChecksAgainOnceLocked(t, move, func(fs afero.Fs) error {
|
||||
return fs.RemoveAll(testStateDir + "/vaults.d/work/secrets.d/test%secret")
|
||||
}, vault.ErrSecretNotFound)
|
||||
})
|
||||
|
||||
t.Run("destination vault removed", func(t *testing.T) {
|
||||
requireChecksAgainOnceLocked(t, move, func(fs afero.Fs) error {
|
||||
return fs.RemoveAll(testStateDir + "/vaults.d/other")
|
||||
}, vault.ErrVaultNotFound)
|
||||
})
|
||||
|
||||
t.Run("destination vault created again from another mnemonic", func(t *testing.T) {
|
||||
// Set by the move, which has finished once
|
||||
// requireChecksAgainOnceLocked returns
|
||||
var moveErr error
|
||||
|
||||
requireChecksAgainOnceLocked(t, func(cli *Instance) error {
|
||||
moveErr = move(cli)
|
||||
|
||||
return moveErr
|
||||
}, func(fs afero.Fs) error {
|
||||
err := fs.RemoveAll(testStateDir + "/vaults.d/other")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = vault.CreateVault(fs, testStateDir, "other", otherMnemonic, nil)
|
||||
|
||||
return err
|
||||
}, errVaultCreatedAgain)
|
||||
|
||||
// secret unlocker add fails with the same error, so its message
|
||||
// must fit a move too
|
||||
require.EqualError(t, moveErr, "vault 'other' was removed and "+
|
||||
"created again with another key while it was being unlocked; "+
|
||||
"nothing was changed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossVaultMoveRefusesExistingSecretBeforeUnlocking checks that a move
|
||||
// between vaults onto a secret that exists, without --force, is refused
|
||||
// before either vault is unlocked, since unlocking can ask for a
|
||||
// passphrase. The vaults have no unlockers and the move is given no
|
||||
// mnemonic, so unlocking either vault fails.
|
||||
func TestCrossVaultMoveRefusesExistingSecretBeforeUnlocking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
createCrossVaults(t, fs)
|
||||
|
||||
work := vault.NewVault(fs, testStateDir, "work")
|
||||
work.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
err := work.AddSecret("y",
|
||||
memguard.NewBufferFromBytes([]byte("other")), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
instance := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
err = instance.MoveSecret(&cobra.Command{}, "default:x", "work:y", false)
|
||||
require.ErrorIs(t, err, vault.ErrSecretExists)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -14,6 +15,10 @@ import (
|
||||
// every memguard buffer before it returns, so the caller must do nothing
|
||||
// but exit with the code.
|
||||
func Entry() int {
|
||||
// Every time secret prints or writes is in UTC, whatever TZ or the
|
||||
// system's time zone is set to.
|
||||
time.Local = time.UTC //nolint:gosmopolitan // this makes local time UTC
|
||||
|
||||
raiseLockedMemoryLimit()
|
||||
|
||||
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
|
||||
|
||||
+53
-123
@@ -875,12 +875,24 @@ func (cli *Instance) findSecretToRemove(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MoveSecret moves or renames a secret (within or across vaults). It
|
||||
// leaves taking the state directory lock to moveSecretWithinVault and
|
||||
// moveSecretCrossVault, since a move between vaults unlocks both vaults
|
||||
// before it takes the lock.
|
||||
// MoveSecret moves or renames a secret (within or across vaults), holding
|
||||
// the state directory lock.
|
||||
func (cli *Instance) MoveSecret(
|
||||
cmd *cobra.Command, source, dest string, force bool,
|
||||
) error {
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
return cli.moveSecret(cmd, source, dest, force)
|
||||
}
|
||||
|
||||
// moveSecret does the work of MoveSecret. The caller holds the state
|
||||
// directory lock.
|
||||
func (cli *Instance) moveSecret(
|
||||
cmd *cobra.Command, source, dest string, force bool,
|
||||
) error {
|
||||
// Parse source and destination
|
||||
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
|
||||
@@ -980,9 +992,8 @@ func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
|
||||
return vault.NewVault(cli.fs, cli.stateDir, name), nil
|
||||
}
|
||||
|
||||
// moveSecretWithinVault renames a secret within the vault vlt, holding the
|
||||
// state directory lock. Its caller, MoveSecret, has already checked both
|
||||
// secret names.
|
||||
// moveSecretWithinVault renames a secret within the vault vlt. Its caller,
|
||||
// MoveSecret, has already checked both secret names.
|
||||
func (cli *Instance) moveSecretWithinVault(
|
||||
cmd *cobra.Command, vlt *vault.Vault, source, dest string, force bool,
|
||||
) error {
|
||||
@@ -992,12 +1003,6 @@ func (cli *Instance) moveSecretWithinVault(
|
||||
return fmt.Errorf("secret '%s' %w", source, errMoveOntoItself)
|
||||
}
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1084,75 +1089,59 @@ func (cli *Instance) sameDirectory(dir, other string) (bool, error) {
|
||||
|
||||
// moveSecretCrossVault handles moving between two different vaults. Its
|
||||
// caller, MoveSecret, has already checked both secret names and that both
|
||||
// vaults exist. The copy needs the source vault's long-term key and is
|
||||
// encrypted to the destination vault's long-term public key. The
|
||||
// destination vault is unlocked to check that the copy will open with what
|
||||
// the user gave, and, once the state directory lock is held, that it still
|
||||
// has the key unlocked. Either unlock can ask for a passphrase, so both
|
||||
// happen before the lock is taken, and no other command waits while the
|
||||
// user answers.
|
||||
// vaults exist.
|
||||
func (cli *Instance) moveSecretCrossVault(
|
||||
cmd *cobra.Command,
|
||||
srcVault *vault.Vault, srcSecretName string,
|
||||
destVault *vault.Vault, destSecretName string,
|
||||
force bool,
|
||||
) error {
|
||||
// Before unlocking, check that the source secret exists, that the
|
||||
// destination is not the source under another name, and, without
|
||||
// --force, that the destination secret does not exist
|
||||
_, err := cli.checkCrossVaultMove(
|
||||
srcVault, srcSecretName, destVault, destSecretName, force)
|
||||
srcVaultDir, err := srcVault.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get source vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Verify source secret exists
|
||||
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
||||
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, srcSecretDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("secret '%s' %w in vault '%s'",
|
||||
srcSecretName, vault.ErrSecretNotFound, srcVault.Name)
|
||||
}
|
||||
|
||||
// The source is removed after the copy, so a destination that is the
|
||||
// source under another name would be lost with it.
|
||||
destVaultDir, err := destVault.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||
}
|
||||
|
||||
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||
|
||||
same, err := cli.sameDirectory(srcSecretDir, destSecretDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if same {
|
||||
return fmt.Errorf("secret '%s:%s' %w: '%s:%s' is the same secret on "+
|
||||
"this filesystem", srcVault.Name, srcSecretName, errMoveOntoItself,
|
||||
destVault.Name, destSecretName)
|
||||
}
|
||||
|
||||
// Copying needs the long-term keys of both vaults
|
||||
srcVault.Mnemonic, srcVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
||||
destIdentity, err := destVault.GetOrDeriveLongTermKey()
|
||||
_, err = destVault.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVault.Name, err)
|
||||
}
|
||||
|
||||
_, err = srcVault.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unlock source vault '%s': %w",
|
||||
srcVault.Name, err)
|
||||
}
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
err = cli.checkLongTermKey(destVault, destIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return cli.copySecretAndRemoveSource(
|
||||
cmd, srcVault, srcSecretName, destVault, destSecretName, force)
|
||||
}
|
||||
|
||||
// copySecretAndRemoveSource copies the secret srcSecretName of srcVault,
|
||||
// with all its versions, to destSecretName in destVault, and removes the
|
||||
// source. moveSecretCrossVault calls it holding the state directory lock.
|
||||
// Another command may have changed either vault since moveSecretCrossVault
|
||||
// checked them, so it checks them again first.
|
||||
func (cli *Instance) copySecretAndRemoveSource(
|
||||
cmd *cobra.Command,
|
||||
srcVault *vault.Vault, srcSecretName string,
|
||||
destVault *vault.Vault, destSecretName string,
|
||||
force bool,
|
||||
) error {
|
||||
srcSecretDir, err := cli.checkCrossVaultMove(
|
||||
srcVault, srcSecretName, destVault, destSecretName, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Count versions for user feedback
|
||||
versions, _ := secret.ListVersions(cli.fs, srcSecretDir)
|
||||
versionCount := len(versions)
|
||||
@@ -1178,65 +1167,6 @@ func (cli *Instance) copySecretAndRemoveSource(
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCrossVaultMove checks that the secret srcSecretName exists in
|
||||
// srcVault, that destSecretName in destVault is not the source under
|
||||
// another name, and, unless force is set, that destSecretName does not
|
||||
// exist in destVault. It returns the source's directory.
|
||||
func (cli *Instance) checkCrossVaultMove(
|
||||
srcVault *vault.Vault, srcSecretName string,
|
||||
destVault *vault.Vault, destSecretName string,
|
||||
force bool,
|
||||
) (string, error) {
|
||||
srcVaultDir, err := srcVault.GetDirectory()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get source vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Verify source secret exists
|
||||
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
||||
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, srcSecretDir)
|
||||
if err != nil || !exists {
|
||||
return "", fmt.Errorf("secret '%s' %w in vault '%s'",
|
||||
srcSecretName, vault.ErrSecretNotFound, srcVault.Name)
|
||||
}
|
||||
|
||||
// The source is removed after the copy, so a destination that is the
|
||||
// source under another name would be lost with it.
|
||||
destVaultDir, err := destVault.GetDirectory()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||
}
|
||||
|
||||
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||
|
||||
same, err := cli.sameDirectory(srcSecretDir, destSecretDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if same {
|
||||
return "", fmt.Errorf("secret '%s:%s' %w: '%s:%s' is the same secret "+
|
||||
"on this filesystem", srcVault.Name, srcSecretName, errMoveOntoItself,
|
||||
destVault.Name, destSecretName)
|
||||
}
|
||||
|
||||
exists, err = afero.DirExists(cli.fs, destSecretDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check if destination secret exists: %w", err)
|
||||
}
|
||||
|
||||
if exists && !force {
|
||||
return "", fmt.Errorf(
|
||||
"secret '%s' %w in vault '%s' (use --force to overwrite)",
|
||||
destSecretName, vault.ErrSecretExists, destVault.Name)
|
||||
}
|
||||
|
||||
return srcSecretDir, nil
|
||||
}
|
||||
|
||||
// sourceRemovalError returns the error of a move between vaults that
|
||||
// copied all versionCount versions of the secret srcName to destName, and
|
||||
// then failed with removeErr to remove srcSecretDir, the directory of
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
@@ -40,10 +39,6 @@ var (
|
||||
// composes "GPG key <id> is already added as an unlocker".
|
||||
errGPGKeyAlreadyUnlocker = errors.New(
|
||||
"is already added as an unlocker")
|
||||
// errVaultCreatedAgain carries only the message tail; the caller
|
||||
// composes "vault '<name>' was removed and created again ...".
|
||||
errVaultCreatedAgain = errors.New("was removed and created again " +
|
||||
"with another key while it was being unlocked; nothing was changed")
|
||||
)
|
||||
|
||||
// UnlockerInfo represents unlocker information for display
|
||||
@@ -429,11 +424,8 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
|
||||
// command waits while the user answers. Once the lock is held, it fails if
|
||||
// another command has removed the vault in the meantime, since writing the
|
||||
// unlocker would create the vault's directory again, holding nothing else.
|
||||
// It also fails if the vault was removed and created again with another
|
||||
// long-term key, which the unlocker would then hold instead of the new
|
||||
// vault's own.
|
||||
func (cli *Instance) unlockThenLock(vlt *vault.Vault) (func(), error) {
|
||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||
_, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
@@ -443,7 +435,7 @@ func (cli *Instance) unlockThenLock(vlt *vault.Vault) (func(), error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = cli.checkLongTermKey(vlt, ltIdentity)
|
||||
_, err = cli.existingVault(vlt.GetName())
|
||||
if err != nil {
|
||||
release()
|
||||
|
||||
@@ -453,33 +445,6 @@ func (cli *Instance) unlockThenLock(vlt *vault.Vault) (func(), error) {
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// checkLongTermKey checks, for the vault vlt unlocked as ltIdentity before
|
||||
// the state directory lock was taken, that vlt still exists and that its
|
||||
// long-term public key is still that of ltIdentity. Another command may have
|
||||
// removed the vault in the meantime, or removed it and created it again with
|
||||
// another long-term key. The caller holds the lock.
|
||||
func (cli *Instance) checkLongTermKey(
|
||||
vlt *vault.Vault, ltIdentity *age.X25519Identity,
|
||||
) error {
|
||||
_, err := cli.existingVault(vlt.GetName())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
|
||||
ltPubKey, err := afero.ReadFile(cli.fs, filepath.Join(vaultDir, "pub.age"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
}
|
||||
|
||||
if string(ltPubKey) != ltIdentity.Recipient().String() {
|
||||
return fmt.Errorf("vault '%s' %w", vlt.GetName(), errVaultCreatedAgain)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// autoSelectUnlocker selects the newly created unlocker as current,
|
||||
// printing a warning if selection fails
|
||||
func autoSelectUnlocker(cmd *cobra.Command, vlt *vault.Vault, unlockerID string) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1"
|
||||
}
|
||||
|
||||
+26
-46
@@ -2,18 +2,18 @@
|
||||
# script/bootstrap: install all dependencies needed to build and develop
|
||||
# this repo. Idempotent: every install is guarded by a check so already
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes nothing is present. The
|
||||
# installed node is used only when its major version is the pinned one;
|
||||
# otherwise (node missing, or another major) the pinned version is
|
||||
# installed via nvm (installing nvm itself first, from a hash-verified
|
||||
# release archive, never curl | sh), and yarn and the packages are
|
||||
# installed under it.
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, node, yarn, go, or python). Node is used directly if installed;
|
||||
# otherwise a pinned version is installed via nvm (installing nvm
|
||||
# itself first, from a hash-verified release archive, never curl | sh).
|
||||
# golangci-lint is never installed: script/lint runs it in docker.
|
||||
#
|
||||
# Uncomment the language sections in main() that apply to this repo.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Pinned versions, 2026-07-06
|
||||
# Pinned versions, 2026-07-06. Never "latest" or "lts"; exact versions.
|
||||
NODE_VERSION="22.17.0"
|
||||
NVM_VERSION="0.40.3"
|
||||
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
|
||||
@@ -22,7 +22,6 @@ YARN_VERSION="1.22.22"
|
||||
|
||||
PKGMGR=""
|
||||
SUDO=""
|
||||
APT_UPDATED=""
|
||||
|
||||
detect_pkgmgr() {
|
||||
[ -n "$PKGMGR" ] && return 0
|
||||
@@ -51,14 +50,7 @@ pkg_install() {
|
||||
detect_pkgmgr
|
||||
case "$PKGMGR" in
|
||||
nix) nix-env -iA "nixpkgs.$1" ;;
|
||||
apt)
|
||||
# Package lists may be empty (fresh images); refresh once per run.
|
||||
if [ -z "$APT_UPDATED" ]; then
|
||||
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
APT_UPDATED=1
|
||||
fi
|
||||
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
|
||||
;;
|
||||
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
|
||||
brew) brew install "$3" ;;
|
||||
apk) apk add --no-cache "$4" ;;
|
||||
esac
|
||||
@@ -90,7 +82,7 @@ nvm_sh() {
|
||||
|
||||
ensure_nvm() {
|
||||
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
|
||||
# nvm prerequisites; nvm itself requires bash
|
||||
# nvm prerequisites; nvm itself requires bash, so install it too
|
||||
if missing bash; then pkg_install bash bash bash bash; fi
|
||||
if missing curl; then pkg_install curl curl curl curl; fi
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
@@ -103,68 +95,56 @@ ensure_nvm() {
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
# True when the node on PATH has the pinned major version. Only the
|
||||
# major is compared: the Dockerfile stages start from a node 22 alpine
|
||||
# image whose exact version is not the pin, and nvm cannot install a
|
||||
# prebuilt node on alpine. Another major is not used: the pinned yarn 1
|
||||
# prints a deprecation warning under node 24. script/fmt and
|
||||
# script/fmt-check carry this function unchanged, to run the yarn
|
||||
# installed here.
|
||||
node_is_pinned_major() {
|
||||
if ! command -v node >/dev/null 2>&1; then return 1; fi
|
||||
installed="$(node --version)"
|
||||
installed="${installed#v}"
|
||||
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
|
||||
}
|
||||
|
||||
ensure_node() {
|
||||
if node_is_pinned_major; then
|
||||
echo "bootstrap: using node $(node --version)"
|
||||
return 0
|
||||
fi
|
||||
if ! missing node; then return 0; fi
|
||||
ensure_nvm
|
||||
nvm_sh "nvm install $NODE_VERSION"
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && \
|
||||
echo \"bootstrap: using node \$(node --version)\""
|
||||
}
|
||||
|
||||
ensure_yarn() {
|
||||
if ! node_is_pinned_major; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
||||
corepack prepare yarn@$YARN_VERSION --activate"
|
||||
return 0
|
||||
fi
|
||||
if ! missing yarn; then return 0; fi
|
||||
if ! missing corepack; then
|
||||
corepack enable
|
||||
corepack prepare "yarn@$YARN_VERSION" --activate
|
||||
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
||||
corepack prepare yarn@$YARN_VERSION --activate"
|
||||
else
|
||||
npm install -g "yarn@$YARN_VERSION"
|
||||
fi
|
||||
}
|
||||
|
||||
install_js_deps() {
|
||||
if node_is_pinned_major; then
|
||||
yarn install --frozen-lockfile
|
||||
else
|
||||
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
||||
yarn install --frozen-lockfile"
|
||||
else
|
||||
yarn install --frozen-lockfile
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
if missing make; then pkg_install gnumake make make make; fi
|
||||
# Base tooling (every repo)
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
if missing make; then pkg_install gnumake make make make; fi
|
||||
|
||||
# ---- JS / docs repos ----
|
||||
# prettier, pinned in package.json and yarn.lock, formats the markdown
|
||||
ensure_node
|
||||
ensure_yarn
|
||||
install_js_deps
|
||||
|
||||
# ---- Go repos ----
|
||||
if missing go; then pkg_install go golang go go; fi
|
||||
go mod download
|
||||
|
||||
# ---- Python repos ----
|
||||
# if missing python3; then pkg_install python3 python3 python3 python3; fi
|
||||
# python3 -m venv .venv
|
||||
# ./.venv/bin/pip install -e '.[dev]'
|
||||
|
||||
echo "bootstrap complete"
|
||||
}
|
||||
|
||||
|
||||
+9
-13
@@ -2,11 +2,9 @@
|
||||
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
||||
# checks out and runs this and nothing else, and script/fmt-check runs
|
||||
# the formatter on the host, which a pristine checkout cannot do.
|
||||
# A new CACHEBUST value reruns every RUN step, since a cached gate phase
|
||||
# is a check that did not run; unlike --no-cache, it keeps Go's build cache.
|
||||
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
||||
# first; a stage without that line is served from cache on an unchanged
|
||||
# tree, with no warning.
|
||||
# --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
|
||||
# did not run.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -16,17 +14,15 @@ main() {
|
||||
cd "$ROOT"
|
||||
"$SCRIPT_DIR/bootstrap"
|
||||
"$SCRIPT_DIR/check"
|
||||
# The version and the tag each get their own line: a failing
|
||||
# command substitution inside an argument does not trip `set -e`,
|
||||
# so the inline form degrades silently to an empty constant. The
|
||||
# VERSION build argument takes precedence over the version a build
|
||||
# stage derives from the .git in the context.
|
||||
# Own line: a failing command substitution inside an argument does
|
||||
# not trip `set -e`, so the inline form degrades silently to an
|
||||
# empty constant. The VERSION build argument takes precedence over
|
||||
# the version a build stage derives from the .git in the context.
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
[ -n "$version" ] || version="unknown"
|
||||
tag="$("$SCRIPT_DIR/projectname")"
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
docker build --no-cache \
|
||||
--build-arg VERSION="$version" \
|
||||
-t "$tag" .
|
||||
-t "$("$SCRIPT_DIR/projectname")" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+8
-13
@@ -1,11 +1,8 @@
|
||||
#!/bin/sh
|
||||
# script/docker: build the Docker image tagged with the project name.
|
||||
# Identical in all repos; the tag comes from script/projectname.
|
||||
# A new CACHEBUST value reruns every RUN step, since a cached gate phase
|
||||
# is a check that did not run; unlike --no-cache, it keeps Go's build cache.
|
||||
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
||||
# first; a stage without that line is served from cache on an unchanged
|
||||
# tree, with no warning.
|
||||
# --no-cache because the gate phases the final stage depends on are RUN
|
||||
# steps, and a cached one is a check that did not run.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -13,17 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# The version and the tag each get their own line: a failing
|
||||
# command substitution inside an argument does not trip `set -e`,
|
||||
# so the inline form degrades silently to an empty constant. The
|
||||
# VERSION build argument takes precedence over the version a build
|
||||
# stage derives from the .git in the context.
|
||||
# Own line: a failing command substitution inside an argument does
|
||||
# not trip `set -e`, so the inline form degrades silently to an
|
||||
# empty constant. The VERSION build argument takes precedence over
|
||||
# the version a build stage derives from the .git in the context.
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
[ -n "$version" ] || version="unknown"
|
||||
tag="$("$SCRIPT_DIR/projectname")"
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
docker build --no-cache \
|
||||
--build-arg VERSION="$version" \
|
||||
-t "$tag" .
|
||||
-t "$("$SCRIPT_DIR/projectname")" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+6
-16
@@ -8,25 +8,15 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
# Must match the pin in script/bootstrap.
|
||||
NODE_VERSION="22.17.0"
|
||||
|
||||
# True when the node on PATH has the pinned major version: the same
|
||||
# function as in script/bootstrap, which uses it to decide where it
|
||||
# installs yarn.
|
||||
node_is_pinned_major() {
|
||||
if ! command -v node >/dev/null 2>&1; then return 1; fi
|
||||
installed="$(node --version)"
|
||||
installed="${installed#v}"
|
||||
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
|
||||
}
|
||||
|
||||
# Run the yarn script/bootstrap installed: the yarn on PATH when the
|
||||
# node on PATH has the pinned major version, and otherwise yarn under
|
||||
# the pinned node in nvm, which bootstrap leaves off the PATH of the
|
||||
# shell that called it. nvm is a bash script, hence the subshell.
|
||||
# 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 node_is_pinned_major && command -v yarn >/dev/null 2>&1; then
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
exec yarn "$@"
|
||||
fi
|
||||
if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
echo "fmt: no yarn; run script/bootstrap first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+6
-16
@@ -8,25 +8,15 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
# Must match the pin in script/bootstrap.
|
||||
NODE_VERSION="22.17.0"
|
||||
|
||||
# True when the node on PATH has the pinned major version: the same
|
||||
# function as in script/bootstrap, which uses it to decide where it
|
||||
# installs yarn.
|
||||
node_is_pinned_major() {
|
||||
if ! command -v node >/dev/null 2>&1; then return 1; fi
|
||||
installed="$(node --version)"
|
||||
installed="${installed#v}"
|
||||
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
|
||||
}
|
||||
|
||||
# Run the yarn script/bootstrap installed: the yarn on PATH when the
|
||||
# node on PATH has the pinned major version, and otherwise yarn under
|
||||
# the pinned node in nvm, which bootstrap leaves off the PATH of the
|
||||
# shell that called it. nvm is a bash script, hence the subshell.
|
||||
# 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 node_is_pinned_major && command -v yarn >/dev/null 2>&1; then
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
exec yarn "$@"
|
||||
fi
|
||||
if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
echo "fmt-check: no yarn; run script/bootstrap first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# script/install-precommit: install the git pre-commit hook that runs
|
||||
# script/precommit. Our own extension to scripts-to-rule-them-all.
|
||||
# Generic: needs no adaptation.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
+5
-8
@@ -5,12 +5,9 @@
|
||||
# make its answer untrustworthy.
|
||||
#
|
||||
# The phase is not the last stage in the file, so it is built only when
|
||||
# --target names it. A new CACHEBUST value reruns every RUN step, since
|
||||
# a cached lint layer is a lint that did not run; unlike --no-cache, it
|
||||
# keeps Go's build cache. This relies on every stage of the Dockerfile
|
||||
# declaring ARG CACHEBUST first; a stage without that line is served
|
||||
# from cache on an unchanged tree, with no warning.
|
||||
# --output type=cacheonly writes no image, since nothing uses one.
|
||||
# --target names it. --no-cache because a cached lint layer is a lint
|
||||
# that did not run. The tag makes each build replace the previous image
|
||||
# instead of leaving a dangling one behind.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -18,9 +15,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
docker build --no-cache \
|
||||
--target lint \
|
||||
--output type=cacheonly .
|
||||
-t "$("$SCRIPT_DIR/projectname")-lint" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# script/setup: set up the repo for development after a fresh clone:
|
||||
# installs dependencies and the git pre-commit hook.
|
||||
# installs dependencies (script/bootstrap) and the git pre-commit hook.
|
||||
# Add any repo-specific initialization (db init, .env template) here.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
+4
-8
@@ -2,12 +2,8 @@
|
||||
# script/test: run the test suite. Testing is a phase of the Dockerfile
|
||||
# and this builds that phase alone, on the same terms as script/lint:
|
||||
# --target because a phase that is not the last stage is built only when
|
||||
# named, and a new CACHEBUST value because a cached test layer is a test
|
||||
# that did not run; unlike --no-cache, it keeps Go's build cache. This
|
||||
# relies on every stage of the Dockerfile declaring ARG CACHEBUST first;
|
||||
# a stage without that line is served from cache on an unchanged tree,
|
||||
# with no warning. --output type=cacheonly writes no image, since
|
||||
# nothing uses one.
|
||||
# named, --no-cache because a cached test layer is a test that did not
|
||||
# run, and a tag so each build replaces the previous image.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -15,9 +11,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
|
||||
docker build --no-cache \
|
||||
--target test \
|
||||
--output type=cacheonly .
|
||||
-t "$("$SCRIPT_DIR/projectname")-test" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user