| 1 | name: Release artifacts |
| 2 | |
| 3 | on: |
| 4 | workflow_call: |
| 5 | inputs: |
| 6 | source_sha: |
| 7 | description: Exact 40-character source commit to build |
| 8 | required: true |
| 9 | type: string |
| 10 | version: |
| 11 | description: Workspace version without a v prefix |
| 12 | required: true |
| 13 | type: string |
| 14 | retention_days: |
| 15 | description: Retention for Actions-only intermediate and assembled artifacts |
| 16 | required: false |
| 17 | default: 7 |
| 18 | type: number |
| 19 | |
| 20 | permissions: |
| 21 | contents: read |
| 22 | |
| 23 | env: |
| 24 | CARGO_TERM_COLOR: always |
| 25 | CARGO_INCREMENTAL: 0 |
| 26 | RUSTFLAGS: -Dwarnings |
| 27 | # Build identity is the trusted workflow SHA. Callers pass source_sha only |
| 28 | # so `pin` can refuse a mismatch; it must not retarget checkout or caches. |
| 29 | CODEWHALE_BUILD_SHA: ${{ github.sha }} |
| 30 | |
| 31 | jobs: |
| 32 | pin: |
| 33 | name: Pin caller SHA to this run |
| 34 | timeout-minutes: 10 |
| 35 | runs-on: ubuntu-latest |
| 36 | steps: |
| 37 | - name: Require source_sha equals github.sha |
| 38 | env: |
| 39 | SOURCE_SHA: ${{ inputs.source_sha }} |
| 40 | run: | |
| 41 | set -euo pipefail |
| 42 | if [[ "${#SOURCE_SHA}" -ne 40 || "${SOURCE_SHA}" =~ [^0-9a-fA-F] ]]; then |
| 43 | echo "::error::source_sha must be a full 40-character commit SHA." >&2 |
| 44 | exit 1 |
| 45 | fi |
| 46 | expected="$(printf '%s' "${SOURCE_SHA}" | tr '[:upper:]' '[:lower:]')" |
| 47 | actual="$(printf '%s' "${GITHUB_SHA}" | tr '[:upper:]' '[:lower:]')" |
| 48 | if [[ "${actual}" != "${expected}" ]]; then |
| 49 | echo "::error::Reusable workflow SHA ${actual} does not match source_sha ${SOURCE_SHA}." >&2 |
| 50 | exit 1 |
| 51 | fi |
| 52 | |
| 53 | build: |
| 54 | name: Build ${{ matrix.platform }} |
| 55 | timeout-minutes: 90 |
| 56 | # FreeBSD is a source-build target validated via `cargo check --target x86_64-unknown-freebsd -p codewhale-cli --locked` |
| 57 | # (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd). The 7×1 prebuilt matrix stays 7 targets; |
| 58 | # FreeBSD has no prebuilt asset, no npm binary, and no matrix bloat — it builds from source. |
| 59 | strategy: |
| 60 | fail-fast: false |
| 61 | matrix: |
| 62 | include: |
| 63 | - os: ubuntu-latest |
| 64 | target: x86_64-unknown-linux-musl |
| 65 | platform: linux-x64 |
| 66 | cli_binary: codewhale |
| 67 | shim_binary: codew |
| 68 | cli_artifact: codewhale-linux-x64 |
| 69 | shim_artifact: codew-linux-x64 |
| 70 | compat_tui_artifact: codewhale-tui-linux-x64 |
| 71 | - os: ubuntu-24.04-arm |
| 72 | target: aarch64-unknown-linux-musl |
| 73 | platform: linux-arm64 |
| 74 | cli_binary: codewhale |
| 75 | shim_binary: codew |
| 76 | cli_artifact: codewhale-linux-arm64 |
| 77 | shim_artifact: codew-linux-arm64 |
| 78 | compat_tui_artifact: codewhale-tui-linux-arm64 |
| 79 | - os: ubuntu-latest |
| 80 | target: aarch64-linux-android |
| 81 | platform: android-arm64 |
| 82 | cli_binary: codewhale |
| 83 | shim_binary: codew |
| 84 | cli_artifact: codewhale-android-arm64 |
| 85 | shim_artifact: codew-android-arm64 |
| 86 | compat_tui_artifact: codewhale-tui-android-arm64 |
| 87 | - os: macos-latest |
| 88 | target: x86_64-apple-darwin |
| 89 | platform: macos-x64 |
| 90 | cli_binary: codewhale |
| 91 | shim_binary: codew |
| 92 | cli_artifact: codewhale-macos-x64 |
| 93 | shim_artifact: codew-macos-x64 |
| 94 | compat_tui_artifact: codewhale-tui-macos-x64 |
| 95 | - os: macos-latest |
| 96 | target: aarch64-apple-darwin |
| 97 | platform: macos-arm64 |
| 98 | cli_binary: codewhale |
| 99 | shim_binary: codew |
| 100 | cli_artifact: codewhale-macos-arm64 |
| 101 | shim_artifact: codew-macos-arm64 |
| 102 | compat_tui_artifact: codewhale-tui-macos-arm64 |
| 103 | - os: windows-latest |
| 104 | target: x86_64-pc-windows-msvc |
| 105 | platform: windows-x64 |
| 106 | cli_binary: codewhale.exe |
| 107 | shim_binary: codew.exe |
| 108 | cli_artifact: codewhale-windows-x64.exe |
| 109 | shim_artifact: codew-windows-x64.exe |
| 110 | compat_tui_artifact: codewhale-tui-windows-x64.exe |
| 111 | - os: windows-11-arm |
| 112 | target: aarch64-pc-windows-msvc |
| 113 | platform: windows-arm64 |
| 114 | cli_binary: codewhale.exe |
| 115 | shim_binary: codew.exe |
| 116 | cli_artifact: codewhale-windows-arm64.exe |
| 117 | shim_artifact: codew-windows-arm64.exe |
| 118 | compat_tui_artifact: codewhale-tui-windows-arm64.exe |
| 119 | runs-on: ${{ matrix.os }} |
| 120 | needs: pin |
| 121 | steps: |
| 122 | # No ref: — GITHUB_SHA only. CodeQL treats workflow_call checkout-with-ref |
| 123 | # and any ref named *sha* as an untrusted checkout (cache-poisoning). |
| 124 | - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 |
| 125 | - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master 2026-07-18 |
| 126 | with: |
| 127 | toolchain: stable |
| 128 | targets: ${{ matrix.target }} |
| 129 | - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 |
| 130 | id: sccache |
| 131 | continue-on-error: true |
| 132 | - name: Enable sccache |
| 133 | if: steps.sccache.outcome == 'success' |
| 134 | shell: bash |
| 135 | run: | |
| 136 | { |
| 137 | echo "SCCACHE_GHA_ENABLED=true" |
| 138 | echo "RUSTC_WRAPPER=sccache" |
| 139 | echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" |
| 140 | } >> "${GITHUB_ENV}" |
| 141 | # Restore after the trusted lockfile is on disk. Key is OS + arch + |
| 142 | # explicit stable toolchain + rust-cache's Cargo.lock / rust-toolchain |
| 143 | # hash. Never interpolate github.event, github.ref, github.sha, or inputs. |
| 144 | - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 |
| 145 | with: |
| 146 | cache-bin: false |
| 147 | prefix-key: v1-${{ runner.os }}-${{ runner.arch }}-stable |
| 148 | - name: Build static Linux binaries (musl) |
| 149 | if: endsWith(matrix.target, '-unknown-linux-musl') |
| 150 | shell: bash |
| 151 | run: | |
| 152 | sudo apt-get update |
| 153 | sudo apt-get install -y binutils musl-tools |
| 154 | rustup target add --toolchain stable ${{ matrix.target }} |
| 155 | cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli |
| 156 | - name: Configure Android NDK linker |
| 157 | if: matrix.target == 'aarch64-linux-android' && runner.os == 'Linux' |
| 158 | shell: bash |
| 159 | env: |
| 160 | ANDROID_NDK_VERSION: 27.2.12479018 |
| 161 | run: | |
| 162 | set -euo pipefail |
| 163 | sudo apt-get update |
| 164 | sudo apt-get install -y libclang-dev |
| 165 | ndk="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}" |
| 166 | linker="" |
| 167 | if [[ -n "${ndk}" ]]; then |
| 168 | linker="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" |
| 169 | fi |
| 170 | if [[ -z "${linker}" || ! -x "${linker}" ]]; then |
| 171 | if ! command -v sdkmanager >/dev/null 2>&1; then |
| 172 | echo "sdkmanager is required to install Android NDK ${ANDROID_NDK_VERSION}" >&2 |
| 173 | exit 1 |
| 174 | fi |
| 175 | android_home="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" |
| 176 | if [[ -z "${android_home}" ]]; then |
| 177 | echo "ANDROID_HOME or ANDROID_SDK_ROOT is required to install Android NDK ${ANDROID_NDK_VERSION}" >&2 |
| 178 | exit 1 |
| 179 | fi |
| 180 | yes | sdkmanager --licenses >/dev/null || true |
| 181 | sdkmanager --install "ndk;${ANDROID_NDK_VERSION}" |
| 182 | ndk="${android_home}/ndk/${ANDROID_NDK_VERSION}" |
| 183 | linker="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" |
| 184 | fi |
| 185 | ar="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" |
| 186 | if [[ ! -x "${linker}" ]]; then |
| 187 | echo "Android linker not found: ${linker}" >&2 |
| 188 | exit 1 |
| 189 | fi |
| 190 | if [[ ! -x "${ar}" ]]; then |
| 191 | echo "Android archiver not found: ${ar}" >&2 |
| 192 | exit 1 |
| 193 | fi |
| 194 | { |
| 195 | echo "ANDROID_NDK_ROOT=${ndk}" |
| 196 | echo "ANDROID_NDK_HOME=${ndk}" |
| 197 | echo "CC_aarch64_linux_android=${linker}" |
| 198 | echo "AR_aarch64_linux_android=${ar}" |
| 199 | echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=${linker}" |
| 200 | echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--target=aarch64-linux-android24 --sysroot=${ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot" |
| 201 | } >> "${GITHUB_ENV}" |
| 202 | - name: Build |
| 203 | if: ${{ !endsWith(matrix.target, '-unknown-linux-musl') }} |
| 204 | shell: bash |
| 205 | run: cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli |
| 206 | - name: Materialize codew command alias |
| 207 | shell: bash |
| 208 | run: | |
| 209 | bin_dir="target/${{ matrix.target }}/dist" |
| 210 | cp "${bin_dir}/${{ matrix.cli_binary }}" "${bin_dir}/${{ matrix.shim_binary }}" |
| 211 | cmp "${bin_dir}/${{ matrix.cli_binary }}" "${bin_dir}/${{ matrix.shim_binary }}" |
| 212 | - name: Verify static Linux binaries and launch on matching native runners |
| 213 | if: >- |
| 214 | endsWith(matrix.target, '-unknown-linux-musl') && |
| 215 | ((startsWith(matrix.target, 'x86_64-') && runner.arch == 'X64') || |
| 216 | (startsWith(matrix.target, 'aarch64-') && runner.arch == 'ARM64')) |
| 217 | shell: bash |
| 218 | run: | |
| 219 | set -euo pipefail |
| 220 | bin_dir="target/${{ matrix.target }}/dist" |
| 221 | for binary in "${{ matrix.cli_binary }}" "${{ matrix.shim_binary }}"; do |
| 222 | bin_path="${bin_dir}/${binary}" |
| 223 | if readelf -l "${bin_path}" | grep -Fq 'INTERP'; then |
| 224 | echo "Expected a static musl binary, but ${bin_path} has an ELF interpreter" >&2 |
| 225 | exit 1 |
| 226 | fi |
| 227 | "${bin_path}" --version |
| 228 | done |
| 229 | - name: Smoke binaries on matching native runners |
| 230 | if: >- |
| 231 | matrix.target != 'aarch64-linux-android' && |
| 232 | ((startsWith(matrix.target, 'x86_64-') && runner.arch == 'X64') || |
| 233 | (startsWith(matrix.target, 'aarch64-') && runner.arch == 'ARM64')) |
| 234 | shell: bash |
| 235 | run: | |
| 236 | bin_dir="target/${{ matrix.target }}/dist" |
| 237 | "${bin_dir}/${{ matrix.cli_binary }}" --version |
| 238 | "${bin_dir}/${{ matrix.shim_binary }}" --version |
| 239 | - name: Stage binaries |
| 240 | shell: bash |
| 241 | run: | |
| 242 | stage_binary() { |
| 243 | local binary="$1" |
| 244 | local artifact="$2" |
| 245 | local bin_path="target/${{ matrix.target }}/dist/${binary}" |
| 246 | if [[ ! -f "${bin_path}" ]]; then |
| 247 | echo "Binary not at ${bin_path}; searching target/ for ${binary}:" >&2 |
| 248 | find target -name "${binary}" -type f |
| 249 | exit 1 |
| 250 | fi |
| 251 | cp "${bin_path}" "${artifact}" |
| 252 | } |
| 253 | |
| 254 | stage_binary "${{ matrix.cli_binary }}" "${{ matrix.cli_artifact }}" |
| 255 | stage_binary "${{ matrix.shim_binary }}" "${{ matrix.shim_artifact }}" |
| 256 | # Compatibility bridge for v0.9.4's hard-coded release |
| 257 | # completeness/updater contract. This is the same runtime, not a |
| 258 | # separately compiled or installed TUI command. |
| 259 | stage_binary "${{ matrix.cli_binary }}" "${{ matrix.compat_tui_artifact }}" |
| 260 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 261 | with: |
| 262 | name: ${{ matrix.cli_artifact }} |
| 263 | path: ${{ matrix.cli_artifact }} |
| 264 | if-no-files-found: error |
| 265 | retention-days: ${{ inputs.retention_days }} |
| 266 | overwrite: true |
| 267 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 268 | with: |
| 269 | name: ${{ matrix.shim_artifact }} |
| 270 | path: ${{ matrix.shim_artifact }} |
| 271 | if-no-files-found: error |
| 272 | retention-days: ${{ inputs.retention_days }} |
| 273 | overwrite: true |
| 274 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 275 | with: |
| 276 | name: ${{ matrix.compat_tui_artifact }} |
| 277 | path: ${{ matrix.compat_tui_artifact }} |
| 278 | if-no-files-found: error |
| 279 | retention-days: ${{ inputs.retention_days }} |
| 280 | overwrite: true |
| 281 | |
| 282 | bundle: |
| 283 | timeout-minutes: 15 |
| 284 | needs: build |
| 285 | if: ${{ !cancelled() && needs.build.result == 'success' }} |
| 286 | runs-on: ubuntu-latest |
| 287 | steps: |
| 288 | - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 |
| 289 | - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 |
| 290 | with: |
| 291 | path: artifacts |
| 292 | pattern: '*' |
| 293 | - name: Create and checksum platform archives |
| 294 | shell: bash |
| 295 | env: |
| 296 | SOURCE_SHA: ${{ github.sha }} |
| 297 | run: | |
| 298 | set -euo pipefail |
| 299 | source_date_epoch="$(git show -s --format=%ct "${SOURCE_SHA}")" |
| 300 | if [[ ! "${source_date_epoch}" =~ ^[0-9]+$ ]]; then |
| 301 | echo "Could not read a Unix timestamp for source commit ${SOURCE_SHA}" >&2 |
| 302 | exit 1 |
| 303 | fi |
| 304 | SOURCE_DATE_EPOCH="${source_date_epoch}" \ |
| 305 | bash scripts/release/create-release-bundles.sh artifacts bundles |
| 306 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 307 | with: |
| 308 | name: codewhale-bundles |
| 309 | path: | |
| 310 | bundles/*.tar.gz |
| 311 | bundles/*.zip |
| 312 | bundles/codewhale-bundles-sha256.txt |
| 313 | if-no-files-found: error |
| 314 | retention-days: ${{ inputs.retention_days }} |
| 315 | overwrite: true |
| 316 | |
| 317 | windows-installer: |
| 318 | timeout-minutes: 15 |
| 319 | needs: build |
| 320 | if: ${{ !cancelled() && needs.build.result == 'success' }} |
| 321 | runs-on: windows-latest |
| 322 | steps: |
| 323 | - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 |
| 324 | - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 |
| 325 | with: |
| 326 | path: artifacts |
| 327 | pattern: '*windows-x64.exe' |
| 328 | - name: Install NSIS |
| 329 | shell: pwsh |
| 330 | run: choco install nsis -y --no-progress |
| 331 | - name: Build NSIS installer |
| 332 | shell: pwsh |
| 333 | run: | |
| 334 | $ErrorActionPreference = "Stop" |
| 335 | Copy-Item "artifacts\codewhale-windows-x64.exe\codewhale-windows-x64.exe" "scripts\installer\codewhale.exe" |
| 336 | Copy-Item "artifacts\codew-windows-x64.exe\codew-windows-x64.exe" "scripts\installer\codew.exe" |
| 337 | $makensis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe" |
| 338 | if (!(Test-Path $makensis)) { |
| 339 | $makensis = "${env:ProgramFiles}\NSIS\makensis.exe" |
| 340 | } |
| 341 | if (!(Test-Path $makensis)) { |
| 342 | throw "makensis.exe not found after NSIS install" |
| 343 | } |
| 344 | Push-Location scripts\installer |
| 345 | & $makensis "/DVERSION=${{ inputs.version }}" "codewhale.nsi" |
| 346 | Pop-Location |
| 347 | if (!(Test-Path "scripts\installer\CodeWhaleSetup.exe")) { |
| 348 | throw "CodeWhaleSetup.exe was not produced" |
| 349 | } |
| 350 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 351 | with: |
| 352 | name: CodeWhaleSetup.exe |
| 353 | path: scripts/installer/CodeWhaleSetup.exe |
| 354 | if-no-files-found: error |
| 355 | retention-days: ${{ inputs.retention_days }} |
| 356 | overwrite: true |
| 357 | |
| 358 | assemble: |
| 359 | timeout-minutes: 15 |
| 360 | needs: [bundle, windows-installer] |
| 361 | if: ${{ !cancelled() && needs.bundle.result == 'success' && needs.windows-installer.result == 'success' }} |
| 362 | runs-on: ubuntu-latest |
| 363 | steps: |
| 364 | - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 |
| 365 | - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| 366 | with: |
| 367 | node-version: 20 |
| 368 | package-manager-cache: false |
| 369 | - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 |
| 370 | with: |
| 371 | path: intermediate-artifacts |
| 372 | pattern: '*' |
| 373 | - name: Assemble exact authoritative release inventory |
| 374 | run: node scripts/release/assemble-release-assets.js intermediate-artifacts release-assets |
| 375 | - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 376 | with: |
| 377 | name: codewhale-release-assets |
| 378 | path: release-assets/* |
| 379 | if-no-files-found: error |
| 380 | retention-days: ${{ inputs.retention_days }} |
| 381 | compression-level: 0 |
| 382 | overwrite: true |
| 383 | |
| 384 | smoke: |
| 385 | timeout-minutes: 15 |
| 386 | needs: assemble |
| 387 | if: ${{ !cancelled() && needs.assemble.result == 'success' }} |
| 388 | runs-on: ubuntu-latest |
| 389 | steps: |
| 390 | - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 |
| 391 | - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| 392 | with: |
| 393 | node-version: 20 |
| 394 | package-manager-cache: false |
| 395 | - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 |
| 396 | with: |
| 397 | name: codewhale-release-assets |
| 398 | path: release-assets |
| 399 | - name: Verify 34-asset bridge inventory and checksum manifests (single binary) |
| 400 | run: node scripts/release/assemble-release-assets.js --verify release-assets |
| 401 | - name: Test release inventory contracts |
| 402 | run: | |
| 403 | node --test scripts/release/assemble-release-assets.test.js |
| 404 | node --test npm/codewhale/test/artifacts.test.js npm/codewhale/test/release-assets.test.js |
| 405 | - name: Render AUR metadata from candidate Linux archives |
| 406 | run: bash packaging/aur/render.sh release-assets "${RUNNER_TEMP}/codewhale-bin" |
| 407 | - name: Smoke packed npm wrapper against candidate assets |
| 408 | env: |
| 409 | CODEWHALE_SMOKE_ASSETS_DIR: ${{ github.workspace }}/release-assets |
| 410 | run: node scripts/release/npm-wrapper-smoke.js |
| 411 | - name: Record non-public candidate identity |
| 412 | shell: bash |
| 413 | run: | |
| 414 | { |
| 415 | echo "### Release artifact candidate" |
| 416 | echo "" |
| 417 | echo "- Source: \`${{ github.sha }}\`" |
| 418 | echo "- Version metadata: \`${{ inputs.version }}\`" |
| 419 | echo "- Inventory: 7 targets / 34 files (single binary; 7 legacy alias assets)" |
| 420 | echo "- Publication: none (Actions artifact \`codewhale-release-assets\` only)" |
| 421 | } >> "${GITHUB_STEP_SUMMARY}" |
| 422 |