docker build . stamps the git tag or short commit, not dev (closes #154)
check / check (push) Failing after 46s

A plain `docker build .` now stamps the version from git rather than `dev`/`0.0.0`. After `tsc`, `script/build` writes into `dist/package.json` the version `script/version` decides: `VERSION` when given, otherwise `git describe --tags --always` (the tag; or tag, commits since and short commit; or the short commit), otherwise `package.json`'s. A checkout with `.git` that yields an empty, `dev` or `unknown` version fails the build. `.dockerignore` sends `.git` but not `.git/config`, so no remote URL or credential reaches the image. `ARG VERSION` has no default, and the host scripts' version still wins.

Not changed: `REPO_POLICIES.md` still says `ARG VERSION=dev` until the shared policy changes.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #157.
This commit is contained in:
2026-10-02 06:12:56 +02:00
committed by clawbot
parent e50d2a78c8
commit 9e94542a57
12 changed files with 284 additions and 29 deletions
+6 -3
View File
@@ -1,9 +1,12 @@
# Mirrors .gitignore, with one deliberate exception: .gitignore itself stays # Mirrors .gitignore, with one deliberate exception: .gitignore itself stays
# in the build context, because prettier 3 reads it as a default ignore file # in the build context, because prettier 3 reads it as a default ignore file
# and dropping it would change what the lint phase's prettier check sees. # and dropping it would change what the lint phase's prettier check sees.
#
# VCS # .git is deliberately NOT excluded: the build derives the version it stamps
.git # from it (script/version). It is sent without its config, which holds the
# clone's remote URL and any credential in it, and which the build stage, the
# final image, would otherwise carry. git describe does not need it.
.git/config
# OS # OS
.DS_Store .DS_Store
+6 -3
View File
@@ -61,9 +61,12 @@ RUN script/bootstrap
COPY . . COPY . .
# The version is computed on the host and passed in, because # Version stamped into the build: the VERSION build arg when one is given,
# .dockerignore excludes .git. # otherwise what script/version derives from the .git the build context
ARG VERSION=dev # carries (script/bootstrap installed git), so any `docker build .` of a
# clone stamps its commit. The label can only carry the build arg, and is
# empty without one.
ARG VERSION
LABEL org.opencontainers.image.version="${VERSION}" LABEL org.opencontainers.image.version="${VERSION}"
RUN make build RUN make build
+4 -2
View File
@@ -26,8 +26,10 @@ check:
build: build:
@script/build @script/build
build-bin: # Bundles the built dist/, so the binary reports the version script/build
nix-shell -p bun --run "bun build bin/quak.ts --compile --outfile bin/quak" # stamped.
build-bin: build
nix-shell -p bun --run "bun build dist/bin/quak.js --compile --outfile bin/quak"
install: build-bin install: build-bin
mkdir -p ~/bin mkdir -p ~/bin
+34 -3
View File
@@ -126,9 +126,12 @@ alpine. We provide:
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by - `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag `script/docker` for the image tag
- `script/build` — compile the TypeScript sources into `dist/`, then verify that - `script/build` — compile the TypeScript sources into `dist/`, stamp the
the entrypoints `package.json` declares (`main`, `types`, `bin`) are among the version into `dist/package.json`, then verify that the entrypoints
files the compiler wrote, and make the CLI executable (our own extension) `package.json` declares (`main`, `types`, `bin`) are among the files the
compiler wrote, and make the CLI executable (our own extension)
- `script/version` — print the version `script/build` stamps (our own
extension); see Version below
- `script/test` — run the test suite, by building the `test` phase of the - `script/test` — run the test suite, by building the `test` phase of the
`Dockerfile` (vitest, 90s timeout, verbose rerun on failure); requires docker `Dockerfile` (vitest, 90s timeout, verbose rerun on failure); requires docker
- `script/lint` — run eslint and a prettier check, by building the `lint` phase - `script/lint` — run eslint and a prettier check, by building the `lint` phase
@@ -176,6 +179,34 @@ an exact version, installed from `yarn.lock` under `--frozen-lockfile` in both
places, and reads `.gitignore` as its default ignore file — which is why places, and reads `.gitignore` as its default ignore file — which is why
`.dockerignore` keeps `.gitignore` in the build context. `.dockerignore` keeps `.gitignore` in the build context.
### Version
`quak --version` reports the `version` of `dist/package.json`, which
`script/build` writes after compiling; the repo's own `package.json` keeps
`0.0.0`, and that is what the tests, which run from source, report.
`script/version` decides what is written:
- the `VERSION` environment variable, or the `Dockerfile`'s `VERSION` build arg
(`--build-arg VERSION=...`), when one is given and not empty;
- otherwise, in a checkout with `.git`, `git describe --tags --always`: the tag
on a tagged commit; the tag, the commits since it and the short commit on a
later commit (`v1.2.3-4-gabc1234`); the short commit when no tag is reachable;
- otherwise, as in a source tarball, the version `package.json` declares.
The build fails if the checkout has `.git` and the version still comes out
empty, `dev` or `unknown`: such a build could not be traced back to its commit.
`.dockerignore` therefore does not leave out `.git`, so any `docker build .` of
a clone stamps the commit it was built from; a shallow clone stamps a tag only
when the cloned commit itself carries one, and otherwise the short commit. It
leaves out `.git/config`, which holds the clone's remote URL and any credential
in it, so the image carries `.git` without its config; `git describe` does not
need that file. `script/docker` (and so `make docker`) and `script/cibuild` pass
the version they resolve on the host, with `--dirty`, as the build arg, which
takes precedence. The image's `org.opencontainers.image.version` label carries
that build arg only, so a build given none leaves it empty. `make build-bin`
bundles the built `dist/`, so the single binary reports the stamped version too.
## Rationale ## Rationale
Ente is one of very few photo services with a credible end-to-end encryption Ente is one of very few photo services with a credible end-to-end encryption
+9
View File
@@ -25,6 +25,15 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-02: `docker build .` stamps the commit's tag or short commit, not
`dev` (issue 154). `script/build` writes the version `script/version` prints
into `dist/package.json`: the `VERSION` environment variable or build arg when
one is given, otherwise `git describe --tags --always`, otherwise the version
`package.json` declares. `.dockerignore` sends `.git`, and a checkout with
`.git` whose version comes out empty, `dev` or `unknown` fails the build.
`make build-bin` bundles the built `dist/`, so the single binary reports the
same version.
- 2026-10-02: `photo.exif()` returns every EXIF tag in the file as `ExifTags`, - 2026-10-02: `photo.exif()` returns every EXIF tag in the file as `ExifTags`,
keyed by tag name, each as exifreader decodes it, not only the thirteen common keyed by tag name, each as exifreader decodes it, not only the thirteen common
fields (issue 156). The embedded thumbnail's tags are under `Thumbnail`, fields (issue 156). The embedded thumbnail's tags are under `Thumbnail`,
+23 -9
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# script/build: compile the TypeScript sources into dist/, then verify that # script/build: compile the TypeScript sources into dist/, stamp the version
# the artifacts package.json advertises are among the files the compiler # script/version prints into it, then verify that the artifacts package.json
# actually wrote. tsc reports success by exit status alone and knows nothing # advertises are among the files the compiler actually wrote. tsc reports success by exit status alone and knows nothing
# about the manifest, so without this step a green build can still ship a # about the manifest, so without this step a green build can still ship a
# package whose main, types or bin resolve to nothing. Our own extension to # package whose main, types or bin resolve to nothing. Our own extension to
# scripts-to-rule-them-all. # scripts-to-rule-them-all.
@@ -46,13 +46,24 @@ for (const bin of bins) {
} }
# src/index.ts imports ../package.json for the version, which tsc copies to # src/index.ts imports ../package.json for the version, which tsc copies to
# dist/package.json. Running the built CLI proves that import resolves from # dist/package.json. The version script/version prints is written into that
# dist/ and reports the version package.json declares. # copy only; the repo's own package.json is left as it is.
stamp_version() {
node -e '
const { readFileSync, writeFileSync } = require("node:fs");
const pkg = JSON.parse(readFileSync("dist/package.json", "utf-8"));
pkg.version = process.argv[1];
writeFileSync("dist/package.json", JSON.stringify(pkg, null, 4) + "\n");
' "$1"
}
# Running the built CLI proves the import resolves from dist/ and reports
# the stamped version.
verify_version() { verify_version() {
built="$(node dist/bin/quak.js --version)" built="$(node dist/bin/quak.js --version)"
declared="$(node -p 'require("./package.json").version')" if [ "$built" != "$1" ]; then
if [ "$built" != "$declared" ]; then echo "build: dist/bin/quak.js reports $built, the build stamped $1" >&2
echo "build: dist/bin/quak.js reports $built, package.json declares $declared" >&2
exit 1 exit 1
fi fi
echo "build: dist/bin/quak.js reports version $built" echo "build: dist/bin/quak.js reports version $built"
@@ -60,9 +71,12 @@ verify_version() {
main() { main() {
cd "$ROOT" cd "$ROOT"
# Own line, so that a failing script/version stops the build.
version="$("$ROOT/script/version")"
yarn run tsc yarn run tsc
stamp_version "$version"
verify_entrypoints verify_entrypoints
verify_version verify_version "$version"
} }
main "$@" main "$@"
+3 -3
View File
@@ -15,9 +15,9 @@ main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore # empty constant. The version resolved here goes in as the VERSION
# excludes .git, so `git describe` in a build stage yields an empty # build arg, which takes precedence over what the build would derive
# version without failing. # from the .git in its context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache \
+3 -3
View File
@@ -12,9 +12,9 @@ main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore # empty constant. The version resolved here goes in as the VERSION
# excludes .git, so `git describe` in a build stage yields an empty # build arg, which takes precedence over what the build would derive
# version without failing. # from the .git in its context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache \
Executable
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# script/version: print the version script/build stamps into the built
# package. Our own extension to scripts-to-rule-them-all.
#
# Order of precedence:
#
# 1. $VERSION, if set and not empty: an explicit value, such as the
# Dockerfile's VERSION build arg.
# 2. If this checkout has .git, `git describe --tags --always`: the tag
# on a tagged commit; the tag, the commits since it and the short
# commit on a later commit (v1.2.3-4-gabc1234); the short commit when
# no tag is reachable.
# 3. Otherwise, as in a source tarball, the version package.json declares.
#
# A checkout with .git whose version still comes out empty, dev or unknown
# fails: git is missing or could not read the checkout, and the build could
# not be traced back to its commit.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
version="${VERSION:-}"
if [ -e .git ]; then
if [ -z "$version" ]; then
version="$(git describe --tags --always || true)"
fi
case "$version" in
"" | dev | unknown)
echo "version: $ROOT has .git, but the version came out '$version'" >&2
exit 1
;;
esac
elif [ -z "$version" ]; then
version="$(node -p 'require("./package.json").version')"
fi
echo "$version"
}
main "$@"
+4 -2
View File
@@ -1,5 +1,7 @@
// package.json is the one place the version is written. tsc copies it to // A build reports the version script/build stamps into dist/package.json;
// dist/package.json, so this path resolves from source and from dist/src/. // package.json's own version is reported only when running from source. tsc
// copies package.json to dist/package.json, so this path resolves from source
// and from dist/src/.
import pkg from "../package.json" with { type: "json" }; import pkg from "../package.json" with { type: "json" };
export const VERSION: string = pkg.version; export const VERSION: string = pkg.version;
+14 -1
View File
@@ -9,8 +9,10 @@
// Excluding too much: Prettier 3 reads `.gitignore` as a default ignore file, // Excluding too much: Prettier 3 reads `.gitignore` as a default ignore file,
// so dropping it from the context silently changes which files the lint // so dropping it from the context silently changes which files the lint
// phase's prettier check looks at compared to `make fmt-check` on the host. // phase's prettier check looks at compared to `make fmt-check` on the host.
// And without `.git`, a `docker build .` given no `VERSION` build arg cannot
// derive the version (`script/version`) and stamps `package.json`'s instead.
// //
// Neither shows up as a build failure, so they are asserted here. // None of these shows up as a build failure, so they are asserted here.
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -47,6 +49,17 @@ describe(".dockerignore", () => {
expect(dockerignore).not.toContain(".gitignore"); expect(dockerignore).not.toContain(".gitignore");
}); });
it("leaves .git in the build context for the version", () => {
expect(dockerignore).not.toContain(".git");
expect(dockerignore).not.toContain(".git/");
});
// The build stage is the final image, so a .git/config sent in would
// ship the clone's remote URL and any credential in it.
it("sends .git without its config", () => {
expect(dockerignore).toContain(".git/config");
});
// BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a // BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a
// file would silently give the build a different, unreviewed context — // file would silently give the build a different, unreviewed context —
// and eslint's flat config does not ignore dot-directories, so a stray // and eslint's flat config does not ignore dot-directories, so a stray
+137
View File
@@ -0,0 +1,137 @@
// `script/version` prints the version `script/build` stamps into
// `dist/package.json`, which is what `quak --version` reports from a build.
// A `docker build .` of a clone is given no `VERSION` build arg, so the
// version has to come from the `.git` in its context: the tag on a tagged
// commit; the tag, the commits since it and the short commit on a later commit;
// the short commit when no tag is reachable. A checkout with `.git` that still
// yields no usable version must fail the build, not ship a version nobody can
// trace back to its commit.
//
// Each test copies the script into a fresh directory, which the script then
// treats as the checkout, and executes it there.
import { afterEach, describe, expect, it } from "vitest";
import { execFileSync, spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
let checkout = "";
afterEach(() => {
rmSync(checkout, { recursive: true, force: true });
});
// A checkout holding the script and a package.json that declares 0.0.0, with
// no .git yet.
const makeCheckout = (): void => {
checkout = mkdtempSync(join(tmpdir(), "quak-version-"));
mkdirSync(join(checkout, "script"));
copyFileSync(
join(repoRoot, "script/version"),
join(checkout, "script/version"),
);
chmodSync(join(checkout, "script/version"), 0o755);
writeFileSync(join(checkout, "package.json"), '{ "version": "0.0.0" }\n');
};
// git in the checkout, with an identity and no commit signing, whatever the
// host's own git config says.
const git = (...args: string[]): string =>
execFileSync(
"git",
[
"-c",
"user.name=quak",
"-c",
"user.email=quak@example.invalid",
"-c",
"commit.gpgsign=false",
...args,
],
{ cwd: checkout, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
).trim();
const makeCommittedCheckout = (): void => {
makeCheckout();
git("init", "-q");
git("add", "package.json");
git("commit", "-q", "-m", "first");
};
// Runs the script with nothing in its environment but PATH and, when given,
// VERSION.
const runVersion = (version?: string) =>
spawnSync(join(checkout, "script/version"), {
cwd: checkout,
encoding: "utf-8",
env: { PATH: process.env.PATH, VERSION: version },
});
describe("script/version", () => {
it("prints the short commit of an untagged commit", () => {
makeCommittedCheckout();
expect(runVersion().stdout.trim()).toBe(
git("rev-parse", "--short", "HEAD"),
);
});
it("prints the tag of a tagged commit", () => {
makeCommittedCheckout();
git("tag", "v1.2.3");
expect(runVersion().stdout.trim()).toBe("v1.2.3");
});
// script/docker and script/cibuild pass the version they resolve on the
// host as the VERSION build arg.
it("prints the VERSION it is given over what git would derive", () => {
makeCommittedCheckout();
expect(runVersion("x").stdout.trim()).toBe("x");
});
// `--build-arg VERSION=` must not stamp an empty version.
it("treats an empty VERSION as unset", () => {
makeCommittedCheckout();
expect(runVersion("").stdout.trim()).toBe(
git("rev-parse", "--short", "HEAD"),
);
});
// A source tarball has no .git: it keeps the version package.json
// declares, and must still build.
it("prints package.json's version where there is no .git", () => {
makeCheckout();
const result = runVersion();
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("0.0.0");
});
// A repository with no commits stands in for any .git that git cannot
// describe: git missing from the image, or refusing to read the checkout.
it("fails where .git yields no version", () => {
makeCheckout();
git("init", "-q");
const result = runVersion();
expect(result.status).not.toBe(0);
expect(result.stdout).toBe("");
});
it.each(["dev", "unknown"])(
"fails where there is .git and the version is %s",
(version) => {
makeCommittedCheckout();
const result = runVersion(version);
expect(result.status).not.toBe(0);
expect(result.stdout).toBe("");
},
);
});