Skip to content

Commit 9727d19

Browse files
donislawdevclaude
andcommitted
release: the macOS signing script stops changing directory
Found by the first real release, which it stopped twice in two consecutive steps. Phase B refused on tfg-gui.app with "was signed by a DIFFERENT certificate, expected C656..., got none". The signature was correct: codesign -dv on that same bundle shows Developer ID Application, a chain to the Apple Root CA and a timestamp. What failed was reading it back. certificate_of cd'd into a temporary directory and then asked codesign about the bundle, because --extract-certificates looked like a file name rather than a path. The bundle path is relative - sign_release.py hands this script a directory under the home directory - so after the cd it named nothing, codesign answered "No such file or directory", and no certificate came out. A refusal that is right about there being a problem and wrong about what it is costs more than a silent one: it sends somebody to look at the card. Measured on the Mac, all three ways: relative after a cd gives nothing, absolute gives exactly the pinned digest, and relative with the prefix written as a path and no cd gives the same. So the cd goes. The same trap sat one step further on and was measured before anybody reached it: the zip for notarisation cd'd into the unpacked directory and wrote to a path relative to where the script started, so the file was never created. Bare ditto with both paths as they arrive works, and --keepParent still puts the .app at the top of the archive - read back out of the zip rather than assumed. Why the rehearsal did not show this: it ran the script by hand with an absolute directory, and the real caller passes a relative one. The command was proven, the call was not. The guard asks whether there is any cd at all rather than whether the paths are absolute, because that is the property that can be read off the file. If a directory change is ever genuinely needed here, the guard is the conversation about it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2f2371e commit 9727d19

2 files changed

Lines changed: 64 additions & 2 deletions

File tree

‎.github/scripts/sign_macos.sh‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,26 @@ A renewal is a DIFFERENT certificate and internal/legal has to move with it."
120120
}
121121

122122
# The certificate that ACTUALLY signed a bundle, read back out of it.
123+
# 🔴 Nothing here changes directory, and that is the whole of what this function
124+
# learned on 2026-08-28, on the first real release.
125+
#
126+
# It used to cd into the temporary directory and pass the bundle path after
127+
# that, because --extract-certificates looked like a file NAME. The path it was
128+
# given is relative - sign_release.py passes a directory under the home
129+
# directory - so after the cd it named nothing. codesign answered "No such file
130+
# or directory", no certificate came out, and the script stopped the release
131+
# saying the bundle "was signed by a DIFFERENT certificate, got none" about a
132+
# bundle that was correctly signed, by the pinned certificate, with a timestamp.
133+
# A refusal that is right about there being a problem and wrong about what it is
134+
# costs more than a silent one, because it sends somebody to look at the card.
135+
#
136+
# The prefix takes a path. Measured rather than assumed: with the prefix written
137+
# as "$tmp/cert" and the bundle path left exactly as it arrives, cert0 appears
138+
# and hashes to the pinned digest.
123139
certificate_of() {
124140
local bundle="$1" tmp
125141
tmp="$(mktemp -d)"
126-
( cd "$tmp" && codesign -d --extract-certificates=cert "$bundle" >/dev/null 2>&1 )
142+
codesign -d --extract-certificates="$tmp/cert" "$bundle" >/dev/null 2>&1
127143
if [ ! -f "$tmp/cert0" ]; then
128144
rm -rf "$tmp"
129145
echo "none"
@@ -172,8 +188,15 @@ Nothing has been handed back."
172188
# Apple takes a zip, a pkg or a dmg, and will not take the tar.gz we publish.
173189
# So the zip exists only to carry the bundle there. What gets stapled and
174190
# republished is the bundle itself.
191+
# The same trap as certificate_of, one step further on, and it was measured
192+
# the same day rather than met later: this cd'd into the unpacked directory
193+
# and then wrote to a path that was relative to where the script STARTED, so
194+
# the zip was never created and the next line would have stopped the release
195+
# with "could not zip". Without the cd, ditto is given both paths as they
196+
# arrive and --keepParent still puts tfg-gui.app at the top of the archive,
197+
# which is the shape notarisation wants - read back out of the zip.
175198
local zip="${work}/notarise.zip"
176-
( cd "$work" && ditto -c -k --keepParent "$(basename "$app")" "$zip" ) ||
199+
ditto -c -k --keepParent "$app" "$zip" ||
177200
die "could not zip $(basename "$app") for notarisation"
178201

179202
local out

‎internal/guard/macossigning_test.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,45 @@ func TestTheIconMacOSReadsCarriesEverySizeItIsAskedFor(t *testing.T) {
204204
t.Logf("%d entries, every size macOS asks for, %d bytes", len(found), len(body))
205205
}
206206

207+
// The macOS signing script never changes directory.
208+
//
209+
// Found by the first real release, on 2026-08-28, and it stopped that release
210+
// twice in two consecutive steps. Every path this script works with is derived
211+
// from the directory it is handed, and sign_release.py hands it one relative to
212+
// the home directory. Two commands ran inside a subshell that had cd'd
213+
// somewhere else first, so the paths they were given stopped meaning anything:
214+
//
215+
// - certificate_of cd'd into a temporary directory and then asked codesign
216+
// about the bundle. codesign answered "No such file or directory", no
217+
// certificate came out, and the script refused the release saying the
218+
// bundle was "signed by a DIFFERENT certificate, got none" - about a bundle
219+
// that was correctly signed by the pinned certificate, with a timestamp,
220+
// chaining to the Apple Root CA. Measured after the fact: the same command
221+
// with the path resolved gives exactly the pinned digest;
222+
// - the zip for notarisation cd'd into the unpacked bundle and wrote to a
223+
// path relative to where the script started, so the file was never created.
224+
// That one had not been reached yet and would have stopped the next step.
225+
//
226+
// Asked as "no cd at all" rather than "the paths are absolute", because that is
227+
// the property that can be read off the file. If a directory change is ever
228+
// genuinely needed here, make every path absolute first and this guard is the
229+
// conversation about it.
230+
//
231+
// 🔴 Why nothing caught this before: the rehearsal of 2026-08-28 ran the script
232+
// by hand with an absolute directory, and sign_release.py passes a relative one.
233+
// The command was proven, the call was not.
234+
func TestTheMacSigningScriptDoesNotDependOnWhereItIsRunFrom(t *testing.T) {
235+
script := macSigningScript(t)
236+
237+
if strings.Contains(script, "cd \"") {
238+
t.Error("sign_macos.sh changes directory somewhere. Every path in it comes from the " +
239+
"directory it is handed, and sign_release.py hands it a relative one - so a cd " +
240+
"silently changes what those paths mean.\n" +
241+
"What happened when this was last true: codesign reported no certificate for a " +
242+
"correctly signed bundle, and the release stopped saying the wrong thing about why.")
243+
}
244+
}
245+
207246
// The pin is a digest with a date, and the script derives its selector from it.
208247
//
209248
// Same shape as the Windows pin and for the same reason: codesign selects by

0 commit comments

Comments
 (0)