Skip to content

Commit 2cb95b8

Browse files
robertsLandoclaude
andauthored
feat(e2e): claude-code smoke with Zstd + tar.gz + archive verify (#12)
* feat(e2e): claude-code smoke with Zstd + tar.gz + archive verify - Add `Zstd` to the compress-node enum (core/inputs.ts), regenerate action.yml / packages/build/action.yml / docs/inputs.md, rebundle dist. Zstd requires Node.js >= 22.15 on the build host. - New `claude-code-smoke` e2e job: installs @anthropic-ai/claude-code from npm, builds via SEA mode with compress-node=Zstd + tar.gz + sha256 on ubuntu-x64/arm64, macos-arm64, windows-x64. Runs the binary with `claude --version`, then recomputes the sha256 against the sidecar and extracts the tarball to re-verify --version on the archived copy. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(e2e): pin claude-code 2.1.114 (last pure-JS) + pkg 6.18 for Zstd - @anthropic-ai/claude-code >= 2.1.117 ships as a thin wrapper that delegates to a native binary via optionalDependencies; pkg can't bundle that. Pin 2.1.114, the last release whose `bin.claude` points at a real `cli.js`. - pkg 6.17 is the first yao-pkg/pkg to accept `--compress Zstd`; the action's default pkg-version (~6.16.0) rejected it with "Invalid compression algorithm Zstd". Override to ~6.18.0 for this job. - Resolve `pkg.bin.claude` explicitly (instead of Object.values()[0]) so a future per-OS alias can't steer us at the wrong entrypoint. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(e2e): pin claude-code 2.1.112 + print binary output on non-zero exit - 2.1.113 already switched @anthropic-ai/claude-code from a pure-JS cli.js entrypoint to a native-binary wrapper (bin/claude.exe). My earlier pin of 2.1.114 landed inside the new layout, so pkg was compiling a shell-script placeholder that errors at runtime. 2.1.112 is the last pure-JS release — verified locally with SEA+Zstd: builds in ~20s, binary runs and prints "2.1.112 (Claude Code)" on --version. - Previous `--version` step captured output via `out=$(... 2>&1)` under `set -e`, which aborted before echoing when the binary exited non- zero (as it did on every runner). Suspend `set -e` around the capture so the logs actually show what the binary printed. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(archive): drop --mtime on bsdtar; strip sha256sum backslash-escape Surfaced by the new claude-code-smoke e2e: - macOS: the macos-latest runner's bsdtar rejects `--mtime` outright with "Option --mtime is not supported", even though upstream libarchive accepts it. The action was always passing --mtime; tiny- cjs never hit this because its macOS matrix entry uses zip. Keep --mtime on GNU tar (linux) only; rely on the utimes pre-pin for bsdtar paths (archive() is single-file, so the header inherits that mtime). Test updated to assert --mtime presence only on linux. - Windows: git-bash's `sha256sum` escapes the output line with a leading backslash when the filename contains backslashes (Windows paths), per GNU coreutils convention. The e2e's verify step was comparing `\<digest>` against the sidecar's clean hex. Strip the leading backslash via sed before parsing. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(e2e): cygpath archive/extract paths on Windows before tar GNU tar from git-bash parses `D:\a\...` as a remote host:path target (colon-separated rsh syntax), producing: tar (child): Cannot connect to D: resolve failed Convert any Windows drive-letter path to the POSIX form (`/d/a/...`) via cygpath before invoking tar. No-op on Linux/macOS — falls back to echo when cygpath isn't on PATH. --version + sha256 sidecar check already passed on Windows in the previous run; this unblocks the tar-round-trip tail. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * chore(e2e): trip-wire for non-JS claude bin + cygpath fixture path Review follow-ups on #12: - Guard the pin: assert the resolved bin entry is a JS file (.js/.mjs/ .cjs) before patching it into the fixture package.json. If the @anthropic-ai/claude-code pin ever drifts past 2.1.112 into the native-binary wrapper layout (bin → bin/claude.exe shell-script placeholder), fail loudly rather than bundling garbage. - Replace the ad-hoc `${fix//\\//}` backslash-strip with the same cygpath helper used in the verify step, so the fixture path emitted to GITHUB_OUTPUT is always POSIX-form on Windows git-bash. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * revert(e2e): restore drive-letter fixture path; cygpath was Node-incompatible Reverting the $fix cygpath conversion from 3fca6c7. cygpath -u emits POSIX form (/d/a/...), which GNU tar in git-bash handles correctly but Node's fs on Windows misparses as drive-relative, producing paths like D:\d\a\_temp\... and failing with ENOENT. The composite's build step is Node-based, not shell-tar-based, so it wants the drive-letter- plus-forward-slash form (D:/a/...) that ${fix//\\//} produces. cygpath stays in the verify step where tar is the consumer — different tool, different path convention. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(build): drop duplicate pkg log + group per-target logs with timings Surfaced by matrix e2e debugging: the orchestrator emitted every pkg argv twice (once as "[pkg-action] pkg …" in main.ts, once as "[pkg-action] Invoking: …" from the runner) and was silent through the finalize stages — no signal for when archive started, when the checksum was computed, or which shasum files were written. - Drop the main.ts pre-log; runPkg's "Invoking:" already carries the command path, and GH Actions renders the raw `[command]` line too, so three copies was overkill. - Log pkg wall time after runPkg completes. - Group the per-target finalize loop under a `logger.startGroup` so each target collapses into its own fold in the GH Actions UI — matters for matrix runs where 4+ targets interleave. - Add explicit archive/checksum progress lines with sizes + timings (`archive → …`, `archived … (17.8 MB, 4.2s)`, `sha256 <digest> <file>`) so any downstream verification failure trivially diffs against the build log. - Log each SHASUMS file as it's written, with entry count. - Include overall wall time in the final "done" line. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * feat(build): group pkg invocation in its own log fold The pkg CLI emits ~15–30 lines per build (Walking dependencies, node download/extract, SEA asset generation, blob injection, strip warnings) and the GH-Actions exec shim adds `[command]` echo on top. On a multi- target run all of that interleaved with finalize output is hard to skim. Wrap runPkg in startGroup/endGroup with a header that names the target list, so reviewers see a single collapsible "pkg build (targets=…)" block followed by the one-line wall-time summary. Same pattern we already use for the per-target finalize blocks. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 6cfb86a commit 2cb95b8

11 files changed

Lines changed: 444 additions & 152 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,204 @@ jobs:
250250
[ -f "$bin" ] || { echo "::error::binary missing: $bin"; exit 1; }
251251
echo "Checking $bin"
252252
node --experimental-strip-types .github/scripts/assert-windows-metadata.ts "$bin"
253+
254+
# ──────────────────────────────────────────────────────────────────────
255+
# Real-world smoke: pull @anthropic-ai/claude-code from npm, build a
256+
# native binary per runner OS/arch with Zstd-compressed pkg payload +
257+
# tar.gz archive, execute the binary with --version, then verify the
258+
# archive round-trips and the sha256 sidecar matches the archive bytes.
259+
#
260+
# claude-code is ESM, so mode: sea (standard pkg can't bytecode-compile
261+
# ESM). Each matrix entry builds for the runner's native target so the
262+
# produced binary can be launched in-place for the smoke assertion,
263+
# giving real cross-OS + cross-arch coverage (x64 and arm64 on Linux,
264+
# arm64 on macOS, x64 on Windows). Zstd bundled-binary compression
265+
# requires Node.js >= 22.15 on the build host; .node-version is 22.22.
266+
claude-code-smoke:
267+
name: claude-code-smoke / ${{ matrix.os }} / ${{ matrix.target }}
268+
runs-on: ${{ matrix.os }}
269+
timeout-minutes: 30
270+
strategy:
271+
fail-fast: false
272+
matrix:
273+
include:
274+
- os: ubuntu-latest
275+
target: node22-linux-x64
276+
- os: ubuntu-24.04-arm
277+
target: node22-linux-arm64
278+
- os: macos-latest
279+
target: node22-macos-arm64
280+
- os: windows-latest
281+
target: node22-win-x64
282+
steps:
283+
- uses: actions/checkout@v6
284+
285+
- uses: actions/setup-node@v6
286+
with:
287+
node-version-file: .node-version
288+
289+
- name: Prepare claude-code fixture
290+
id: fix
291+
shell: bash
292+
run: |
293+
set -euo pipefail
294+
fix="${RUNNER_TEMP}/claude-code-fixture"
295+
mkdir -p "$fix"
296+
cd "$fix"
297+
npm init -y >/dev/null
298+
# Pin 2.1.112 — the last pure-JS release of
299+
# @anthropic-ai/claude-code. Starting at 2.1.113 the package
300+
# ships as a thin wrapper around a native binary distributed
301+
# via platform optionalDependencies (bin points to a shell-
302+
# script placeholder like `bin/claude.exe`), which pkg can't
303+
# meaningfully bundle. --ignore-scripts skips the postinstall
304+
# native-binary fetcher — irrelevant for 2.1.112 but a cheap
305+
# guardrail if the pin ever drifts.
306+
npm install --omit=dev --ignore-scripts @anthropic-ai/[email protected]
307+
# Resolve the package's `claude` bin entry. Prefer the named
308+
# 'claude' key so we don't accidentally pick a future per-OS
309+
# alias (e.g. 'claude.exe') that would break pkg on Linux.
310+
entry=$(node -e "
311+
const path = require('path');
312+
const pkgPath = require.resolve('@anthropic-ai/claude-code/package.json', { paths: [process.cwd()] });
313+
const pkgDir = path.dirname(pkgPath);
314+
const pkg = require(pkgPath);
315+
const bin = typeof pkg.bin === 'string'
316+
? pkg.bin
317+
: (pkg.bin && pkg.bin.claude) || Object.values(pkg.bin || {})[0];
318+
if (!bin) throw new Error('claude bin not found in package.json');
319+
console.log(path.relative(process.cwd(), path.resolve(pkgDir, bin)).split(path.sep).join('/'));
320+
")
321+
echo "entry=$entry"
322+
# Trip-wire: if the pin ever drifts to a claude-code version
323+
# that again points `bin.claude` at a shell-script placeholder
324+
# (bin/claude.exe), fail loudly here rather than letting pkg
325+
# silently bundle a non-JS file.
326+
case "$entry" in
327+
*.js|*.mjs|*.cjs) ;;
328+
*) echo "::error::claude bin is not a JS entrypoint ($entry) — pin may have drifted past 2.1.112"; exit 1 ;;
329+
esac
330+
node -e "
331+
const fs = require('fs');
332+
const p = './package.json';
333+
const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
334+
pkg.bin = './' + process.argv[1];
335+
pkg.pkg = {
336+
assets: ['node_modules/@anthropic-ai/claude-code/**/*']
337+
};
338+
fs.writeFileSync(p, JSON.stringify(pkg, null, 2));
339+
" "$entry"
340+
cat ./package.json
341+
# Emit drive-letter + forward-slash form (e.g. `D:/a/_temp/…`).
342+
# The downstream composite is Node-based: Node's fs resolves
343+
# `D:/a/…` correctly on Windows but mis-parses cygpath's POSIX
344+
# form (`/d/a/…`) as drive-relative, yielding `D:\d\a\…`.
345+
echo "fixture=${fix//\\//}" >> "$GITHUB_OUTPUT"
346+
347+
- name: Build claude binary
348+
id: build
349+
uses: ./
350+
with:
351+
config: ${{ steps.fix.outputs.fixture }}/package.json
352+
targets: ${{ matrix.target }}
353+
mode: sea
354+
compress-node: Zstd
355+
# Zstd landed in @yao-pkg/pkg 6.17. The action's default
356+
# (~6.16.0) predates it, so pin a Zstd-capable version.
357+
pkg-version: ~6.18.0
358+
compress: tar.gz
359+
checksum: sha256
360+
filename: 'claude-{version}-{os}-{arch}'
361+
362+
- name: Run claude --version
363+
shell: bash
364+
run: |
365+
set -uo pipefail
366+
bin=$(echo '${{ steps.build.outputs.binaries }}' | jq -r '.[0]')
367+
[ -f "$bin" ] || { echo "::error::binary missing: $bin"; exit 1; }
368+
chmod +x "$bin" || true
369+
# Capture without `set -e` so stderr-carrying, non-zero exits
370+
# still print the output for diagnosis.
371+
out=$("$bin" --version 2>&1)
372+
status=$?
373+
echo "--- claude --version (exit=$status) ---"
374+
echo "$out"
375+
echo "--- end ---"
376+
[ "$status" -eq 0 ] || { echo "::error::claude --version exited $status"; exit 1; }
377+
# claude --version prints a semver-shaped string. Enforce the
378+
# shape to catch silent regressions where the binary boots but
379+
# spits an unrelated banner.
380+
echo "$out" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+' || {
381+
echo "::error::unexpected --version output"; exit 1;
382+
}
383+
384+
- name: Verify tar.gz archive + sha256 sidecar
385+
shell: bash
386+
run: |
387+
set -euo pipefail
388+
# GNU tar in git-bash parses `D:\a\...` / `D:/a/...` as
389+
# host:path (colon-separated remote rsh target). cygpath
390+
# converts to the POSIX form (`/d/a/...`) that GNU tar treats
391+
# as a local absolute path. No-op on Linux/macOS.
392+
to_posix() {
393+
if command -v cygpath >/dev/null 2>&1; then
394+
cygpath -u "$1"
395+
else
396+
echo "$1"
397+
fi
398+
}
399+
archive=$(to_posix "$(echo '${{ steps.build.outputs.artifacts }}' | jq -r '.[0]')")
400+
[ -f "$archive" ] || { echo "::error::archive missing: $archive"; exit 1; }
401+
case "$archive" in
402+
*.tar.gz) ;;
403+
*) echo "::error::expected .tar.gz archive, got: $archive"; exit 1 ;;
404+
esac
405+
406+
# Sidecar must exist and carry a 64-hex-char sha256 + the archive
407+
# basename. Recompute the digest locally and compare byte-for-byte
408+
# against the sidecar so we catch any post-build tampering.
409+
sidecar="${archive}.sha256"
410+
[ -f "$sidecar" ] || { echo "::error::missing checksum sidecar: $sidecar"; exit 1; }
411+
recorded=$(awk 'NR==1 { print $1 }' "$sidecar")
412+
[ "${#recorded}" -eq 64 ] || { echo "::error::malformed sha256 in sidecar: $recorded"; exit 1; }
413+
# sha256sum on git-bash prefixes the whole line with a literal
414+
# backslash when the filename contains backslashes (Windows
415+
# paths), per GNU coreutils' name-escape convention — strip it
416+
# before comparing against the sidecar's clean hex digest.
417+
if command -v sha256sum >/dev/null 2>&1; then
418+
actual=$(sha256sum "$archive" | sed 's/^\\//' | awk '{print $1}')
419+
else
420+
actual=$(shasum -a 256 "$archive" | awk '{print $1}')
421+
fi
422+
if [ "$recorded" != "$actual" ]; then
423+
echo "::error::sha256 mismatch — sidecar=$recorded actual=$actual"; exit 1
424+
fi
425+
echo "sidecar sha256 OK: $recorded"
426+
427+
# Tar round-trip: extract into a scratch dir, confirm the binary
428+
# lands inside, then run --version on the extracted copy so we
429+
# know the archive itself — not just the pre-archive binary — is
430+
# usable.
431+
extract=$(to_posix "${RUNNER_TEMP}/claude-extract")
432+
rm -rf "$extract"
433+
mkdir -p "$extract"
434+
tar -tzf "$archive" >/dev/null
435+
tar -xzf "$archive" -C "$extract"
436+
437+
bin_basename=$(basename "$(echo '${{ steps.build.outputs.binaries }}' | jq -r '.[0]')")
438+
extracted_bin=$(find "$extract" -type f -name "$bin_basename" | head -n 1)
439+
[ -n "$extracted_bin" ] || { echo "::error::extracted tar missing $bin_basename"; exit 1; }
440+
chmod +x "$extracted_bin" || true
441+
# Temporarily suspend `set -e` so non-zero exits still print
442+
# the captured output for diagnosis.
443+
set +e
444+
out=$("$extracted_bin" --version 2>&1)
445+
status=$?
446+
set -e
447+
echo "--- extracted $extracted_bin --version (exit=$status) ---"
448+
echo "$out"
449+
echo "--- end ---"
450+
[ "$status" -eq 0 ] || { echo "::error::extracted binary exited $status"; exit 1; }
451+
echo "$out" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+' || {
452+
echo "::error::extracted binary produced unexpected output"; exit 1;
453+
}

action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ inputs:
2222
description: 'pkg''s bundled Node.js major (e.g. 22, 24). Does not affect the action''s own Node runtime.'
2323
default: '22'
2424
compress-node:
25-
description: 'pkg''s bundled-binary compression: Brotli | GZip | None.'
25+
description: 'pkg''s bundled-binary compression: Brotli | GZip | Zstd | None. Zstd requires Node.js >= 22.15 on the build host.'
2626
default: 'None'
2727
fallback-to-source:
2828
description: 'Pass pkg --fallback-to-source for bytecode-fabricator failures.'

docs/inputs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Every `pkg-action` input, grouped by category.
1313
| `targets` || no | no | Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target. |
1414
| `mode` | `standard` | no | no | standard \| sea — selects pkg Standard or SEA mode. |
1515
| `node-version` | `22` | no | no | pkg's bundled Node.js major (e.g. 22, 24). Does not affect the action's own Node runtime. |
16-
| `compress-node` | `None` | no | no | pkg's bundled-binary compression: Brotli \| GZip \| None. |
16+
| `compress-node` | `None` | no | no | pkg's bundled-binary compression: Brotli \| GZip \| Zstd \| None. Zstd requires Node.js >= 22.15 on the build host. |
1717
| `fallback-to-source` | `false` | no | no | Pass pkg --fallback-to-source for bytecode-fabricator failures. |
1818
| `public` | `false` | no | no | Pass pkg --public (ships sources as plaintext). |
1919
| `public-packages` || no | no | Comma-separated package names to mark public (pkg --public-packages). |

packages/build/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ inputs:
1919
description: 'pkg''s bundled Node.js major (e.g. 22, 24). Does not affect the action''s own Node runtime.'
2020
default: '22'
2121
compress-node:
22-
description: 'pkg''s bundled-binary compression: Brotli | GZip | None.'
22+
description: 'pkg''s bundled-binary compression: Brotli | GZip | Zstd | None. Zstd requires Node.js >= 22.15 on the build host.'
2323
default: 'None'
2424
fallback-to-source:
2525
description: 'Pass pkg --fallback-to-source for bytecode-fabricator failures.'

0 commit comments

Comments
 (0)