Archify version or commit
2.17.0-dev.1 at main commit 1891105
Installation method
Git clone
Diagram type
Installation or packaging
Exact command
# Windows 10, Git for Windows (Git Bash / mintty), Node v22.21.1, clean checkout of main @ 1891105, repository root.
# Note: inside mintty Git for Windows aliases `node` to `winpty node.exe`; use `command node` whenever output is redirected.
# 1. Windows-style absolute output path -> fails before writing anything
mkdir -p /c/Users/<user>/AppData/Local/Temp/archify-repro
bash scripts/build-zip.sh "C:/Users/<user>/AppData/Local/Temp/archify-repro/out.zip"
echo "exit=$?"
# 2. Same directory as a POSIX path -> builds, but the bytes are not canonical
bash scripts/build-zip.sh /c/Users/<user>/AppData/Local/Temp/archify-repro/out.zip
sha256sum archify.zip /c/Users/<user>/AppData/Local/Temp/archify-repro/out.zip
/c/Windows/System32/tar.exe -tvf archify.zip | grep -a "bin/archify.mjs"
/c/Windows/System32/tar.exe -tvf /c/Users/<user>/AppData/Local/Temp/archify-repro/out.zip | grep -a "bin/archify.mjs"
# 3. The repository's own reproducibility test hits defect 1
cd archify
command node --test --test-name-pattern="byte-for-byte" test/release-package-gates.test.mjs
Minimal redacted JSON reproduction
// No typed JSON is involved. The input is the checkout itself (any tracked tree at 1891105).
// The failure is in the packaging scripts under scripts/, not in a diagram.
Validation receipt or exact error
// ---- Defect 1: Windows-style absolute output path (step 1) ----
// build-zip.sh exits 1; nothing is written. The path Node was asked to open:
Error: ENOENT: no such file or directory, open 'D:\Workspace\archify\C;C:\Program Files\Git\Users\<user>\AppData\Local\Temp\archify-repro\.out.zip.<pid>.<random>.tmp'
at Object.openSync (node:fs:561:18)
at file:///D:/Workspace/archify/scripts/write-deterministic-zip.mjs:156:19
exit=1
// ---- Defect 1 as seen by the repository test (step 3) ----
// The test passes an os.tmpdir() path (backslash form); this time MSYS performs no conversion and Node
// resolves the concatenated string against the current drive.
not ok 1 - archive build is byte-for-byte reproducible across caller time zones without system zip
Error: ENOENT: no such file or directory, open 'D:\d\Workspace\archify\C:\Users\<user>\AppData\Local\Temp\archify-package-reproducible-XXXXXX\.utc.zip.<pid>.<random>.tmp'
at file:///D:/Workspace/archify/scripts/write-deterministic-zip.mjs:156:19
1 !== 0 // archify/test/release-package-gates.test.mjs:559 assert.equal(build.status, 0, ...)
# tests 1
# pass 0
# fail 1
# duration_ms 1127.8061
// ---- Defect 2: executable bit lost (step 2) ----
// Same size, different digest:
ff40803bb51b003ce2b7dc5af7567552c021cbc6e33a2af0e100bf97498164b8 archify.zip (committed, 1,877,430 bytes)
45185254ccf6db627b4bba4380949860dbea8b6029739b5878e51da562733f43 out.zip (built on Windows, 1,877,430 bytes)
// bsdtar listing of the only entry that differs:
-rwxr-xr-x 0 0 0 76056 Jan 01 1980 archify/bin/archify.mjs // committed archify.zip
-rw-r--r-- 0 0 0 76056 Jan 01 1980 archify/bin/archify.mjs // Windows build
// Entry-by-entry comparison with Python's zipfile (script below): 79/79 entries have identical bytes and CRC;
// the only metadata difference is external_attr 0o100755 -> 0o100644 on archify/bin/archify.mjs
// (comparison script and the chmod/stat probe are in "Actual behavior" below).
Expected behavior
scripts/build-zip.sh accepts a Windows-style absolute output path (C:/... or C:\...) the same way it accepts POSIX and relative paths. The repository's own test hands it an os.tmpdir() path, so this is the documented entry point on Windows.
- With Node 22 the rebuilt archive byte-matches the committed
archify.zip on Windows as well. CONTRIBUTING.md (§Packages and generated artifacts, lines 87-90) and the comment in scripts/build-zip.sh state that canonical bytes depend only on the Node 22 toolchain. The archive should record the Git index mode that scripts/stage-clean-skill.mjs already enforces (100755 for archify/bin/archify.mjs, the only executable tracked under archify/).
archive build is byte-for-byte reproducible across caller time zones without system zip passes on Windows with Node 22.
Actual behavior
Two independent defects. Every payload byte is correct; only the output-path handling and one mode bit are wrong.
Defect 1 — Windows-style absolute output paths are treated as relative.
scripts/build-zip.sh:8-10 tests [[ "$out" != /* ]] and prepends $(pwd)/ to anything that does not start with /. C:/... and C:\... both fail that test. For the forward-slash form the MSYS argument conversion then treats the embedded colon as a path-list separator and converts each half (D:\Workspace\archify\C;C:\Program Files\Git\Users\...); for the backslash form no conversion happens and Node resolves the string against the current drive (D:\d\Workspace\archify\C:\Users\...). Either way scripts/write-deterministic-zip.mjs:156 fails with ENOENT when opening its temporary file. Relative paths and /c/... paths work, which is why zip-freshness (/tmp/fresh.zip) never sees this.
Defect 2 — the executable bit is re-derived from the filesystem and lost on NTFS.
scripts/stage-clean-skill.mjs reads the Git index mode (git ls-files --stage, line 51), allows only 100644/100755 (line 120), and calls chmodSync(target, 0o755) for 100755 entries (line 258). scripts/write-deterministic-zip.mjs:111 then recomputes the mode as fs.statSync(file.absolute).mode & 0o111 ? 0o755 : 0o644. NTFS has no execute bit: after chmodSync(f, 0o755), statSync(f).mode & 0o777 is 0o666 on Windows (probe above), so the writer records 0644 for archify/bin/archify.mjs. That changes four bytes in the entry's central-directory header (line 87), which is why the size is identical while the digest differs.
Probe used for the NTFS claim (mode-demo.mjs, run with command node):
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-mode-demo-'));
const file = path.join(dir, 'archify.mjs');
fs.writeFileSync(file, '#!/usr/bin/env node\n');
fs.chmodSync(file, 0o755);
const mode = fs.statSync(file).mode;
console.log('platform', process.platform, 'node', process.version);
console.log('after chmodSync(0o755): stat mode =', (mode & 0o777).toString(8), 'exec bits set?', Boolean(mode & 0o111));
fs.rmSync(dir, { recursive: true, force: true });
platform win32 node v22.21.1
after chmodSync(0o755): stat mode = 666 exec bits set? false
Entry-by-entry comparison used for the "79/79 identical" claim:
import sys, zipfile
a, b = (zipfile.ZipFile(p) for p in sys.argv[1:3])
ia, ib = ({i.filename: i for i in z.infolist()} for z in (a, b))
assert ia.keys() == ib.keys()
for name in sorted(ia):
x, y = ia[name], ib[name]
same = a.read(name) == b.read(name) and x.CRC == y.CRC
if not same or x.external_attr != y.external_attr:
print(name, "content-identical" if same else "CONTENT DIFFERS", oct(x.external_attr >> 16), "->", oct(y.external_attr >> 16))
Why CI cannot see it. test and zip-freshness run on ubuntu-latest only (.github/workflows/ci.yml:9-10, 82-95); package smoke (windows-latest) only extracts the committed archive (152-181). Windows contributors are nevertheless expected to rebuild and commit archify.zip (CONTRIBUTING.md:87-90; review requests on #207 and #220) and currently cannot produce canonical bytes, and npm test reports the reproducibility test as failed on a clean Windows checkout.
Related work that does not cover this. #144 / #202 (line endings), #132 (invoking build-zip.sh through bash in tests), draft #349 (test-only change to the stager mode assertions; production code unchanged). Neither the writer's mode derivation nor the output-path check is addressed anywhere.
Suggested fix (I can open a PR).
scripts/build-zip.sh: normalize Windows-style absolute paths before the /* check (cygpath -u when available); everything else unchanged.
- Hand the Git index mode from
stage-clean-skill.mjs to write-deterministic-zip.mjs explicitly (for example a mode manifest written outside the staged tree) instead of re-deriving it from fs.stat; fail closed when a staged file has no recorded mode; keep the current stat behaviour when no manifest is given so older tags rebuilt by published-update-manifest (ci.yml:136-140) are unaffected.
- Add a platform-independent writer test (declared 100755 but 0644 on disk must produce 0755 in the central header, and the inverse) and let the existing reproducibility test run on Windows.
No archify.zip rebuild is involved: scripts/ is outside the packaged tree, so the committed archive must remain byte-identical and zip-freshness verifies that on Linux.
Supporting visual evidence
No response
Environment
Windows 10 Pro 10.0.19045; Git for Windows 2.32.0.windows.2 (Git Bash / mintty, GNU bash 4.4.23 msys); Node.js v22.21.1; npm 10.9.4. No coding client or model involved; the reproduction is the packaging scripts alone.
Final checks
Archify version or commit
2.17.0-dev.1 at main commit 1891105
Installation method
Git clone
Diagram type
Installation or packaging
Exact command
Minimal redacted JSON reproduction
Validation receipt or exact error
Expected behavior
scripts/build-zip.shaccepts a Windows-style absolute output path (C:/...orC:\...) the same way it accepts POSIX and relative paths. The repository's own test hands it anos.tmpdir()path, so this is the documented entry point on Windows.archify.zipon Windows as well.CONTRIBUTING.md(§Packages and generated artifacts, lines 87-90) and the comment inscripts/build-zip.shstate that canonical bytes depend only on the Node 22 toolchain. The archive should record the Git index mode thatscripts/stage-clean-skill.mjsalready enforces (100755 forarchify/bin/archify.mjs, the only executable tracked underarchify/).archive build is byte-for-byte reproducible across caller time zones without system zippasses on Windows with Node 22.Actual behavior
Two independent defects. Every payload byte is correct; only the output-path handling and one mode bit are wrong.
Defect 1 — Windows-style absolute output paths are treated as relative.
scripts/build-zip.sh:8-10tests[[ "$out" != /* ]]and prepends$(pwd)/to anything that does not start with/.C:/...andC:\...both fail that test. For the forward-slash form the MSYS argument conversion then treats the embedded colon as a path-list separator and converts each half (D:\Workspace\archify\C;C:\Program Files\Git\Users\...); for the backslash form no conversion happens and Node resolves the string against the current drive (D:\d\Workspace\archify\C:\Users\...). Either wayscripts/write-deterministic-zip.mjs:156fails with ENOENT when opening its temporary file. Relative paths and/c/...paths work, which is whyzip-freshness(/tmp/fresh.zip) never sees this.Defect 2 — the executable bit is re-derived from the filesystem and lost on NTFS.
scripts/stage-clean-skill.mjsreads the Git index mode (git ls-files --stage, line 51), allows only 100644/100755 (line 120), and callschmodSync(target, 0o755)for 100755 entries (line 258).scripts/write-deterministic-zip.mjs:111then recomputes the mode asfs.statSync(file.absolute).mode & 0o111 ? 0o755 : 0o644. NTFS has no execute bit: afterchmodSync(f, 0o755),statSync(f).mode & 0o777is0o666on Windows (probe above), so the writer records 0644 forarchify/bin/archify.mjs. That changes four bytes in the entry's central-directory header (line 87), which is why the size is identical while the digest differs.Probe used for the NTFS claim (
mode-demo.mjs, run withcommand node):Entry-by-entry comparison used for the "79/79 identical" claim:
Why CI cannot see it.
testandzip-freshnessrun onubuntu-latestonly (.github/workflows/ci.yml:9-10,82-95);package smoke (windows-latest)only extracts the committed archive (152-181). Windows contributors are nevertheless expected to rebuild and commitarchify.zip(CONTRIBUTING.md:87-90; review requests on #207 and #220) and currently cannot produce canonical bytes, andnpm testreports the reproducibility test as failed on a clean Windows checkout.Related work that does not cover this. #144 / #202 (line endings), #132 (invoking
build-zip.shthrough bash in tests), draft #349 (test-only change to the stager mode assertions; production code unchanged). Neither the writer's mode derivation nor the output-path check is addressed anywhere.Suggested fix (I can open a PR).
scripts/build-zip.sh: normalize Windows-style absolute paths before the/*check (cygpath -uwhen available); everything else unchanged.stage-clean-skill.mjstowrite-deterministic-zip.mjsexplicitly (for example a mode manifest written outside the staged tree) instead of re-deriving it fromfs.stat; fail closed when a staged file has no recorded mode; keep the current stat behaviour when no manifest is given so older tags rebuilt bypublished-update-manifest(ci.yml:136-140) are unaffected.No
archify.ziprebuild is involved:scripts/is outside the packaged tree, so the committed archive must remain byte-identical andzip-freshnessverifies that on Linux.Supporting visual evidence
No response
Environment
Windows 10 Pro 10.0.19045; Git for Windows 2.32.0.windows.2 (Git Bash / mintty, GNU bash 4.4.23 msys); Node.js v22.21.1; npm 10.9.4. No coding client or model involved; the reproduction is the packaging scripts alone.
Final checks