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