From 618a41c892d3c9e19ba21ae06899385f71f4233e Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Fri, 5 Jun 2026 22:18:03 -0400 Subject: [PATCH] Add split debug archives Add a debug-archive input that preserves optimized release binaries while publishing detached debug data as a separate asset. Darwin builds use Cargo packed dSYM output; Linux builds use objcopy so the companion contains conventional DWARF and the executable keeps a GNU debug link. Reject unsupported cross and universal Darwin configurations instead of silently producing unusable artifacts. Verify UUIDs and source-level symbolication in CI. Developed with assistance from OpenAI Codex, an LLM. --- .github/.cspell/project-dictionary.txt | 9 ++ .github/workflows/ci.yml | 107 +++++++++++++++++++++- CHANGELOG.md | 2 + README.md | 22 +++++ action.yml | 15 ++++ main.sh | 120 +++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 1 deletion(-) diff --git a/.github/.cspell/project-dictionary.txt b/.github/.cspell/project-dictionary.txt index 3cfb7d55..93b33fb8 100644 --- a/.github/.cspell/project-dictionary.txt +++ b/.github/.cspell/project-dictionary.txt @@ -1,3 +1,12 @@ +atos codesign coreutils +debuglink +dsymutil +dwarfdump lipo +objcopy +otool +segname +vmaddr +xcrun diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 254be256..bf3f58a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: matrix: include: - os: ubuntu-24.04 + bin-leading-dir: bin + debug-archive: true + leading-dir: true - os: ubuntu-24.04-arm - os: ubuntu-24.04 target: aarch64-unknown-linux-gnu @@ -66,6 +69,7 @@ jobs: workspace: true - os: macos-latest checksums: sha256 sha512 sha1 md5 + debug-archive: true target: aarch64-apple-darwin - os: macos-latest checksums: sha256, sha512, sha1, md5 @@ -93,7 +97,19 @@ jobs: target: ${{ matrix.target }} runner: none if: matrix.target != '' && matrix.build-tool == 'cargo' - - run: cargo new --bin test-crate + - run: | + cargo new --bin test-crate + cat >| test-crate/src/main.rs <<'EOF' + #[unsafe(no_mangle)] + #[inline(never)] + pub extern "C" fn debug_archive_test() { + std::hint::black_box(()); + } + + fn main() { + debug_archive_test(); + } + EOF - uses: ./ id: upload-rust-binary-action with: @@ -110,6 +126,9 @@ jobs: tar-xz: all zip: all manifest-path: test-crate/Cargo.toml + debug-archive: ${{ matrix.debug-archive || 'false' }} + leading-dir: ${{ matrix.leading-dir || 'false' }} + bin-leading-dir: ${{ matrix.bin-leading-dir }} codesign: '-' codesign-prefix: 'com.example.' codesign-options: 'runtime' @@ -138,15 +157,101 @@ jobs: printf 'outputs.md5 should be a file\n' test -f "${OUTPUT_MD5}" + + if [[ "${DEBUG_ARCHIVE}" == "true" ]]; then + printf 'outputs.debug-archive should be a file\n' + test -f "${OUTPUT_DEBUG_ARCHIVE}" + grep "$(basename -- "${OUTPUT_DEBUG_ARCHIVE}")" "${OUTPUT_SHA256}" >/dev/null + fi env: OUTPUT_ARCHIVE: ${{ steps.upload-rust-binary-action.outputs.archive }} OUTPUT_ZIP: ${{ steps.upload-rust-binary-action.outputs.zip }} OUTPUT_TAR: ${{ steps.upload-rust-binary-action.outputs.tar }} OUTPUT_TAR_XZ: ${{ steps.upload-rust-binary-action.outputs.tar-xz }} + OUTPUT_DEBUG_ARCHIVE: ${{ steps.upload-rust-binary-action.outputs.debug-archive }} OUTPUT_SHA256: ${{ steps.upload-rust-binary-action.outputs.sha256 }} OUTPUT_SHA512: ${{ steps.upload-rust-binary-action.outputs.sha512 }} OUTPUT_SHA1: ${{ steps.upload-rust-binary-action.outputs.sha1 }} OUTPUT_MD5: ${{ steps.upload-rust-binary-action.outputs.md5 }} + DEBUG_ARCHIVE: ${{ matrix.debug-archive || 'false' }} + - name: Check debug archive + if: ${{ matrix.debug-archive }} + env: + BIN_LEADING_DIR: ${{ matrix.bin-leading-dir }} + LEADING_DIR: ${{ matrix.leading-dir || 'false' }} + OUTPUT_ARCHIVE: ${{ steps.upload-rust-binary-action.outputs.archive }} + OUTPUT_DEBUG_ARCHIVE: ${{ steps.upload-rust-binary-action.outputs.debug-archive }} + OUTPUT_TAR: ${{ steps.upload-rust-binary-action.outputs.tar }} + run: | + mkdir -- extracted + tar -xf "${OUTPUT_TAR}" -C extracted + tar -xf "${OUTPUT_DEBUG_ARCHIVE}" -C extracted + debug_prefix='' + if [[ "${LEADING_DIR}" == "true" ]]; then + debug_prefix="${OUTPUT_ARCHIVE}/" + fi + if [[ -n "${BIN_LEADING_DIR}" ]]; then + debug_prefix+="${BIN_LEADING_DIR}/" + fi + binary="extracted/${debug_prefix}test-crate" + + case "${RUNNER_OS}" in + Linux) + debug="extracted/${debug_prefix}test-crate.debug" + test -f "${debug}" + binary_sections="$(objdump -h "${binary}")" + debug_sections="$(objdump -h "${debug}")" + if grep '\.debug_info' <<<"${binary_sections}" >/dev/null; then + exit 1 + fi + grep '\.debug_info' <<<"${debug_sections}" >/dev/null + readelf --string-dump=.gnu_debuglink "${binary}" | + grep 'test-crate.debug' >/dev/null + address="$( + nm --defined-only "${binary}" | + awk '$3 == "debug_archive_test" { address = $1 } + END { print address }' + )" + test -n "${address}" + addr2line -e "${binary}" "0x${address}" | + grep '/test-crate/src/main.rs:' >/dev/null + ;; + macOS) + debug="extracted/${debug_prefix}test-crate.dSYM" + test -d "${debug}" + binary_uuid="$(xcrun dwarfdump --uuid "${binary}" | awk '{ print $2 }')" + debug_uuid="$(xcrun dwarfdump --uuid "${debug}" | awk '{ print $2 }')" + test -n "${binary_uuid}" + test "${binary_uuid}" = "${debug_uuid}" + load_commands="$(otool -l "${binary}")" + if grep '__DWARF' <<<"${load_commands}" >/dev/null; then + exit 1 + fi + load_address="$( + awk ' + $1 == "segname" && $2 == "__TEXT" { text = 1; next } + text && $1 == "vmaddr" && !address { address = $2 } + END { print address } + ' <<<"${load_commands}" + )" + address="$( + nm -nm "${binary}" | + awk '$4 == "_debug_archive_test" { address = $1 } + END { print address }' + )" + dwarf=("${debug}/Contents/Resources/DWARF/"*) + test -n "${load_address}" + test -n "${address}" + test "${#dwarf[@]}" -eq 1 + xcrun atos \ + -arch arm64 \ + -o "${dwarf[0]}" \ + -l "${load_address}" \ + "0x${address}" | + grep 'main.rs:' >/dev/null + ;; + *) exit 1 ;; + esac - name: Check b2 output if: ${{ contains(matrix.checksums || 'b2,sha256,sha512,sha1,md5', 'b2') }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab8ed52..88dffba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Note: In this file, do not use the hard wrap in the middle of a sentence for com ## [Unreleased] +- Add `debug-archive` support for publishing companion debug archives. + ## [1.30.2] - 2026-04-17 - Enhance security when `dry-run` is true. diff --git a/README.md b/README.md index 9044d873..8c9319ad 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Currently, this action is basically intended to be used in combination with an a | checksum | | Algorithms to be used for checksum (sha256, sha512, b2, sha1, or md5) (whitespace or comma separated list).
Note: b2 is not available by default on macOS, install `b2sum` to use it. sha1 and md5 are insecure and strongly discouraged. | String | | | include | | Additional files to be included to the archive (whitespace or comma separated list) | String | | | asset | | Additional files to be uploaded separately (whitespace or comma separated list) | String | | +| debug-archive | | Preserve debug information in a separate release archive | Boolean | `false` | +| objcopy | | `objcopy` command to use when separating ELF debug information | String | | | leading-dir | | Whether to create the leading directory in the archive or not | Boolean | `false` | | bin-leading-dir | | Create extra leading directory(s) for binary file(s) specified by `bin` option | String | | | build-tool | | Tool to build binaries (cargo, cross, or cargo-zigbuild, see [cross-compilation example](#example-workflow-cross-compilation) for more) | String | | @@ -76,6 +78,7 @@ Currently, this action is basically intended to be used in combination with an a | zip | `.zip` archive file name. | | tar | `.tar.gz` archive file name. | | tar-xz | `.tar.xz` archive file name. | +| debug-archive | Separate debug archive file name. | | sha256 | SHA256 checksum file name. | | sha512 | SHA512 checksum file name. | | b2 | BLAKE2 checksum file name. | @@ -641,6 +644,25 @@ README.md - [cargo-hack](https://github.com/taiki-e/cargo-hack/blob/202e6e59d491c9202ce148c9ef423853267226db/.github/workflows/release.yml#L47-L84) - [tokio-console](https://github.com/tokio-rs/console/blob/9699300ec7901b71dce0d3555a7be2c86ec4e533/.github/workflows/release.yaml#L28-L43) +### Preserve debug information + +Use `debug-archive` to publish a small production binary together with a +separate archive containing its debug information: + +```yaml +- uses: taiki-e/upload-rust-binary-action@v1 + with: + bin: my-cli + debug-archive: true +``` + +For macOS Darwin targets this publishes `.dSYM.tar.gz`. For Linux +targets it publishes `.debug.tar.gz` and adds a GNU debug link to the +binary. The debug archive mirrors the binary archive's leading-directory +layout. Cargo cross builds can specify the target-compatible tool with the +`objcopy` input. The `cross` build tool and universal Darwin targets are not +currently supported by this option. This option requires Rust 1.65 or newer. + ### Optimize Rust binary You can optimize performance or size of Rust binaries by passing the profile options. diff --git a/action.yml b/action.yml index 5050f887..acbbed67 100644 --- a/action.yml +++ b/action.yml @@ -67,6 +67,16 @@ inputs: Additional files to be uploaded separately (whitespace or comma separated list). Note that glob pattern is not supported yet. required: false + debug-archive: + description: Preserve debug information in a separate release archive + required: false + debug_archive: + description: Alias for 'debug-archive' + required: false + default: 'false' + objcopy: + description: objcopy command to use when separating ELF debug information + required: false leading-dir: description: Whether to create the leading directory in the archive or not required: false @@ -151,6 +161,9 @@ outputs: tar-xz: description: '.tar.xz archive file name' value: ${{ steps.upload-rust-binary-action.outputs.tar-xz }} + debug-archive: + description: 'Separate debug archive file name' + value: ${{ steps.upload-rust-binary-action.outputs.debug-archive }} b2: description: 'BLAKE2 checksum file name' value: ${{ steps.upload-rust-binary-action.outputs.b2 }} @@ -192,6 +205,8 @@ runs: INPUT_ZIP: ${{ inputs.zip }} INPUT_INCLUDE: ${{ inputs.include }} INPUT_ASSET: ${{ inputs.asset }} + INPUT_DEBUG_ARCHIVE: ${{ inputs.debug-archive || inputs.debug_archive }} + INPUT_OBJCOPY: ${{ inputs.objcopy }} INPUT_LEADING_DIR: ${{ inputs.leading-dir || inputs.leading_dir }} INPUT_BIN_LEADING_DIR: ${{ inputs.bin-leading-dir }} INPUT_BUILD_TOOL: ${{ inputs.build-tool || inputs.build_tool }} diff --git a/main.sh b/main.sh index 484b69b8..40bf2855 100755 --- a/main.sh +++ b/main.sh @@ -204,6 +204,13 @@ if [[ -n "${asset}" ]]; then done < <(normalize_comma_or_space_separated "${asset}") fi +debug_archive_enabled="${INPUT_DEBUG_ARCHIVE:-}" +case "${debug_archive_enabled}" in + true) debug_archive_enabled=1 ;; + false) debug_archive_enabled='' ;; + *) bail "'debug-archive' input option must be 'true' or 'false': '${debug_archive_enabled}'" ;; +esac + checksum="${INPUT_CHECKSUM:-}" checksums=() if [[ -n "${checksum}" ]]; then @@ -322,6 +329,39 @@ case "${input_profile}" in *) profile_directory=${input_profile} ;; esac +if [[ -n "${debug_archive_enabled}" ]]; then + if [[ "${rustc_minor_version}" -lt 65 ]]; then + bail "'debug-archive' requires Rust 1.65 or newer" + fi + profile_env=$(tr '[:lower:]' '[:upper:]' <<<"${input_profile}") + profile_env=${profile_env//-/_} + export "CARGO_PROFILE_${profile_env}_DEBUG=2" + case "${target}" in + universal-apple-darwin | universal2-apple-darwin) + bail "'debug-archive' does not support universal Darwin targets" + ;; + *-apple-darwin) + if [[ "${host_os}" != "macos" ]]; then + bail "'debug-archive' for Darwin targets requires a macOS host" + fi + dsymutil=$(xcrun --find dsymutil) + dsymutil_dir=$(mktemp -d) + ln -s -- "${dsymutil}" "${dsymutil_dir}/dsymutil" + PATH="${dsymutil_dir}:${PATH}" + export PATH + export "CARGO_PROFILE_${profile_env}_SPLIT_DEBUGINFO=packed" + ;; + *-linux-*) + export "CARGO_PROFILE_${profile_env}_STRIP=none" + export "CARGO_PROFILE_${profile_env}_SPLIT_DEBUGINFO=off" + ;; + *) bail "'debug-archive' does not support target '${target}'" ;; + esac + if [[ "${build_tool}" == "cross" ]]; then + bail "'debug-archive' does not support the cross build tool" + fi +fi + bins=() for bin_name in "${bin_names[@]}"; do bins+=("${bin_name}${exe}") @@ -453,6 +493,52 @@ case "${INPUT_TARGET:-}" in ;; esac +debug_files=() +debug_archive='' +if [[ -n "${debug_archive_enabled}" ]]; then + case "${target}" in + *-apple-darwin) + for bin_exe in "${bins[@]}"; do + debug="${target_dir}/${bin_exe}.dSYM" + if [[ ! -d "${debug}" ]]; then + bail "debug bundle not found: ${debug}" + fi + binary_uuid=$(xcrun dwarfdump --uuid "${target_dir}/${bin_exe}" | awk '{ print $2 }') + debug_uuid=$(xcrun dwarfdump --uuid "${debug}" | awk '{ print $2 }') + if [[ -z "${binary_uuid}" ]] || [[ "${binary_uuid}" != "${debug_uuid}" ]]; then + bail "debug bundle UUID does not match binary: ${debug}" + fi + debug_files+=("${bin_exe}.dSYM") + done + debug_archive="${archive}.dSYM.tar.gz" + ;; + *) + objcopy="${INPUT_OBJCOPY:-"${OBJCOPY:-}"}" + if [[ -z "${objcopy}" ]]; then + if type -P llvm-objcopy >/dev/null; then + objcopy=llvm-objcopy + elif type -P objcopy >/dev/null; then + objcopy=objcopy + else + bail "'debug-archive' requires objcopy; specify the 'objcopy' input option" + fi + elif ! type -P "${objcopy}" >/dev/null; then + bail "objcopy command not found: ${objcopy}" + fi + for bin_exe in "${bins[@]}"; do + debug="${target_dir}/${bin_exe}.debug" + x "${objcopy}" --only-keep-debug "${target_dir}/${bin_exe}" "${debug}" + ( + cd -- "${target_dir}" + x "${objcopy}" --strip-debug --add-gnu-debuglink="${bin_exe}.debug" "${bin_exe}" + ) + debug_files+=("${bin_exe}.debug") + done + debug_archive="${archive}.debug.tar.gz" + ;; + esac +fi + case "${host_os}" in macos) if type -P codesign >/dev/null; then @@ -461,6 +547,40 @@ case "${host_os}" in ;; esac +if [[ -n "${debug_archive}" ]]; then + assets+=("${debug_archive}") + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf 'debug-archive=%s\n' "${debug_archive}" >>"${GITHUB_OUTPUT}" + else + warn "GITHUB_OUTPUT is not set; skip setting the 'debug-archive' output" + printf 'debug-archive: %s\n' "${debug_archive}" + fi + debug_archive_path="${PWD}/${debug_archive}" + debug_tmpdir=$(mktemp -d) + mkdir -- "${debug_tmpdir:?}/${archive}" + if [[ -n "${bin_leading_dir}" ]]; then + mkdir -p -- "${debug_tmpdir}/${archive}/${bin_leading_dir}"/ + debug_filenames=("${bin_leading_dir%%/*}") + debug_destination="${debug_tmpdir}/${archive}/${bin_leading_dir}" + else + debug_filenames=("${debug_files[@]}") + debug_destination="${debug_tmpdir}/${archive}" + fi + for debug_file in "${debug_files[@]}"; do + x cp -rL -- "${target_dir}/${debug_file}" "${debug_destination}"/ + done + pushd -- "${debug_tmpdir}" >/dev/null + if [[ -n "${leading_dir}" ]]; then + x tar acf "${debug_archive_path}" "${archive}" + else + pushd -- "${archive}" >/dev/null + x tar acf "${debug_archive_path}" "${debug_filenames[@]}" + popd >/dev/null + fi + popd >/dev/null + rm -rf -- "${debug_tmpdir:?}" +fi + if [[ "${INPUT_TAR/all/${platform}}" == "${platform}" ]] \ || [[ "${INPUT_TAR_XZ/all/${platform}}" == "${platform}" ]] \ || [[ "${INPUT_ZIP/all/${platform}}" == "${platform}" ]]; then