Skip to content

Commit 706fdc0

Browse files
singular0claude
andcommitted
Fail the build when Git is present but unreadable
v0.1.0's Debian packages shipped as 0.0.0. The debian jobs run in a debian:trixie container, so the build runs as root over a workspace owned by the runner account, and Git has refused to read a repository owned by another user since 2.35. The resolver swallowed that refusal and answered with its Git-less fallback, which is how a package stamped 0.0.0 got built underneath a changelog stanza that already said 0.1.0, beside DMGs the non-container macOS jobs versioned correctly. Both halves of that were wrong. The resolver now passes -c safe.directory for the source directory it was pointed at -- reading the identity of a tree the caller has already asked to compile needs no further permission -- which covers CI, the local container path and any sudo build in one place, so build-deb.sh no longer needs its own workaround. And a tree that has a .git yet yields nothing is now a hard error: an absent repository is 0.0.0, an unreadable one is a broken build. release.yml re-checks the built filenames against the tag before it publishes, since four jobs resolving the version separately is exactly how they came to disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CaJar47XxqehpSd7chyAk5
1 parent 4eef4c6 commit 706fdc0

5 files changed

Lines changed: 126 additions & 15 deletions

File tree

.github/workflows/release.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,34 @@ jobs:
9797
merge-multiple: true
9898
path: dist
9999

100+
# The gate resolved the tag on its own runner; each package job resolves
101+
# it again on its own. v0.1.0 shipped with the two of them disagreeing --
102+
# correct DMGs beside debs that called themselves 0.0.0, because the
103+
# container builds could not read the checkout and said so to nobody.
104+
# Every asset carries its version in its name, so the tag can be checked
105+
# against what four separate jobs actually produced, one step before the
106+
# release exists.
107+
- name: Check the packages carry the tag's version
108+
working-directory: dist
109+
run: |
110+
wrong=
111+
for f in corelet_*.deb; do
112+
case $f in
113+
corelet_${{ needs.gate.outputs.package }}_*.deb) ;;
114+
*) wrong="$wrong $f" ;;
115+
esac
116+
done
117+
for f in Corelet-*.dmg; do
118+
case $f in
119+
Corelet-${{ needs.gate.outputs.version }}-*.dmg) ;;
120+
*) wrong="$wrong $f" ;;
121+
esac
122+
done
123+
if [ -n "$wrong" ]; then
124+
echo "::error::$GITHUB_REF_NAME did not build as itself:$wrong"
125+
exit 1
126+
fi
127+
100128
# Nothing here is notarized or signed with a key anyone can check
101129
# against, so the checksums are the only way a download can be verified
102130
# at all. The -dbgsym packages stay out of the release: they are for

CLAUDE.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ each other.
1010
## Build
1111

1212
Qt 6 Widgets, C++20, no dependencies beyond Qt. No formatter config. Tests are two CTest binaries
13-
built under `BUILD_TESTING` (`tests/history_test.cpp`, `tests/chat_model_test.cpp`); run them with
14-
`ctest --test-dir build`. `libqt6sql6-sqlite` is a Build-Depend as well as a runtime one because
13+
built under `BUILD_TESTING` (`tests/history_test.cpp`, `tests/chat_model_test.cpp`) plus
14+
`tests/version_test.cmake`, which drives the version resolver over throwaway repositories rather
15+
than compiling anything; run them with `ctest --test-dir build`. `libqt6sql6-sqlite` is a Build-Depend as well as a runtime one because
1516
the history test opens a real QSQLITE database and `dh_auto_test` fails without the driver plugin.
1617

1718
```sh
@@ -56,12 +57,24 @@ reads them, so no list of Qt packages is repeated in the script, workflow or REA
5657
Git is the only source of the current version. `cmake/version.cmake` accepts valid `v` + SemVer
5758
release tags and produces one canonical identity plus the syntax-constrained Debian and Apple
5859
forms. An exact `v1.2.3` reports `1.2.3`; later commits report `1.2.3-N-gHASH`; a Git checkout with
59-
no reachable release tag reports `0.0.0-HASH`; and a tree without usable Git metadata reports
60-
`0.0.0`. Staged or unstaged tracked changes add `-dirty`; untracked build products do not. The
60+
no reachable release tag reports `0.0.0-HASH`; and a source tree with no repository in it at all —
61+
a downloaded tarball, or the staging tree the Debian package builds from — reports `0.0.0`.
62+
Staged or unstaged tracked changes add `-dirty`; untracked build products do not. The
6163
resolver generates the header, man page and Info.plist on every build and only rewrites changed
6264
files, so tagging an already configured checkout updates its identity without forcing an otherwise
6365
unchanged rebuild.
6466

67+
A tree that *does* have a `.git` and still yields nothing is a hard error rather than a `0.0.0`.
68+
That distinction is what v0.1.0 was missing: Git refuses a repository owned by another user, which
69+
is what a container job is — the workspace belongs to the runner account and the build runs as
70+
root — so the Debian jobs resolved nothing, said so to nobody, and shipped debs stamped `0.0.0`
71+
underneath a changelog that said `0.1.0`, beside correctly named DMGs. The resolver now passes
72+
`-c safe.directory` for the source directory it was pointed at (`safe.directory` is read only from
73+
protected scopes, so an environment variable would not do, and it must be the physical path, not a
74+
route through a symlink), which fixes CI, the local container path and any `sudo` build in one
75+
place. `release.yml` then re-checks the built filenames against the tag before publishing, because
76+
every job resolving the version separately is exactly how they came to disagree.
77+
6578
Debian native versions cannot contain hyphens, so prereleases use `~` and development components
6679
use `+`/`.` there. Apple's standard bundle fields are numeric: `CFBundleShortVersionString` uses
6780
the tag's numeric core (or `0.0.0`) and `CFBundleVersion` uses the Git commit count (or `0`); the

cmake/version.cmake

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,55 @@ else()
5757
set(CORELET_VERSION_BUILD "0")
5858
set(CORELET_VERSION_DATE "Thu, 01 Jan 1970 00:00:00 +0000")
5959

60+
# Two different things produce no version here, and only one of them is
61+
# fine. A source copy with no repository -- a downloaded tarball, or the
62+
# staging tree the Debian package builds from -- is genuinely 0.0.0. A tree
63+
# that does have a .git and still yields nothing is a broken build, not a
64+
# version: that case once put 0.0.0 packages into a tagged release,
65+
# silently, and beneath the version the changelog itself already carried.
66+
# So from here on, a .git that cannot be read is fatal.
6067
find_package(Git QUIET)
68+
if(SOURCE_DIR AND EXISTS "${SOURCE_DIR}/.git" AND NOT GIT_EXECUTABLE)
69+
message(FATAL_ERROR "no git to read the checkout at ${SOURCE_DIR}")
70+
endif()
6171
if(GIT_EXECUTABLE AND SOURCE_DIR)
72+
# Since 2.35 Git refuses to read a repository owned by another user,
73+
# which is exactly what a container build is: the workspace belongs to
74+
# the host's runner account and the build runs as root. Nothing here
75+
# does more than read the identity of a tree the caller already told us
76+
# to compile, so the directory we were pointed at is trusted for the
77+
# duration. safe.directory is honoured only from protected scopes --
78+
# system, global and command line -- so it has to be passed as -c
79+
# rather than an environment variable, and it must be the physical path
80+
# Git resolves the repository to, not a route through a symlink.
81+
get_filename_component(source_path "${SOURCE_DIR}" REALPATH)
82+
set(git "${GIT_EXECUTABLE}" -c "safe.directory=${source_path}")
83+
6284
execute_process(
63-
COMMAND "${GIT_EXECUTABLE}" rev-parse --verify HEAD
85+
COMMAND ${git} rev-parse --is-inside-work-tree
6486
WORKING_DIRECTORY "${SOURCE_DIR}"
6587
OUTPUT_QUIET
66-
ERROR_QUIET
67-
RESULT_VARIABLE have_head)
88+
ERROR_VARIABLE git_error
89+
RESULT_VARIABLE in_repository)
90+
if(NOT in_repository EQUAL 0 AND EXISTS "${SOURCE_DIR}/.git")
91+
message(FATAL_ERROR
92+
"Git cannot read the checkout at ${SOURCE_DIR}: ${git_error}")
93+
endif()
94+
95+
# A repository whose HEAD is unborn -- git init with nothing committed
96+
# yet -- has no identity to report and no build to break.
97+
set(have_head 1)
98+
if(in_repository EQUAL 0)
99+
execute_process(
100+
COMMAND ${git} rev-parse --verify HEAD
101+
WORKING_DIRECTORY "${SOURCE_DIR}"
102+
OUTPUT_QUIET
103+
ERROR_QUIET
104+
RESULT_VARIABLE have_head)
105+
endif()
68106
if(have_head EQUAL 0)
69107
execute_process(
70-
COMMAND "${GIT_EXECUTABLE}" rev-parse --short=7 HEAD
108+
COMMAND ${git} rev-parse --short=7 HEAD
71109
WORKING_DIRECTORY "${SOURCE_DIR}"
72110
OUTPUT_VARIABLE short_hash
73111
OUTPUT_STRIP_TRAILING_WHITESPACE
@@ -77,7 +115,7 @@ else()
77115
message(FATAL_ERROR "cannot resolve Git hash: ${git_error}")
78116
endif()
79117
execute_process(
80-
COMMAND "${GIT_EXECUTABLE}" rev-list --count HEAD
118+
COMMAND ${git} rev-list --count HEAD
81119
WORKING_DIRECTORY "${SOURCE_DIR}"
82120
OUTPUT_VARIABLE CORELET_VERSION_BUILD
83121
OUTPUT_STRIP_TRAILING_WHITESPACE
@@ -87,7 +125,7 @@ else()
87125
message(FATAL_ERROR "cannot count Git commits: ${git_error}")
88126
endif()
89127
execute_process(
90-
COMMAND "${GIT_EXECUTABLE}" show -s --format=%aD HEAD
128+
COMMAND ${git} show -s --format=%aD HEAD
91129
WORKING_DIRECTORY "${SOURCE_DIR}"
92130
OUTPUT_VARIABLE CORELET_VERSION_DATE
93131
OUTPUT_STRIP_TRAILING_WHITESPACE
@@ -98,7 +136,7 @@ else()
98136
endif()
99137

100138
execute_process(
101-
COMMAND "${GIT_EXECUTABLE}" tag --merged HEAD
139+
COMMAND ${git} tag --merged HEAD
102140
WORKING_DIRECTORY "${SOURCE_DIR}"
103141
OUTPUT_VARIABLE merged_tags
104142
OUTPUT_STRIP_TRAILING_WHITESPACE
@@ -108,7 +146,7 @@ else()
108146
message(FATAL_ERROR "cannot list Git tags: ${git_error}")
109147
endif()
110148
string(REPLACE "\n" ";" merged_tags "${merged_tags}")
111-
set(describe_command "${GIT_EXECUTABLE}" describe --tags --long --abbrev=7)
149+
set(describe_command ${git} describe --tags --long --abbrev=7)
112150
set(release_tag_count 0)
113151
foreach(tag IN LISTS merged_tags)
114152
is_release_tag("${tag}" valid)
@@ -163,7 +201,7 @@ else()
163201
# Match git describe --dirty: staged and unstaged tracked changes
164202
# count, while untracked build products do not.
165203
execute_process(
166-
COMMAND "${GIT_EXECUTABLE}" status --porcelain --untracked-files=no
204+
COMMAND ${git} status --porcelain --untracked-files=no
167205
WORKING_DIRECTORY "${SOURCE_DIR}"
168206
OUTPUT_VARIABLE dirty
169207
OUTPUT_STRIP_TRAILING_WHITESPACE

scripts/build-deb.sh

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,6 @@ build_container() {
162162
cp -a /src /work
163163
rm -rf /work/build /work/dist /work/.git
164164
/work/scripts/build-deb.sh deps
165-
git config --global --add safe.directory /src
166165
cmake -DSOURCE_DIR=/src -DOUTPUT_MANIFEST=/tmp/corelet-version.cmake \
167166
-P /src/cmake/version.cmake
168167
CORELET_VERSION_MANIFEST=/tmp/corelet-version.cmake \

tests/version_test.cmake

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ function(resolve source output)
3636
endif()
3737
endfunction()
3838

39+
function(resolve_failure source output label)
40+
execute_process(
41+
COMMAND "${CMAKE_COMMAND}" -DSOURCE_DIR=${source}
42+
-DOUTPUT_MANIFEST=${output} -P "${resolver}"
43+
OUTPUT_QUIET ERROR_QUIET RESULT_VARIABLE status)
44+
if(status EQUAL 0)
45+
message(FATAL_ERROR "${label}: the resolver answered anyway")
46+
endif()
47+
endfunction()
48+
3949
function(assert_equal actual expected label)
4050
if(NOT "${actual}" STREQUAL "${expected}")
4151
message(FATAL_ERROR "${label}: expected '${expected}', got '${actual}'")
@@ -107,4 +117,27 @@ assert_equal("${CORELET_VERSION}" "0.0.0" "Git-less fallback")
107117
assert_equal("${CORELET_VERSION_DEBIAN}" "0.0.0" "Git-less Debian fallback")
108118
assert_equal("${CORELET_VERSION_BUILD}" "0" "Git-less bundle build")
109119

110-
file(REMOVE_RECURSE "${repo}" "${plain}" "${manifest}")
120+
# git init and nothing committed: a repository with no identity to report yet,
121+
# which is not the same as one that cannot be read.
122+
set(unborn "${TEST_ROOT}/version-test-unborn")
123+
file(MAKE_DIRECTORY "${unborn}")
124+
execute_process(COMMAND git init -q WORKING_DIRECTORY "${unborn}"
125+
OUTPUT_QUIET ERROR_VARIABLE error RESULT_VARIABLE status)
126+
if(NOT status EQUAL 0)
127+
message(FATAL_ERROR "git init failed in ${unborn}: ${error}")
128+
endif()
129+
resolve("${unborn}" "${manifest}")
130+
include("${manifest}")
131+
assert_equal("${CORELET_VERSION}" "0.0.0" "unborn HEAD fallback")
132+
133+
# A .git Git refuses to read has to fail the build rather than resolve to
134+
# 0.0.0. v0.1.0 shipped debs stamped 0.0.0 underneath a changelog that said
135+
# 0.1.0 because a container job hit precisely this -- a checkout owned by
136+
# another user -- and got an answer instead of an error. Ownership needs two
137+
# users to reproduce; a gitdir pointing nowhere is the same refusal.
138+
set(broken "${TEST_ROOT}/version-test-broken")
139+
file(MAKE_DIRECTORY "${broken}")
140+
file(WRITE "${broken}/.git" "gitdir: ${TEST_ROOT}/nowhere\n")
141+
resolve_failure("${broken}" "${manifest}" "unreadable checkout")
142+
143+
file(REMOVE_RECURSE "${repo}" "${plain}" "${unborn}" "${broken}" "${manifest}")

0 commit comments

Comments
 (0)