Skip to content

app-release

app-release #2

Workflow file for this run

# Release pipeline for the Rust MarkItDown suite (app/): the standalone CLI
# binary, the MCP server binary, and the Tauri desktop installers — built
# NATIVELY on each OS/arch (no cross-compilation, so what we ship is what the
# runner actually compiled and ran).
#
# Coverage:
# Linux x86_64 (ubuntu-22.04)
# Linux aarch64 (ubuntu-24.04-arm — GitHub-hosted Arm64 runner)
# Windows x86_64 (windows-latest)
# macOS x86_64 Intel (macos-13)
# macOS aarch64 Apple Silicon (macos-14)
#
# Trigger: push a tag like `app-v0.1.0` (kept distinct from the Python
# package's own tags), or run manually via workflow_dispatch (which produces
# downloadable artifacts but does not create a GitHub Release).
#
# GitHub-runner limitations handled (context-aware):
# * Tests run BEFORE building, and a headless smoke test runs AFTER, so a
# platform only publishes artifacts that have been proven to start and
# convert a real fixture on that exact OS/arch.
# * No display server: the GUI is never launched — only built. The desktop
# unit tests cover the command layer.
# * No network / no Python engine needed: all tests are offline; the
# python_fallback suite is cfg(unix) + shell stubs.
# * macOS builds are unsigned (no certs in CI). They run locally after a
# Gatekeeper override; signing/notarization can be added later via secrets.
# * Arm64 Linux AppImage tooling is flaky on CI, so that arch ships a .deb
# only; x86_64 Linux ships both .deb and AppImage.
name: app-release
on:
push:
# Match the common release-tag forms so a normal `vX.Y.Z` works too, not
# only the `app-v*` prefix. IMPORTANT: a tag only triggers a workflow if
# the file exists in the *commit the tag points to* — create the tag on a
# commit that already contains this file (push the workflow first, then tag).
tags:
- "app-v*"
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag/name for the Release published by a manual run (a prerelease is created)."
required: false
default: "app-dev"
concurrency:
group: app-release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write # needed to create/update the GitHub Release
defaults:
run:
shell: bash
jobs:
build:
name: build (${{ matrix.label }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# `compress: true` UPX-packs the standalone CLI/MCP binaries.
# macOS is excluded: UPX's Mach-O / Apple-Silicon support is
# unreliable and packed binaries are killed by Gatekeeper, so
# compressing them would regress "it must run". Linux/Windows pack
# cleanly and we re-run the smoke test on the packed binary.
- os: ubuntu-22.04
label: linux-x86_64
target: x86_64-unknown-linux-gnu
bundles: deb,appimage
compress: true
- os: ubuntu-24.04-arm
label: linux-aarch64
target: aarch64-unknown-linux-gnu
bundles: deb
compress: true
- os: windows-latest
label: windows-x86_64
target: x86_64-pc-windows-msvc
bundles: msi,nsis
compress: true
# macOS x86_64 (Intel artifacts): CROSS-COMPILED on the Apple
# Silicon runner instead of the Intel `macos-13` runner. Intel
# macOS hosted runners are scarce / being phased down and jobs can
# sit queued for hours; macos-14 (arm64) is plentiful. We install
# Rosetta 2 so the x86_64 binary still actually runs in the smoke
# test. dmg/app packaging is arch-agnostic (hdiutil runs natively).
- os: macos-14
label: macos-x86_64
target: x86_64-apple-darwin
bundles: app,dmg
compress: false
rosetta: true
- os: macos-14
label: macos-aarch64
target: aarch64-apple-darwin
bundles: app,dmg
compress: false
steps:
- uses: actions/checkout@v4
- name: Install Tauri system dependencies (Linux only)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
- name: Install Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: |
app
app/desktop/src-tauri
key: ${{ matrix.target }}
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: app/desktop/package-lock.json
# ---- gate: prove the code works on THIS os/arch before shipping ------
- name: Test core / CLI / MCP workspace
working-directory: app
run: cargo test --workspace --locked
- name: Test desktop backend
working-directory: app/desktop/src-tauri
run: cargo test --locked
# ---- build standalone CLI + MCP (release, native target) ------------
- name: Build CLI + MCP (release)
working-directory: app
run: cargo build --release --locked --target ${{ matrix.target }} -p markitdown-cli -p markitdown-mcp
# ---- compress the standalone binaries (UPX) -------------------------
# The release profile already does LTO + strip + opt-level="s". UPX
# --best --lzma roughly halves the on-disk size on top of that. It
# self-extracts into memory at launch (a few ms; fine for a CLI / a
# long-lived MCP server). Skipped on macOS (see matrix note).
- name: Install UPX
if: matrix.compress && runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y upx-ucl
- name: Install UPX (Windows)
if: matrix.compress && runner.os == 'Windows'
run: choco install upx -y
- name: Compress binaries (UPX --best --lzma)
if: matrix.compress
run: |
set -euo pipefail
EXE=""; [[ "$RUNNER_OS" == "Windows" ]] && EXE=".exe"
BIN="app/target/${{ matrix.target }}/release"
for b in markitdown markitdown-mcp; do
before=$(wc -c < "$BIN/$b$EXE")
upx --best --lzma "$BIN/$b$EXE"
after=$(wc -c < "$BIN/$b$EXE")
echo "$b: $before -> $after bytes ($(( 100 - after * 100 / before ))% smaller)"
done
# x86_64 binaries are cross-built on the Apple Silicon runner; Rosetta 2
# lets us actually run them in the smoke test below.
- name: Install Rosetta 2
if: matrix.rosetta
run: softwareupdate --install-rosetta --agree-to-license
# Smoke test runs AFTER compression, so it validates exactly the bytes
# that ship — a packed binary that can't start fails the release here.
- name: Smoke test released binaries
run: |
set -euo pipefail
EXE=""; [[ "$RUNNER_OS" == "Windows" ]] && EXE=".exe"
BIN="app/target/${{ matrix.target }}/release"
# Capture to files before grepping: under `set -o pipefail` a
# `cli | grep -q` pipeline fails when grep closes the pipe early.
"$BIN/markitdown$EXE" --version
"$BIN/markitdown$EXE" --list-formats > formats.txt
grep -qi PDF formats.txt
"$BIN/markitdown$EXE" packages/markitdown/tests/test_files/test.docx > docx.txt
grep -q AutoGen docx.txt
printf '%s\n%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
> mcp_req.txt
"$BIN/markitdown-mcp$EXE" < mcp_req.txt > mcp_resp.txt
grep -q convert_to_markdown mcp_resp.txt
echo "binary smoke OK on ${{ matrix.label }}"
# ---- install frontend deps + build the Tauri desktop installers -----
- name: Install frontend dependencies
working-directory: app/desktop
run: npm ci
- name: Build desktop app (Tauri)
working-directory: app/desktop
# beforeBuildCommand in tauri.conf.json runs `npm run build` (the
# frontend) automatically. Unsigned on macOS (no CI certs).
run: npm run tauri build -- --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
# ---- collect everything into out/ -----------------------------------
- name: Package artifacts
run: |
set -euo pipefail
TRIPLE="${{ matrix.target }}"
LABEL="${{ matrix.label }}"
EXE=""; [[ "$RUNNER_OS" == "Windows" ]] && EXE=".exe"
mkdir -p out staging
cp "app/target/$TRIPLE/release/markitdown$EXE" staging/
cp "app/target/$TRIPLE/release/markitdown-mcp$EXE" staging/
(
cd staging
if [[ "$RUNNER_OS" == "Windows" ]]; then
7z a "../out/markitdown-cli-mcp-$LABEL.zip" "markitdown$EXE" "markitdown-mcp$EXE"
else
tar czf "../out/markitdown-cli-mcp-$LABEL.tar.gz" markitdown markitdown-mcp
fi
)
# Desktop installers (only the types that exist for this platform).
BUNDLE="app/desktop/src-tauri/target/$TRIPLE/release/bundle"
echo "::group::bundle tree for $LABEL"
[[ -d "$BUNDLE" ]] && find "$BUNDLE" | sort || echo "NO BUNDLE DIR at $BUNDLE"
echo "::endgroup::"
installers=0
if [[ -d "$BUNDLE" ]]; then
while IFS= read -r -d '' f; do
cp "$f" out/ && installers=$((installers + 1))
done < <(find "$BUNDLE" -type f \
\( -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' \
-o -name '*.rpm' -o -name '*.msi' -o -name '*.exe' \) -print0)
fi
# macOS: also ship a zipped, ready-to-run .app — guaranteed even if
# the .dmg styling step is flaky on a headless runner.
if [[ "$RUNNER_OS" == "macOS" ]]; then
APP=$(find "$BUNDLE/macos" -maxdepth 1 -name '*.app' -type d 2>/dev/null | head -1 || true)
if [[ -n "$APP" ]]; then
ditto -c -k --keepParent "$APP" "out/MarkItDown-$LABEL.app.zip"
installers=$((installers + 1))
fi
fi
echo "--- packaged for $LABEL ($installers desktop artifact(s)) ---"
ls -la out
# Fail loudly rather than silently shipping binaries with no app.
if [[ "$installers" -eq 0 ]]; then
echo "::error::no desktop installer/app was produced for $LABEL (check the Build desktop app step)"
exit 1
fi
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: markitdown-${{ matrix.label }}
path: out/*
if-no-files-found: error
# Gather every platform's artifacts and publish them as ONE GitHub Release.
# Runs both for `app-v*` tag pushes (normal release) AND for manual
# workflow_dispatch runs (publishes a prerelease named by the `tag` input),
# so the .dmg / installers / binaries are always visible on the Releases
# page — not buried under the Actions run's Artifacts.
release:
name: publish release
needs: build
if: >-
always() && needs.build.result == 'success' &&
(startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
steps:
- name: Download all build artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: List + checksum everything being released
run: |
set -euo pipefail
cd dist
echo "=== assets to publish ==="
ls -la
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Resolve release tag
id: rel
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
- name: Create / update GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.rel.outputs.tag }}
prerelease: ${{ steps.rel.outputs.prerelease }}
files: dist/*
generate_release_notes: true
fail_on_unmatched_files: true
body: |
## MarkItDown (Rust suite) — ${{ steps.rel.outputs.tag }}
Native builds for Linux (x86_64 / aarch64), Windows (x86_64) and
macOS (Intel / Apple Silicon). Each artifact was tested and
smoke-checked on its own OS/arch runner before release.
**Standalone binaries** (no dependencies):
- `markitdown-cli-mcp-linux-x86_64.tar.gz`
- `markitdown-cli-mcp-linux-aarch64.tar.gz`
- `markitdown-cli-mcp-windows-x86_64.zip`
- `markitdown-cli-mcp-macos-x86_64.tar.gz` (Intel)
- `markitdown-cli-mcp-macos-aarch64.tar.gz` (Apple Silicon)
**Desktop app installers:**
- macOS: `*.dmg` + `MarkItDown-macos-*.app.zip` (Intel & Apple Silicon)
- Linux: `*.deb` (x86_64 & aarch64) + `*.AppImage` (x86_64)
- Windows: `*.msi` + `*-setup.exe`
- `SHA256SUMS.txt` — checksums for every asset.
macOS builds are unsigned; right-click → Open (or
`xattr -dr com.apple.quarantine <app>`) on first launch.