diff --git a/.dockerignore b/.dockerignore index e2a5e34..8db1dad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,12 @@ # Mirrors .gitignore, with one deliberate exception: .gitignore itself stays # 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. - -# VCS -.git +# +# .git is deliberately NOT excluded: the build derives the version it stamps +# 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 .DS_Store diff --git a/Dockerfile b/Dockerfile index 144308a..03a95a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,9 +61,12 @@ RUN script/bootstrap COPY . . -# The version is computed on the host and passed in, because -# .dockerignore excludes .git. -ARG VERSION=dev +# Version stamped into the build: the VERSION build arg when one is given, +# otherwise what script/version derives from the .git the build context +# 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}" RUN make build diff --git a/Makefile b/Makefile index 80fb8c1..a71e286 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,10 @@ check: build: @script/build -build-bin: - nix-shell -p bun --run "bun build bin/quak.ts --compile --outfile bin/quak" +# Bundles the built dist/, so the binary reports the version script/build +# stamped. +build-bin: build + nix-shell -p bun --run "bun build dist/bin/quak.js --compile --outfile bin/quak" install: build-bin mkdir -p ~/bin diff --git a/README.md b/README.md index 97b703e..984fc0f 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,12 @@ alpine. We provide: `script/bootstrap`, then `script/install-precommit` - `script/projectname` — output the project name (our own extension); used by `script/docker` for the image tag -- `script/build` — compile the TypeScript sources into `dist/`, then verify that - the entrypoints `package.json` declares (`main`, `types`, `bin`) are among the - files the compiler wrote, and make the CLI executable (our own extension) +- `script/build` — compile the TypeScript sources into `dist/`, stamp the + version into `dist/package.json`, then verify that the entrypoints + `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 `Dockerfile` (vitest, 90s timeout, verbose rerun on failure); requires docker - `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 `.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 Ente is one of very few photo services with a credible end-to-end encryption diff --git a/TODO.md b/TODO.md index d6972af..2907015 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,15 @@ declares one. # 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`, 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`, diff --git a/script/build b/script/build index 0a074b7..81df6ba 100755 --- a/script/build +++ b/script/build @@ -1,7 +1,7 @@ #!/bin/sh -# script/build: compile the TypeScript sources into dist/, then verify that -# the artifacts package.json advertises are among the files the compiler -# actually wrote. tsc reports success by exit status alone and knows nothing +# script/build: compile the TypeScript sources into dist/, stamp the version +# script/version prints into it, then verify that the artifacts package.json +# 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 # package whose main, types or bin resolve to nothing. Our own extension to # 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 -# dist/package.json. Running the built CLI proves that import resolves from -# dist/ and reports the version package.json declares. +# dist/package.json. The version script/version prints is written into that +# 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() { built="$(node dist/bin/quak.js --version)" - declared="$(node -p 'require("./package.json").version')" - if [ "$built" != "$declared" ]; then - echo "build: dist/bin/quak.js reports $built, package.json declares $declared" >&2 + if [ "$built" != "$1" ]; then + echo "build: dist/bin/quak.js reports $built, the build stamped $1" >&2 exit 1 fi echo "build: dist/bin/quak.js reports version $built" @@ -60,9 +71,12 @@ verify_version() { main() { cd "$ROOT" + # Own line, so that a failing script/version stops the build. + version="$("$ROOT/script/version")" yarn run tsc + stamp_version "$version" verify_entrypoints - verify_version + verify_version "$version" } main "$@" diff --git a/script/cibuild b/script/cibuild index 38f5706..38fecf3 100755 --- a/script/cibuild +++ b/script/cibuild @@ -15,9 +15,9 @@ main() { cd "$ROOT" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. VERSION is computed here because .dockerignore - # excludes .git, so `git describe` in a build stage yields an empty - # version without failing. + # empty constant. The version resolved here goes in as the VERSION + # build arg, which takes precedence over what the build would derive + # from the .git in its context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build --no-cache \ diff --git a/script/docker b/script/docker index c4688e8..940f456 100755 --- a/script/docker +++ b/script/docker @@ -12,9 +12,9 @@ main() { cd "$ROOT" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. VERSION is computed here because .dockerignore - # excludes .git, so `git describe` in a build stage yields an empty - # version without failing. + # empty constant. The version resolved here goes in as the VERSION + # build arg, which takes precedence over what the build would derive + # from the .git in its context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build --no-cache \ diff --git a/script/version b/script/version new file mode 100755 index 0000000..2af8a6a --- /dev/null +++ b/script/version @@ -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 "$@" diff --git a/src/index.ts b/src/index.ts index 1e8b631..60d90ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ -// package.json is the one place the version is written. tsc copies it to -// dist/package.json, so this path resolves from source and from dist/src/. +// A build reports the version script/build stamps into dist/package.json; +// 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" }; export const VERSION: string = pkg.version; diff --git a/test/packaging/build-context.test.ts b/test/packaging/build-context.test.ts index d6c24e4..ea52581 100644 --- a/test/packaging/build-context.test.ts +++ b/test/packaging/build-context.test.ts @@ -9,8 +9,10 @@ // Excluding too much: Prettier 3 reads `.gitignore` as a default ignore file, // 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. +// 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 { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -47,6 +49,17 @@ describe(".dockerignore", () => { 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 // file would silently give the build a different, unreviewed context — // and eslint's flat config does not ignore dot-directories, so a stray diff --git a/test/packaging/version.test.ts b/test/packaging/version.test.ts new file mode 100644 index 0000000..c1395fc --- /dev/null +++ b/test/packaging/version.test.ts @@ -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(""); + }, + ); +});