Skip to content

v3.6.15

v3.6.15 #763

Workflow file for this run

name: Publish Release
# Action-first html-to-markdown release pipeline.
#
# Orchestrates 11 alef language targets:
# crates · cli · python · node · typescript · wasm · ruby · php · go · c-ffi
# java · csharp · elixir · homebrew
#
# Mirrors kreuzberg's publish.yaml composition: every registry interaction,
# every multi-platform build, and every release-asset upload routes through
# a shared kreuzberg-dev/actions composite. Inline bash is reserved for
# repo-quirky orchestration only (homebrew bottle assembly, Node platform
# package staging, NIF artifact packaging, multi-formula brew flow).
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to build (e.g., v2.6.0)"
required: false
type: string
ref:
description: "Git ref (branch, tag, or commit) to build; defaults to the tag"
required: false
type: string
targets:
description: "Comma-separated list of release targets, or 'all'"
required: false
type: string
default: "all"
dry_run:
description: "Prepare artifacts without publishing"
required: false
type: boolean
default: false
republish:
description: "Delete and re-create the tag on current HEAD before publishing"
required: false
type: boolean
default: false
force_republish:
description: "Force republish targets whose registry version already exists"
required: false
type: boolean
default: false
release:
types: [published]
repository_dispatch:
types: [publish-release]
permissions:
contents: read
id-token: write
concurrency:
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.ref || github.event.inputs.tag)) || github.ref || github.run_id }}
cancel-in-progress: false
jobs:
prepare:
name: Prepare metadata
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tag }}
version: ${{ steps.meta.outputs.version }}
ref: ${{ steps.meta.outputs.ref }}
checkout_ref: ${{ steps.meta.outputs.checkout_ref }}
target_sha: ${{ steps.meta.outputs.target_sha }}
matrix_ref: ${{ steps.meta.outputs.matrix_ref }}
is_tag: ${{ steps.meta.outputs.is_tag }}
is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
npm_tag: ${{ steps.meta.outputs.npm_tag }}
dry_run: ${{ steps.meta.outputs.dry_run }}
force_republish: ${{ steps.meta.outputs.force_republish }}
release_targets: ${{ steps.meta.outputs.release_targets }}
release_any: ${{ steps.meta.outputs.release_any }}
release_python: ${{ steps.meta.outputs.release_python }}
release_node: ${{ steps.meta.outputs.release_node }}
release_ruby: ${{ steps.meta.outputs.release_ruby }}
release_cli: ${{ steps.meta.outputs.release_cli }}
release_crates: ${{ steps.meta.outputs.release_crates }}
release_homebrew: ${{ steps.meta.outputs.release_homebrew }}
release_java: ${{ steps.meta.outputs.release_java }}
release_csharp: ${{ steps.meta.outputs.release_csharp }}
release_go: ${{ steps.meta.outputs.release_go }}
release_wasm: ${{ steps.meta.outputs.release_wasm }}
release_php: ${{ steps.meta.outputs.release_php }}
release_elixir: ${{ steps.meta.outputs.release_elixir }}
release_c_ffi: ${{ steps.meta.outputs.release_c_ffi }}
release_kotlin: ${{ steps.meta.outputs.release_kotlin }}
release_swift: ${{ steps.meta.outputs.release_swift }}
release_dart: ${{ steps.meta.outputs.release_dart }}
release_zig: ${{ steps.meta.outputs.release_zig }}
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ (inputs.republish == true && (inputs.ref || github.event.repository.default_branch)) || inputs.ref || inputs.tag || github.ref }}
fetch-depth: 0
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
- name: Configure git identity
run: |
git config user.name "kreuzberg-dev-publisher[bot]"
git config user.email "291994444+kreuzberg-dev-publisher[bot]@users.noreply.github.com"
- name: Retag for republish
if: ${{ inputs.republish == true || github.event.client_payload.republish == true }}
uses: kreuzberg-dev/actions/retag-for-republish@v1
with:
tag: ${{ inputs.tag || github.event.client_payload.tag }}
token: ${{ steps.app-token.outputs.token }}
- name: Resolve release metadata
id: meta
uses: kreuzberg-dev/actions/prepare-release-metadata@v1
- name: Ensure GitHub Release exists for tag
if: ${{ steps.meta.outputs.is_tag == 'true' && steps.meta.outputs.dry_run != 'true' }}
uses: kreuzberg-dev/actions/publish-github-release@v1
with:
tag: ${{ steps.meta.outputs.tag }}
target: ${{ steps.meta.outputs.target_sha }}
notes: "Release ${{ steps.meta.outputs.tag }}"
draft: "true"
prerelease: ${{ steps.meta.outputs.is_prerelease }}
token: ${{ steps.app-token.outputs.token }}
validate-versions:
name: Validate language manifest versions
needs: prepare
if: ${{ needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- name: Validate versions
uses: kreuzberg-dev/actions/validate-versions@v1
with:
version: ${{ needs.prepare.outputs.version }}
# ─── Registry existence checks ────────────────────────────────────────
check-pypi:
name: Check PyPI for existing version
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_python == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: pypi
package: html-to-markdown
version: ${{ needs.prepare.outputs.version }}
check-npm:
name: Check npm for existing versions
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_node == 'true' }}
runs-on: ubuntu-latest
outputs:
node_exists: ${{ steps.check.outputs.exists }}
ts_exists: ${{ steps.check.outputs.ts_exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: npm
package: "@kreuzberg/html-to-markdown-node"
version: ${{ needs.prepare.outputs.version }}
extra-packages: |
ts_exists=@kreuzberg/html-to-markdown
check-wasm:
name: Check npm for existing WASM package
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_wasm == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: npm
package: "@kreuzberg/html-to-markdown-wasm"
version: ${{ needs.prepare.outputs.version }}
check-rubygems:
name: Check RubyGems for existing version
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_ruby == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: rubygems
package: html-to-markdown
version: ${{ needs.prepare.outputs.version }}
check-maven:
name: Check Maven Central for existing version
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_java == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: maven
package: "dev.kreuzberg:html-to-markdown"
version: ${{ needs.prepare.outputs.version }}
check-nuget:
name: Check NuGet for existing version
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_csharp == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: nuget
package: KreuzbergDev.HtmlToMarkdown
version: ${{ needs.prepare.outputs.version }}
check-packagist:
name: Check Packagist for existing version
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_php == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: packagist
package: kreuzberg-dev/html-to-markdown
version: ${{ needs.prepare.outputs.version }}
check-cratesio:
name: Check crates.io for existing versions
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_crates == 'true' }}
runs-on: ubuntu-latest
outputs:
rs_exists: ${{ steps.check.outputs.exists }}
cli_exists: ${{ steps.check.outputs.cli_exists }}
all_exist: ${{ steps.derive.outputs.all_exist }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: cratesio
package: html-to-markdown-rs
version: ${{ needs.prepare.outputs.version }}
extra-packages: |
cli_exists=html-to-markdown-cli
- id: derive
# Inline: aggregate two check-registry outputs into a single boolean
# the publish-crates job can guard on. No shared action equivalent.
run: |
if [[ "${{ steps.check.outputs.exists }}" == "true" && "${{ steps.check.outputs.cli_exists }}" == "true" ]]; then
echo "all_exist=true" >> "$GITHUB_OUTPUT"
else
echo "all_exist=false" >> "$GITHUB_OUTPUT"
fi
check-hex:
name: Check Hex.pm for existing version
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_elixir == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: hex
package: html_to_markdown
version: ${{ needs.prepare.outputs.version }}
check-homebrew:
name: Check Homebrew tap for formula
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_homebrew == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: homebrew
package: html-to-markdown
version: ${{ needs.prepare.outputs.version }}
tap-repo: kreuzberg-dev/homebrew-tap
check-cli:
name: Check GitHub Release for CLI assets
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_cli == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: github-release
package: html-to-markdown
version: ${{ needs.prepare.outputs.version }}
repo: ${{ github.repository }}
tag: ${{ needs.prepare.outputs.tag }}
asset-prefix: cli-
check-go:
name: Check GitHub Release for Go FFI assets
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_go == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: github-release
package: html-to-markdown
version: ${{ needs.prepare.outputs.version }}
repo: ${{ github.repository }}
tag: ${{ needs.prepare.outputs.tag }}
asset-prefix: html-to-markdown-rs-go-
check-c-ffi:
name: Check GitHub Release for C FFI assets
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_c_ffi == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: github-release
package: html-to-markdown
version: ${{ needs.prepare.outputs.version }}
repo: ${{ github.repository }}
tag: ${{ needs.prepare.outputs.tag }}
asset-prefix: html-to-markdown-rs-ffi-
check-elixir-release:
name: Check GitHub Release for Elixir NIF assets
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_elixir == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: github-release
package: html-to-markdown
version: ${{ needs.prepare.outputs.version }}
repo: ${{ github.repository }}
tag: ${{ needs.prepare.outputs.tag }}
asset-prefix: libhtml_to_markdown_nif-
check-maven-kotlin:
name: Check Maven Central for Kotlin Android
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_kotlin == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: maven
package: "dev.kreuzberg:html-to-markdown-kotlin"
version: ${{ needs.prepare.outputs.version }}
check-pub:
name: Check pub.dev for Dart package
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_dart == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: pub
package: html_to_markdown
version: ${{ needs.prepare.outputs.version }}
assemble-dart-package:
name: Assemble Dart package artifact
needs: [prepare, check-pub]
if: ${{ needs.prepare.outputs.release_dart == 'true' && (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && (needs.check-pub.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
- uses: actions/upload-artifact@v7
with:
name: dart-package-assembled
path: packages/dart
if-no-files-found: error
retention-days: 7
# Swift Package Index has no central registry — packages are consumed
# directly from git tags, so the tag push IS the publish event. The
# previous `check-spi-swift` + `publish-swift` jobs only pinged SPI for
# fast re-indexing (SPI auto-discovers within ~1h without a ping), so
# they have been dropped to cut runner cost. The `release_swift` output
# from `prepare` is still emitted but no longer consumed.
#
# Zig is similarly tag-based, but the publish-zig job is retained because
# it appends the tarball URL + SHA-256 to the release notes so consumers
# can copy-paste straight into `build.zig.zon`.
assemble-zig-package:
name: Assemble Zig package with prebuilt FFI libraries
needs: [prepare, check-c-ffi, c-ffi-libraries]
if: ${{ needs.prepare.outputs.release_zig == 'true' && (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
fetch-depth: 0
submodules: recursive
- uses: actions/download-artifact@v8
with:
pattern: c-ffi-*
path: c-ffi-artifacts
merge-multiple: true
- name: Stage prebuilt FFI libraries into Zig package
# Inline: alef zig codegen produces packages/zig/ with include/ (C headers
# from the zig backend). This step populates lib/<rid>/ with the prebuilt
# C FFI shared libraries for each platform so the tarball is complete.
# Platform naming follows alef's Go platform convention: linux-x64,
# linux-arm64, darwin-x64, darwin-arm64, windows-x64, windows-arm64.
shell: bash
run: |
mkdir -p packages/zig/lib
# Map alef's platform names to the directories produced by c-ffi-libraries job.
# c-ffi-libraries matrix produces: c-ffi-{platform}/ where platform is
# linux-x64, linux-arm64, darwin-x64, darwin-arm64, windows-x64, windows-arm64.
declare -A platform_map=(
[linux-x64]="linux-x64"
[linux-arm64]="linux-arm64"
[darwin-x64]="darwin-x64"
[darwin-arm64]="darwin-arm64"
[windows-x64]="windows-x64"
[windows-arm64]="windows-arm64"
)
for alef_rid in "${!platform_map[@]}"; do
artifact_platform="${platform_map[$alef_rid]}"
src_dir="c-ffi-artifacts/${artifact_platform}"
# c-ffi-libraries produces a flat distribution directory per platform
# containing the FFI shared library (.so/.dylib/.dll). Find and copy.
if [[ -d "$src_dir" ]]; then
mkdir -p "packages/zig/lib/${alef_rid}"
# Copy all shared library artifacts (.so, .dylib, .dll) from the distribution
find "$src_dir" -maxdepth 2 -type f \( -name '*.so' -o -name '*.dylib' -o -name '*.dll' \) \
-exec cp {} "packages/zig/lib/${alef_rid}/" \;
echo "Staged FFI libraries for $alef_rid:"
ls -la "packages/zig/lib/${alef_rid}/"
else
echo "Warning: no C FFI artifact found for $alef_rid at $src_dir"
fi
done
- name: Create Zig package tarball
# Inline: archive packages/zig with the populated lib/ subdirectories.
# alef zig backend has already generated the source code, build files,
# and C headers. This step tarballs the complete package.
shell: bash
run: |
cd packages/zig
tar -czf "../../html-to-markdown-rs-zig-v${{ needs.prepare.outputs.version }}.tar.gz" .
- uses: actions/upload-artifact@v7
with:
name: zig-package-assembled
path: html-to-markdown-rs-zig-v${{ needs.prepare.outputs.version }}.tar.gz
if-no-files-found: error
retention-days: 7
check-zig:
name: Check Zig package presence
needs: prepare
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_zig == 'true' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- id: check
# `registry: github-release` (not `registry: zig`) — alef check-registry's
# zig path only verifies the release tag exists. On a real release that's
# always true, so it short-circuits `publish-zig` to skipped (regression:
# v3.5.1 shipped without the zig tarball asset because of this). Verify
# the actual asset is present instead.
uses: kreuzberg-dev/actions/check-registry@v1
with:
registry: github-release
package: html-to-markdown-rs-zig-v${{ needs.prepare.outputs.version }}.tar.gz
version: ${{ needs.prepare.outputs.version }}
repo: ${{ github.repository }}
assets: html-to-markdown-rs-zig-v${{ needs.prepare.outputs.version }}.tar.gz
# ─── Build matrix jobs ────────────────────────────────────────────────
python-wheels:
name: Build Python wheels (${{ matrix.os }})
needs: [prepare, check-pypi]
if: ${{ needs.prepare.outputs.release_python == 'true' && (needs.check-pypi.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 180
env:
# Required by cibuildwheel/delocate: the Rust-compiled .so/.dylib targets
# macOS 10.12+ (default in modern toolchains). 10.9 was the legacy default
# which causes delocate to refuse to relocate the wheel.
MACOSX_DEPLOYMENT_TARGET: "10.12"
strategy:
fail-fast: false
matrix:
# windows-11-arm: cibuildwheel does not yet support aarch64-pc-windows-msvc target
os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-latest, macos-15-intel]
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- name: Free disk space
if: runner.os == 'Linux'
uses: kreuzberg-dev/actions/free-disk-space-linux@v1
- name: Setup Rust on host (macOS, Windows)
# cibuildwheel's before-build runs `cargo build` which auto-installs
# the toolchain pinned by rust-toolchain.toml via rustup. On macOS-arm64
# this collides with the runner's pre-installed clippy/rustfmt and
# rustup aborts with "detected conflict: bin/cargo-clippy". Pre-install
# via actions-rust-lang/setup-rust-toolchain so the toolchain is ready
# by the time cibuildwheel runs cargo. No-op for Linux (cibw runs in
# a manylinux container — host rust is invisible there).
if: runner.os != 'Linux'
uses: kreuzberg-dev/actions/setup-rust@v1
with:
use-sccache: "false"
- name: Build wheels
uses: kreuzberg-dev/actions/build-python-wheels@v1
with:
python-version: "3.13"
package-dir: packages/python
# abi3-py310 is enabled in pyo3 features → one wheel per platform covers 3.10+.
# Without this override, cibuildwheel rebuilds for cp310/cp311/cp312/cp313/cp314 = 5x duplicates.
cibw-build: "cp310-*"
cibw-before-build-linux: >
yum install -y openssl-devel &&
(test -x /usr/bin/aarch64-linux-gnu-gcc ||
ln -sf "$(command -v gcc)" /usr/local/bin/aarch64-linux-gnu-gcc 2>/dev/null || true) &&
pip install maturin uv &&
source ~/.cargo/env &&
python scripts/prepare_wheel.py
cibw-before-build-macos: >
pip install maturin uv &&
source ~/.cargo/env &&
python scripts/prepare_wheel.py
cibw-before-build-windows: >
pip install maturin uv &&
set PATH=%USERPROFILE%\.cargo\bin;%PATH% &&
python scripts\prepare_wheel.py
upload-artifact: "false"
- uses: actions/upload-artifact@v7
with:
name: python-wheels-${{ matrix.os }}
path: wheelhouse/*.whl
retention-days: 14
python-sdist:
name: Build Python sdist
needs: [prepare, check-pypi, publish-crates]
if: ${{ !cancelled() && needs.publish-crates.result != 'failure' && needs.prepare.outputs.release_python == 'true' && (needs.check-pypi.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- uses: kreuzberg-dev/actions/setup-rust@v1
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.13"
- name: Build and bundle CLI binary
# The CLI binary is bundled into the sdist so it can be installed via pipx.
shell: bash
run: |
cargo build --release --locked --package html-to-markdown-cli
mkdir -p packages/python/html_to_markdown/_cli
cp target/release/html-to-markdown packages/python/html_to_markdown/_cli/ 2>/dev/null \
|| cp target/release/html-to-markdown.exe packages/python/html_to_markdown/_cli/
- uses: kreuzberg-dev/actions/build-python-sdist@v1
with:
package-dir: packages/python
output-dir: dist
- uses: actions/upload-artifact@v7
with:
name: python-sdist
path: dist/*.tar.gz
retention-days: 14
node-typescript-defs:
name: Generate Node TypeScript definitions
needs: [prepare, check-npm]
if: ${{ needs.prepare.outputs.release_node == 'true' && (needs.check-npm.outputs.node_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- uses: kreuzberg-dev/actions/setup-rust@v1
- uses: kreuzberg-dev/actions/setup-node-workspace@v1
with:
node-version: "24"
- name: Generate TypeScript definitions
# Inline: napi build produces index.js + a fresh index.d.ts as a side
# effect. We keep napi's index.js (its platform-dispatch stub loader)
# but restore alef's committed index.d.ts: alef adds *Update DTO types
# (e.g. ConversionOptionsUpdate, PreprocessingOptionsUpdate) that the
# napi macros deliberately filter out — those types are re-exported by
# packages/typescript/src/index.ts, so dropping them breaks tsc with
# TS2724 at the wrapper publish step.
shell: bash
run: |
pnpm --filter ./crates/html-to-markdown-node exec napi build --release
git checkout HEAD -- crates/html-to-markdown-node/index.d.ts
mkdir -p typescript-defs
cp crates/html-to-markdown-node/index.js crates/html-to-markdown-node/index.d.ts typescript-defs/
- uses: actions/upload-artifact@v7
with:
name: node-typescript-defs
path: typescript-defs/
retention-days: 14
node-bindings:
name: Build Node bindings (${{ matrix.target }})
needs: [prepare, check-npm]
if: ${{ needs.prepare.outputs.release_node == 'true' && (needs.check-npm.outputs.node_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- { os: macos-latest, target: aarch64-apple-darwin, rust_target: "", use_napi_cross: false }
- { os: macos-15-intel, target: x86_64-apple-darwin, rust_target: "", use_napi_cross: false }
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, rust_target: "", use_napi_cross: false }
- {
os: ubuntu-latest,
target: aarch64-unknown-linux-gnu,
rust_target: aarch64-unknown-linux-gnu,
use_napi_cross: true,
}
# musl + armv7 deferred — napi-cross artifact path mismatch ("No dist dir found") blocks every cross-compiled binding.
- { os: windows-latest, target: x86_64-pc-windows-msvc, rust_target: "", use_napi_cross: false }
- {
os: windows-latest,
target: aarch64-pc-windows-msvc,
rust_target: aarch64-pc-windows-msvc,
use_napi_cross: false,
}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- name: Free disk space
if: runner.os == 'Linux'
uses: kreuzberg-dev/actions/free-disk-space-linux@v1
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
target: ${{ matrix.rust_target || matrix.target }}
cache-key-prefix: publish-node-${{ matrix.target }}
- uses: kreuzberg-dev/actions/setup-node-workspace@v1
with:
node-version: "24"
- name: Build native module
uses: kreuzberg-dev/actions/build-node-napi@v1
with:
crate-dir: crates/html-to-markdown-node
target: ${{ matrix.target }}
use-napi-cross: ${{ matrix.use_napi_cross }}
pack-target-tarball: node-bindings-${{ matrix.target }}.tar.gz
- uses: actions/upload-artifact@v7
with:
name: node-bindings-${{ matrix.target }}
path: node-bindings-${{ matrix.target }}.tar.gz
retention-days: 14
wasm-bindings:
name: Build WASM package
needs: [prepare, check-wasm]
if: ${{ needs.prepare.outputs.release_wasm == 'true' && (needs.check-wasm.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
target: wasm32-unknown-unknown
cache-key-prefix: publish-wasm
- uses: kreuzberg-dev/actions/setup-node-workspace@v1
with:
node-version: "24"
- uses: kreuzberg-dev/actions/build-wasm-package@v1
with:
crate-dir: crates/html-to-markdown-wasm
pack-tarball: "false"
- uses: actions/upload-artifact@v7
with:
name: wasm-bundle
path: |
crates/html-to-markdown-wasm/dist/**
crates/html-to-markdown-wasm/dist-node/**
crates/html-to-markdown-wasm/dist-web/**
crates/html-to-markdown-wasm/pkg/**
crates/html-to-markdown-wasm/package.json
retention-days: 14
ruby-gem:
name: Build Ruby gem (${{ matrix.label }})
needs: [prepare, check-rubygems, publish-crates]
if: ${{ !cancelled() && needs.publish-crates.result != 'failure' && needs.prepare.outputs.release_ruby == 'true' && (needs.check-rubygems.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 180
env:
RB_SYS_CARGO_PROFILE: release
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, label: linux }
- { os: ubuntu-24.04-arm, label: linux-aarch64 }
- { os: macos-latest, label: macos-arm64 }
- { os: macos-15-intel, label: macos-x86_64 }
- { os: ubuntu-latest, label: windows-x64, dock_platform: x64-mingw-ucrt }
# windows-11-arm: deferred until rb-sys + Magnus toolchain validated on aarch64-pc-windows-msvc
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- name: Free disk space
if: runner.os == 'Linux'
uses: kreuzberg-dev/actions/free-disk-space-linux@v1
- name: Drop cached CLI binaries
# Inline: stale CLI artifacts under packages/ruby/exe/ confuse rake
# build's filtering. One-shot cleanup, no shared action equivalent.
shell: bash
run: rm -rf packages/ruby/exe/* packages/ruby/pkg
- name: Install MSYS2 toolchain (Windows)
if: runner.os == 'Windows'
# Inline: rb-sys requires the MSYS2 toolchain on Windows for clang +
# libffi headers. Magnus also needs ucrt64-prefixed binaries on PATH.
shell: pwsh
run: |
ridk install 1 2 3
$msys2Path = (ridk version).Split("`n") | Where-Object { $_ -match "msys2:" } | ForEach-Object { ($_ -split ":\s*")[1].Trim() }
if (-not $msys2Path) { $msys2Path = "C:\msys64" }
ridk exec pacman -S --noconfirm --needed mingw-w64-ucrt-x86_64-clang mingw-w64-ucrt-x86_64-libffi
"$msys2Path\ucrt64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Install Rust (GNU on Windows)
if: runner.os == 'Windows'
# Inline: rb-sys/Magnus only build against the GNU ABI on Windows; the
# default MSVC toolchain produces incompatible artifacts.
shell: pwsh
run: |
rustup install stable-x86_64-pc-windows-gnu
rustup default stable-x86_64-pc-windows-gnu
rustup target add x86_64-pc-windows-gnu
rustc --version
- name: Configure bindgen sysroot (Windows)
if: runner.os == 'Windows'
# Inline: bindgen needs an explicit libclang sysroot pointing at the
# MSYS2 ucrt64 install — discovery fails on the GitHub runner image.
shell: bash
run: |
UCRT="/c/msys64/ucrt64"
echo "BINDGEN_EXTRA_CLANG_ARGS=--sysroot=${UCRT} -I${UCRT}/include" >>"$GITHUB_ENV"
echo "LIBCLANG_PATH=${UCRT}/bin" >>"$GITHUB_ENV"
- uses: kreuzberg-dev/actions/setup-rust@v1
if: runner.os != 'Windows'
with:
cache-key-prefix: publish-ruby-${{ matrix.label }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler: "4.0.3"
bundler-cache: false
- name: Install Ruby dependencies
# Inline: bundle config + install in the package dir; trivially small
# and identical across kreuzcrawl/kreuzberg/html-to-markdown.
shell: bash
working-directory: packages/ruby
run: |
gem install bundler -v 4.0.3
bundle config set --local path 'vendor/bundle'
bundle install --jobs 4 --retry 3
- uses: kreuzberg-dev/actions/build-ruby-gem@v1
if: matrix.dock_platform == ''
with:
package-dir: packages/ruby
- name: Build platform gem
# Inline: each matrix runner builds the gem for its own native platform
# via `rake compile && rake build`. rake-compiler's compile step sets
# the gem's native platform tag from rb_config, so the produced gem is
# already platform-tagged (e.g. kreuzberg-X.Y.Z-x86_64-linux.gem). This
# mirrors the kreuzberg sibling's proven Ruby publish path. For Windows
# we cross-compile inside rb-sys-dock so the GNU toolchain produces a
# x64-mingw-ucrt gem from the linux runner.
shell: bash
run: |
if [ -n "${{ matrix.dock_platform }}" ]; then
# Cross-compile for Windows via Docker using rb-sys-dock.
# The rb-sys-dock container ships its own Ruby toolchain; bundler
# must `install` inside the container before `exec rake` so the
# lockfile materializes against the container's Ruby version
# rather than the host's.
# Pin gem below 0.9.128 to get the 0.9.127 container, then exclude
# Ruby 4.0.2 via RUBY_CC_VERSION — both 0.9.127 and 0.9.128 ship
# Ruby 4.0.2 whose <ruby/defines.h> pulls <sys/select.h>, which
# clang cannot resolve in the mingw cross sysroot. 0.9.127's
# container also ships 3.1.7/3.2.11/3.3.11/3.4.9; pin to those.
#
# Run rb-sys-dock from the WORKSPACE ROOT so the container mounts
# the whole repo — the gem's Cargo.toml has a path-dep
# `html-to-markdown-rs = { path = "../../../../../crates/html-to-markdown" }`
# which resolves outside `packages/ruby/` and would not exist in
# a container mounted at the gem dir. Use `--directory packages/ruby`
# so the container's working dir is still the gem root.
gem install rb_sys -v '< 0.9.128'
# Use `env VAR=val` to plant RUBY_CC_VERSION into the *command's* process
# environment, surviving the container's /etc/profile.d default that
# otherwise re-introduces Ruby 4.0.2 after `export`.
# Wrap the in-container command in `set -euo pipefail` and
# `::group::` markers so an individual step failure surfaces
# its exit code on the runner log instead of disappearing into
# the joined `&&` chain (rb-sys-dock chronic windows-x64
# failures have been opaque since rc.19 because everything ran
# inside one `bash -c "step1 && step2 && …"`).
rb-sys-dock --directory packages/ruby --platform "${{ matrix.dock_platform }}" -- env RUBY_CC_VERSION=3.4.9:3.3.11:3.2.11:3.1.7 bash -c '
set -euo pipefail
echo "::group::rustup toolchain install"
rustup toolchain install stable --no-self-update --profile minimal
echo "::endgroup::"
echo "::group::rustup default + target add"
rustup default stable
rustup target add x86_64-pc-windows-gnu
echo "::endgroup::"
echo "::group::bundle install"
bundle install --jobs=4 --retry=3
echo "::endgroup::"
echo "::group::rake compile"
bundle exec rake compile
echo "::endgroup::"
echo "::group::rake build"
bundle exec rake build
echo "::endgroup::"
'
else
# Native build on matching architecture
cd packages/ruby
bundle exec rake compile
bundle exec rake build
fi
- name: Drop source gem on non-canonical builders
if: ${{ matrix.label != 'linux' }}
# Inline: only the canonical `linux` builder ships the source gem; others
# would emit byte-different source gems that overwrite each other under
# merge-multiple and produce an invalid .gem at publish time.
shell: bash
run: |
shopt -s nullglob
for f in packages/ruby/pkg/*.gem; do
base="$(basename "$f")"
case "$base" in
*-x86_64-linux.gem|*-aarch64-linux.gem|*-arm64-darwin.gem|*-x86_64-darwin.gem|*-x64-mingw32.gem|*-x64-mingw-ucrt.gem|*-arm64-mingw-ucrt.gem) ;;
*) echo "Removing non-canonical source gem $base"; rm -f "$f" ;;
esac
done
- uses: actions/upload-artifact@v7
with:
name: rubygems-${{ matrix.label }}
path: packages/ruby/pkg/*.gem
retention-days: 14
php-extension:
name: Build PHP extension (php${{ matrix.php }} ${{ matrix.platform.label }})
needs: [prepare, check-packagist, publish-crates]
if: ${{ !cancelled() && needs.publish-crates.result != 'failure' && needs.prepare.outputs.release_php == 'true' && (needs.check-packagist.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ${{ matrix.platform.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
php: ["8.2", "8.3", "8.4", "8.5"]
platform:
- { os: ubuntu-latest, label: linux-x86_64, target: x86_64-unknown-linux-gnu }
- { os: ubuntu-24.04-arm, label: linux-arm64, target: aarch64-unknown-linux-gnu }
- { os: macos-latest, label: macos-arm64, target: aarch64-apple-darwin }
- { os: macos-15-intel, label: macos-x86_64, target: x86_64-apple-darwin }
- { os: windows-latest, label: windows-x86_64, target: x86_64-pc-windows-msvc }
# windows-arm64: deferred until ext-php-rs cross-compile to aarch64-pc-windows-msvc is validated
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- uses: kreuzberg-dev/actions/setup-php@v1
with:
php-version: ${{ matrix.php }}
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
cache-key-prefix: publish-php-${{ matrix.platform.label }}-php${{ matrix.php }}
- uses: kreuzberg-dev/actions/install-alef@v1
- uses: kreuzberg-dev/actions/build-php-extension@v1
with:
crate-name: html-to-markdown-php
lib-name: html_to_markdown_php
php-version: ${{ matrix.php }}
php-ts: nts
- name: Determine Windows compiler
if: runner.os == 'Windows'
id: wincompiler
shell: pwsh
run: |
$compiler = switch ('${{ matrix.php }}') {
'8.2' { 'vs16' }
'8.3' { 'vs16' }
'8.4' { 'vs17' }
'8.5' { 'vs17' }
default { 'vs17' }
}
"compiler=$compiler" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
- uses: kreuzberg-dev/actions/package-php-pie@v1
with:
php-version: ${{ matrix.php }}
php-ts: nts
target: ${{ matrix.platform.target }}
windows-compiler: ${{ steps.wincompiler.outputs.compiler }}
version: ${{ needs.prepare.outputs.version }}
output-dir: dist/php-package
- uses: actions/upload-artifact@v7
with:
name: php-package-${{ matrix.platform.label }}-php${{ matrix.php }}
path: |
dist/php-package/php_*.tgz
dist/php-package/php_*.tgz.sha256
dist/php-package/php_*.zip
dist/php-package/php_*.zip.sha256
retention-days: 14
cli-binaries:
name: Build CLI binary (${{ matrix.target }})
needs: [prepare, check-cli]
if: ${{ needs.prepare.outputs.release_cli == 'true' && (needs.check-cli.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
- { os: ubuntu-latest, target: x86_64-unknown-linux-musl }
- { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
- { os: macos-latest, target: aarch64-apple-darwin }
- { os: macos-15-intel, target: x86_64-apple-darwin }
- { os: windows-latest, target: x86_64-pc-windows-msvc }
- { os: windows-11-arm, target: aarch64-pc-windows-msvc }
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- name: Free disk space
if: runner.os == 'Linux'
uses: kreuzberg-dev/actions/free-disk-space-linux@v1
- name: Install musl tools
if: ${{ matrix.target == 'x86_64-unknown-linux-musl' }}
run: sudo apt-get update && sudo apt-get install -y musl-tools
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
target: ${{ matrix.target }}
cache-key-prefix: publish-cli-${{ matrix.target }}
- uses: kreuzberg-dev/actions/build-rust-cli@v1
id: build
with:
package-name: html-to-markdown-cli
binary-name: html-to-markdown
target: ${{ matrix.target }}
- name: Package CLI artifact
# Inline: archive layout (.tar.gz on Unix, .zip on Windows) varies per
# OS in a way no shared action covers; tiny and self-contained.
env:
BINARY_PATH: ${{ steps.build.outputs.binary-path }}
TARGET: ${{ matrix.target }}
shell: bash
run: |
STAGE="cli-${TARGET}"
mkdir -p "$STAGE"
cp LICENSE "$STAGE/" 2>/dev/null || true
cp README.md "$STAGE/" 2>/dev/null || true
if [[ "$RUNNER_OS" == "Windows" ]]; then
cp "$BINARY_PATH" "$STAGE/html-to-markdown.exe"
7z a "${STAGE}.zip" "$STAGE" >/dev/null
else
cp "$BINARY_PATH" "$STAGE/html-to-markdown"
tar -czf "${STAGE}.tar.gz" "$STAGE"
fi
- uses: actions/upload-artifact@v7
with:
name: cli-${{ matrix.target }}
path: |
cli-${{ matrix.target }}.tar.gz
cli-${{ matrix.target }}.zip
if-no-files-found: ignore
retention-days: 14
go-ffi-libraries:
name: Build Go FFI library (${{ matrix.platform }})
needs: [prepare, check-go]
if: ${{ needs.prepare.outputs.release_go == 'true' && (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && (needs.check-go.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, platform: linux-x64, target: x86_64-unknown-linux-gnu }
- { os: ubuntu-24.04-arm, platform: linux-arm64, target: aarch64-unknown-linux-gnu }
- { os: macos-latest, platform: darwin-arm64, target: aarch64-apple-darwin }
- { os: macos-15-intel, platform: darwin-x64, target: x86_64-apple-darwin }
- { os: windows-latest, platform: windows-x64, target: x86_64-pc-windows-msvc }
- { os: windows-11-arm, platform: windows-arm64, target: aarch64-pc-windows-msvc }
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- name: Free disk space
if: runner.os == 'Linux'
uses: kreuzberg-dev/actions/free-disk-space-linux@v1
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
target: ${{ matrix.target }}
cache-key-prefix: publish-go-${{ matrix.platform }}
- uses: kreuzberg-dev/actions/install-alef@v1
- name: Build and package Go FFI library
# Inline: html-to-markdown's FFI build is driven by `alef publish` rather
# than the generic build-go-ffi action — the alef driver knows about
# workspace features, header generation, and the platform-specific
# cgo include paths the package layout expects.
shell: bash
run: |
alef publish build --lang ffi --target ${{ matrix.target }}
alef publish package --lang go --target ${{ matrix.target }} -o dist/go-ffi
# Workaround: alef 0.19.6 packages Go FFI archives with the same
# `-ffi-` infix as the C FFI packager, which collides with the
# `html-to-markdown-rs-ffi-*` asset-prefix probe used by check-go and
# the verify-release-assets pattern list. Rename `-ffi-v` → `-go-v` so
# downstream gating can distinguish Go from C FFI tarballs. Drop this
# block once h2m's alef.toml moves to a release that ships the
# alef-side fix (alef/src/publish/package/go.rs already uses `-go-`
# on main).
shopt -s nullglob
for archive in dist/go-ffi/*-ffi-v*.tar.gz; do
renamed="${archive/-ffi-v/-go-v}"
mv "$archive" "$renamed"
done
shopt -u nullglob
- uses: actions/upload-artifact@v7
with:
name: go-ffi-${{ matrix.platform }}
path: dist/go-ffi
retention-days: 14
c-ffi-libraries:
name: Build C FFI distribution (${{ matrix.platform }})
needs: [prepare, check-c-ffi]
if: ${{ needs.prepare.outputs.release_c_ffi == 'true' && (needs.check-c-ffi.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, platform: linux-x64, target: x86_64-unknown-linux-gnu }
- { os: ubuntu-24.04-arm, platform: linux-arm64, target: aarch64-unknown-linux-gnu }
- { os: macos-latest, platform: darwin-arm64, target: aarch64-apple-darwin }
- { os: macos-15-intel, platform: darwin-x64, target: x86_64-apple-darwin }
- { os: windows-latest, platform: windows-x64, target: x86_64-pc-windows-msvc }
- { os: windows-11-arm, platform: windows-arm64, target: aarch64-pc-windows-msvc }
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- name: Free disk space
if: runner.os == 'Linux'
uses: kreuzberg-dev/actions/free-disk-space-linux@v1
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
target: ${{ matrix.target }}
cache-key-prefix: publish-c-ffi-${{ matrix.platform }}
- uses: kreuzberg-dev/actions/install-alef@v1
- name: Build and package C FFI distribution
# Inline: alef-driven FFI packaging emits the pkgconfig + cmake metadata
# the homebrew formula expects. The generic build-rust-ffi action
# produces only the .so/.dylib/.dll without that scaffolding.
shell: bash
run: |
alef publish build --lang ffi --target ${{ matrix.target }}
alef publish package --lang ffi --target ${{ matrix.target }} -o dist/c-ffi
- uses: actions/upload-artifact@v7
with:
name: c-ffi-${{ matrix.platform }}
path: dist/c-ffi
retention-days: 14
swift-artifactbundle:
name: Build Swift Artifact Bundle
needs: [prepare]
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_swift == 'true' }}
runs-on: macos-latest
timeout-minutes: 180
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
cache-key-prefix: publish-swift-artifactbundle
- uses: kreuzberg-dev/actions/build-swift-artifactbundle@v1
id: build
with:
crate-name: html-to-markdown-rs-swift
artifact-name: HtmlToMarkdown-rs
header-path: target/release/build/html-to-markdown-rs-swift-*/out
output-dir: dist/swift-artifactbundle
build-profile: release
- name: Record checksum
id: checksum
run: |
CHECKSUM=$(cat "${{ steps.build.outputs.bundle-zip }}.sha256" 2>/dev/null || swift package compute-checksum "${{ steps.build.outputs.bundle-zip }}")
echo "checksum=${CHECKSUM}" >> "$GITHUB_OUTPUT"
echo "Artifact bundle checksum: ${CHECKSUM}"
- uses: actions/upload-artifact@v7
with:
name: swift-artifactbundle
path: dist/swift-artifactbundle
retention-days: 14
- name: Save checksum as artifact
run: |
mkdir -p dist/swift-checksums
echo "${{ steps.checksum.outputs.checksum }}" > dist/swift-checksums/HtmlToMarkdown-rs.artifactbundle.zip.checksum
- uses: actions/upload-artifact@v7
with:
name: swift-checksum
path: dist/swift-checksums/
retention-days: 14
java-natives:
name: Build Java FFI library (${{ matrix.platform }})
needs: [prepare, check-maven]
if: ${{ needs.prepare.outputs.release_java == 'true' && (needs.check-maven.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, platform: linux-x86_64, target: x86_64-unknown-linux-gnu }
- { os: ubuntu-24.04-arm, platform: linux-aarch64, target: aarch64-unknown-linux-gnu }
- { os: macos-latest, platform: osx-aarch64, target: aarch64-apple-darwin }
- { os: macos-15-intel, platform: osx-x86_64, target: x86_64-apple-darwin }
- { os: windows-latest, platform: windows-x86_64, target: x86_64-pc-windows-msvc }
- { os: windows-11-arm, platform: windows-aarch64, target: aarch64-pc-windows-msvc }
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- name: Free disk space
if: runner.os == 'Linux'
uses: kreuzberg-dev/actions/free-disk-space-linux@v1
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
target: ${{ matrix.target }}
cache-key-prefix: publish-java-${{ matrix.platform }}
- uses: kreuzberg-dev/actions/install-alef@v1
- name: Build and stage FFI library
# Inline: html-to-markdown reuses the C FFI library for Java (Panama),
# so it builds via `alef publish build --lang ffi` and stages the .so/
# .dylib/.dll into a per-platform directory the publish-maven job
# collects into Maven resources. The build-java-natives shared action
# targets repos with a dedicated jni crate, which we don't have.
shell: bash
run: |
alef publish build --lang ffi --target ${{ matrix.target }}
mkdir -p "dist/java-ffi/${{ matrix.platform }}/native"
for src in "target/${{ matrix.target }}/release" "target/release"; do
[[ -d "$src" ]] || continue
find "$src" -maxdepth 1 -type f \( -name '*.so' -o -name '*.dylib' -o -name '*.dll' \) -name '*html_to_markdown_ffi*' -exec cp {} "dist/java-ffi/${{ matrix.platform }}/native/" \;
done
ls -la "dist/java-ffi/${{ matrix.platform }}/native/"
- uses: actions/upload-artifact@v7
with:
name: java-ffi-${{ matrix.platform }}
path: dist/java-ffi
if-no-files-found: error
retention-days: 14
csharp-natives:
name: Build C# FFI library (${{ matrix.rid }})
needs: [prepare, check-nuget]
if: ${{ needs.prepare.outputs.release_csharp == 'true' && (needs.check-nuget.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, rid: linux-x64, target: x86_64-unknown-linux-gnu }
- { os: ubuntu-24.04-arm, rid: linux-arm64, target: aarch64-unknown-linux-gnu }
- { os: macos-latest, rid: osx-arm64, target: aarch64-apple-darwin }
- { os: macos-15-intel, rid: osx-x64, target: x86_64-apple-darwin }
- { os: windows-latest, rid: win-x64, target: x86_64-pc-windows-msvc }
- { os: windows-11-arm, rid: win-arm64, target: aarch64-pc-windows-msvc }
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- name: Free disk space
if: runner.os == 'Linux'
uses: kreuzberg-dev/actions/free-disk-space-linux@v1
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
target: ${{ matrix.target }}
cache-key-prefix: publish-csharp-${{ matrix.rid }}
- uses: kreuzberg-dev/actions/install-alef@v1
- name: Build and stage FFI library
# Inline: html-to-markdown reuses the C FFI library for C# (P/Invoke),
# so it builds via `alef publish build --lang ffi` and stages the .so/
# .dylib/.dll into runtimes/<rid>/native/ for NuGet packaging. The
# build-csharp-natives shared action targets repos with a dedicated
# csharp interop crate, which we don't have.
shell: bash
run: |
alef publish build --lang ffi --target ${{ matrix.target }}
mkdir -p "dist/csharp-ffi/${{ matrix.rid }}/native"
for src in "target/${{ matrix.target }}/release" "target/release"; do
[[ -d "$src" ]] || continue
find "$src" -maxdepth 1 -type f \( -name '*.so' -o -name '*.dylib' -o -name '*.dll' \) -name '*html_to_markdown_ffi*' -exec cp {} "dist/csharp-ffi/${{ matrix.rid }}/native/" \;
done
ls -la "dist/csharp-ffi/${{ matrix.rid }}/native/"
- uses: actions/upload-artifact@v7
with:
name: csharp-ffi-${{ matrix.rid }}
path: dist/csharp-ffi
if-no-files-found: error
retention-days: 14
elixir-natives:
name: Build Elixir NIF (${{ matrix.label }})
needs: [prepare, check-elixir-release, publish-crates]
if: ${{ !cancelled() && needs.publish-crates.result != 'failure' && needs.prepare.outputs.release_elixir == 'true' && (needs.check-elixir-release.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, label: linux-x86_64, target: x86_64-unknown-linux-gnu }
- { os: ubuntu-24.04-arm, label: linux-aarch64, target: aarch64-unknown-linux-gnu }
- { os: macos-latest, label: macos-arm64, target: aarch64-apple-darwin }
- { os: macos-15-intel, label: macos-x86_64, target: x86_64-apple-darwin }
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- name: Free disk space
if: runner.os == 'Linux'
uses: kreuzberg-dev/actions/free-disk-space-linux@v1
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
target: ${{ matrix.target }}
cache-key-prefix: publish-elixir-${{ matrix.label }}
# Inline build: rewrite the NIF crate's workspace path-deps to registry
# version-deps so the hex source-fallback resolves on a consumer.
- name: Rewrite native dependencies to registry versions
uses: kreuzberg-dev/actions/rewrite-native-deps@v1
with:
lang: elixir
- name: Build NIF
# Inline: rustler_precompiled expects flat .so/.dylib artifacts named
# libhtml_to_markdown_nif-vVERSION-nif-NIF-TARGET. We build once and
# tarball under both supported NIF API versions (2.16 and 2.17).
env:
TARGET: ${{ matrix.target }}
VERSION: ${{ needs.prepare.outputs.version }}
shell: bash
run: |
cargo build --release --locked --target "${TARGET}" \
--manifest-path packages/elixir/native/html_to_markdown_nif/Cargo.toml
NIF_DIR="packages/elixir/native/html_to_markdown_nif"
# Source library extension on disk (set by cargo's cdylib output).
if [[ "${RUNNER_OS}" == "macOS" ]]; then
SRC_EXT="dylib"
elif [[ "${RUNNER_OS}" == "Windows" ]]; then
SRC_EXT="dll"
else
SRC_EXT="so"
fi
# Published artifact extension. rustler_precompiled (<= 0.9) hardcodes
# `dll` for windows and `so` for everything else (including macOS) in
# its download URL — see `lib_name_with_ext/2`. If we publish a .dylib
# tarball, consumers 404 on darwin. Normalize to `so` on non-windows.
if [[ "${RUNNER_OS}" == "Windows" ]]; then
ARTIFACT_EXT="dll"
else
ARTIFACT_EXT="so"
fi
LIB_NAME="libhtml_to_markdown_nif.${SRC_EXT}"
LIB_PATH="${NIF_DIR}/target/${TARGET}/release/${LIB_NAME}"
[[ -f "$LIB_PATH" ]] || LIB_PATH="${NIF_DIR}/target/release/${LIB_NAME}"
mkdir -p dist/elixir
for NIF_VERSION in 2.16 2.17; do
ARTIFACT="libhtml_to_markdown_nif-v${VERSION}-nif-${NIF_VERSION}-${TARGET}.${ARTIFACT_EXT}"
cp "$LIB_PATH" "${ARTIFACT}"
tar -czf "dist/elixir/${ARTIFACT}.tar.gz" "${ARTIFACT}"
rm -f "${ARTIFACT}"
done
- uses: actions/upload-artifact@v7
with:
name: elixir-${{ matrix.label }}
path: dist/elixir/*.tar.gz
if-no-files-found: error
retention-days: 14
elixir-package:
name: Build Elixir Hex package
needs: [prepare, check-hex, publish-crates]
# Must wait for `publish-crates`: the shared `build-elixir-hex` action
# rewrites the NIF's `Cargo.toml` path-dep on `html-to-markdown-rs` to a
# registry version-dep and runs `cargo generate-lockfile` against it. If
# publish-crates hasn't published the new version to crates.io yet, the
# lockfile resolution fails with `failed to select a version for the
# requirement \`html-to-markdown-rs = "^X.Y.Z"\``. Mirrors the gate used
# by python-sdist and ruby-gem.
if: ${{ !cancelled() && needs.publish-crates.result != 'failure' && needs.prepare.outputs.release_elixir == 'true' && (needs.check-hex.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
env:
MIX_ENV: dev
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- uses: erlef/setup-beam@v1
with:
elixir-version: "1.19"
otp-version: "28.1"
- uses: kreuzberg-dev/actions/setup-rust@v1
- uses: kreuzberg-dev/actions/build-elixir-hex@v1
with:
package-dir: packages/elixir
nif-crate-path: packages/elixir/native/html_to_markdown_nif
dry-run: ${{ needs.prepare.outputs.dry_run }}
- uses: actions/upload-artifact@v7
with:
name: elixir-hex-package
path: packages/elixir/html_to_markdown-*.tar
retention-days: 14
cargo-packages:
name: Package Rust crates
needs: [prepare, check-cratesio]
if: ${{ needs.prepare.outputs.release_crates == 'true' && (needs.check-cratesio.outputs.all_exist != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
submodules: recursive
- uses: kreuzberg-dev/actions/setup-rust@v1
- name: Package crates
# Inline: cargo package both crates into crate-artifacts/. The CLI
# crate's package step requires html-to-markdown-rs to already be
# registered, so we tolerate failure with --no-verify and fall back
# to publish-time packaging if needed.
env:
RELEASE_VERSION: ${{ needs.prepare.outputs.version }}
shell: bash
run: |
cargo package -p html-to-markdown-rs --allow-dirty
cli_status=0
cargo package -p html-to-markdown-cli --allow-dirty --no-verify || cli_status=$?
mkdir -p crate-artifacts
cp target/package/html-to-markdown-rs-*.crate crate-artifacts/
if [[ "${cli_status}" -eq 0 ]]; then
cp target/package/html-to-markdown-cli-*.crate crate-artifacts/
else
echo "::warning::Skipping html-to-markdown-cli crate packaging; html-to-markdown-rs ${RELEASE_VERSION} is not yet on crates.io."
fi
- uses: actions/upload-artifact@v7
with:
name: cargo-crates
path: crate-artifacts/*.crate
retention-days: 14
# ─── GitHub Release asset uploads ─────────────────────────────────────
upload-cli-release:
name: Upload CLI binaries to GitHub Release
needs: [prepare, cli-binaries]
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.cli-binaries.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: cli-*
path: dist/cli
merge-multiple: true
- uses: kreuzberg-dev/actions/upload-release-assets@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
working-directory: dist/cli
dry-run: ${{ needs.prepare.outputs.dry_run }}
assets: |
cli-*.tar.gz
cli-*.zip
upload-go-release:
name: Upload Go FFI archives to GitHub Release
needs: [prepare, go-ffi-libraries]
if: ${{ needs.prepare.outputs.release_go == 'true' && (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.go-ffi-libraries.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: go-ffi-*
path: dist/go-ffi
merge-multiple: true
- uses: kreuzberg-dev/actions/upload-release-assets@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
working-directory: dist/go-ffi
dry-run: ${{ needs.prepare.outputs.dry_run }}
assets: |
**/*.tar.gz
**/*.zip
upload-c-ffi-release:
name: Upload C FFI archives to GitHub Release
needs: [prepare, c-ffi-libraries]
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.c-ffi-libraries.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: c-ffi-*
path: dist/c-ffi
merge-multiple: true
- uses: kreuzberg-dev/actions/upload-release-assets@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
working-directory: dist/c-ffi
dry-run: ${{ needs.prepare.outputs.dry_run }}
assets: |
**/*.tar.gz
**/*.zip
upload-swift-release:
name: Upload Swift Artifact Bundle to GitHub Release
needs: [prepare, swift-artifactbundle]
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.swift-artifactbundle.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: swift-*
path: dist/swift
merge-multiple: true
- uses: kreuzberg-dev/actions/upload-release-assets@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
working-directory: dist/swift
dry-run: ${{ needs.prepare.outputs.dry_run }}
assets: |
**/*.zip
**/*.checksum
upload-elixir-release:
name: Upload Elixir NIF archives to GitHub Release
needs: [prepare, elixir-natives]
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.elixir-natives.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: elixir-*
path: dist/elixir
merge-multiple: true
- uses: kreuzberg-dev/actions/upload-release-assets@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
working-directory: dist/elixir
dry-run: ${{ needs.prepare.outputs.dry_run }}
assets: libhtml_to_markdown_nif-*.tar.gz
upload-php-pie-release:
name: Upload PHP PIE archives to GitHub Release
needs: [prepare, php-extension]
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.php-extension.result != 'cancelled' && needs.php-extension.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: php-package-*
path: dist/php-package
merge-multiple: true
# Fail fast if zero PIE archives reach this aggregator — a silently
# empty upload (the old failure mode that orphaned v3.6.11's PIE assets,
# see #333) now hard-errors instead of producing an empty release surface.
if-no-files-found: error
- uses: kreuzberg-dev/actions/upload-release-assets@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
working-directory: dist/php-package
dry-run: ${{ needs.prepare.outputs.dry_run }}
assets: |
php_*.tgz
php_*.tgz.sha256
php_*.zip
php_*.zip.sha256
# ─── Publish jobs ─────────────────────────────────────────────────────
publish-crates:
name: Publish crates.io packages
needs: [prepare, validate-versions, cargo-packages, check-cratesio]
if: ${{ needs.prepare.outputs.release_crates == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.cargo-packages.result == 'success' && (needs.check-cratesio.outputs.all_exist != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
submodules: recursive
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
cache-key-prefix: publish-crates
- uses: kreuzberg-dev/actions/publish-crates@v1
with:
# Order matters: html-to-markdown-rs is depended on by html-to-markdown-cli.
crates: html-to-markdown-rs html-to-markdown-cli
version: ${{ needs.prepare.outputs.version }}
dry-run: ${{ needs.prepare.outputs.dry_run }}
publish-pypi:
name: Publish to PyPI
needs: [prepare, python-wheels, python-sdist, check-pypi]
if: ${{ needs.prepare.outputs.release_python == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.python-wheels.result == 'success' && needs.python-sdist.result == 'success' && (needs.check-pypi.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: python-wheels-*
path: dist
merge-multiple: true
- uses: actions/download-artifact@v8
with:
name: python-sdist
path: dist
- uses: kreuzberg-dev/actions/publish-pypi@v1
with:
packages-dir: dist
dry-run: ${{ needs.prepare.outputs.dry_run }}
publish-rubygems:
name: Publish Ruby gems
needs: [prepare, ruby-gem, check-rubygems]
if: ${{ needs.prepare.outputs.release_ruby == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.ruby-gem.result == 'success' && (needs.check-rubygems.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: rubygems-*
path: dist
merge-multiple: true
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: false
- name: Update RubyGems
run: gem update --system
shell: bash
- uses: rubygems/configure-rubygems-credentials@v2.0.0
- uses: kreuzberg-dev/actions/publish-rubygems@v1
with:
gems-dir: dist
dry-run: ${{ needs.prepare.outputs.dry_run }}
publish-node:
name: Publish Node packages
needs: [prepare, node-bindings, node-typescript-defs, check-npm]
if: ${{ needs.prepare.outputs.release_node == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.node-bindings.result == 'success' && needs.node-typescript-defs.result == 'success' && (needs.check-npm.outputs.node_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
submodules: recursive
- uses: actions/download-artifact@v8
with:
pattern: node-bindings-*
path: node-artifacts
merge-multiple: true
- uses: actions/download-artifact@v8
with:
name: node-typescript-defs
path: typescript-defs
- uses: kreuzberg-dev/actions/setup-node-workspace@v1
with:
node-version: "24"
registry-url: "https://registry.npmjs.org/"
- name: Stage platform packages and main package
# Inline: html-to-markdown's per-platform Node artifact layout is bespoke
# (one tarball per target containing pre-staged npm/<platform>/) and the
# main package needs an injected optionalDependencies map listing every
# platform sub-package. Tightly coupled to napi-rs + repo conventions;
# the generic publish-npm action operates on a single package dir.
shell: bash
run: |
rm -rf crates/html-to-markdown-node/npm
mkdir -p crates/html-to-markdown-node/npm
for pkg in node-artifacts/*.tar.gz; do
tar -xzf "${pkg}" -C crates/html-to-markdown-node
done
cp typescript-defs/index.js typescript-defs/index.d.ts crates/html-to-markdown-node/
# Pack each platform sub-package as a tarball
cd crates/html-to-markdown-node/npm
for dir in */; do
if [ -f "$dir/package.json" ]; then
(cd "$dir" && npm pack && mv ./*.tgz ..)
fi
done
cd ../../..
# Stamp main package's optionalDependencies with all platform packages
tmp_pkg_json="$(mktemp)"
optional_deps_json='{}'
for platform_pkg_json in crates/html-to-markdown-node/npm/*/package.json; do
name="$(jq -r '.name // empty' "$platform_pkg_json")"
version="$(jq -r '.version // empty' "$platform_pkg_json")"
[[ -z "$name" || -z "$version" ]] && { echo "Invalid: $platform_pkg_json" >&2; exit 1; }
optional_deps_json="$(jq -c --arg n "$name" --arg v "$version" '. + {($n): $v}' <<<"$optional_deps_json")"
done
jq --argjson deps "$optional_deps_json" '.optionalDependencies = $deps' \
crates/html-to-markdown-node/package.json > "$tmp_pkg_json"
mv "$tmp_pkg_json" crates/html-to-markdown-node/package.json
- name: Publish platform sub-packages
uses: kreuzberg-dev/actions/publish-npm@v1
with:
packages-dir: crates/html-to-markdown-node/npm
npm-tag: ${{ needs.prepare.outputs.npm_tag }}
dry-run: ${{ needs.prepare.outputs.dry_run }}
- name: Wait for npm indexing (linux-x64-gnu)
if: ${{ needs.prepare.outputs.dry_run != 'true' }}
uses: kreuzberg-dev/actions/wait-for-package@v1
with:
registry: npm
package: "@kreuzberg/html-to-markdown-node-linux-x64-gnu"
version: ${{ needs.prepare.outputs.version }}
max-attempts: "60"
- name: Wait for npm indexing (linux-arm64-gnu)
if: ${{ needs.prepare.outputs.dry_run != 'true' }}
uses: kreuzberg-dev/actions/wait-for-package@v1
with:
registry: npm
package: "@kreuzberg/html-to-markdown-node-linux-arm64-gnu"
version: ${{ needs.prepare.outputs.version }}
max-attempts: "60"
- name: Publish main @kreuzberg/html-to-markdown-node package
uses: kreuzberg-dev/actions/publish-npm@v1
with:
package-dir: crates/html-to-markdown-node
npm-tag: ${{ needs.prepare.outputs.npm_tag }}
dry-run: ${{ needs.prepare.outputs.dry_run }}
- name: Wait for main Node package indexing
if: ${{ needs.prepare.outputs.dry_run != 'true' && needs.check-npm.outputs.ts_exists != 'true' }}
uses: kreuzberg-dev/actions/wait-for-package@v1
with:
registry: npm
package: "@kreuzberg/html-to-markdown-node"
version: ${{ needs.prepare.outputs.version }}
max-attempts: "60"
- name: Build TypeScript wrapper package
if: ${{ needs.check-npm.outputs.ts_exists != 'true' }}
# Inline: pnpm tsc + workspace:* rewrite so npm publish (used by the
# publish-npm shared action) ships an installable package. Tightly
# coupled to packages/typescript layout and pnpm workspace conventions.
shell: bash
run: |
(cd packages/typescript && pnpm install --no-frozen-lockfile && pnpm exec tsc --project tsconfig.json)
version="$(node -p "require('./packages/typescript/package.json').version")"
PKG_VERSION="$version" node -e '
const fs = require("node:fs");
const path = "packages/typescript/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
const ver = process.env.PKG_VERSION;
for (const field of ["dependencies", "peerDependencies", "optionalDependencies"]) {
if (pkg[field]) {
for (const [name, spec] of Object.entries(pkg[field])) {
if (typeof spec === "string" && spec.startsWith("workspace:")) {
pkg[field][name] = ver;
}
}
}
}
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
- name: Publish TypeScript wrapper package
if: ${{ needs.check-npm.outputs.ts_exists != 'true' }}
uses: kreuzberg-dev/actions/publish-npm@v1
with:
package-dir: packages/typescript
npm-tag: ${{ needs.prepare.outputs.npm_tag }}
dry-run: ${{ needs.prepare.outputs.dry_run }}
publish-wasm:
name: Publish WASM package
needs: [prepare, wasm-bindings, check-wasm]
if: ${{ needs.prepare.outputs.release_wasm == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.wasm-bindings.result == 'success' && (needs.check-wasm.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
submodules: recursive
- uses: actions/download-artifact@v8
with:
name: wasm-bundle
path: crates/html-to-markdown-wasm
- name: Drop dist .gitignore files
# Inline: wasm-pack writes .gitignore into each dist dir which trips up
# `npm publish`. One-shot cleanup.
shell: bash
run: |
rm -f crates/html-to-markdown-wasm/dist/.gitignore
rm -f crates/html-to-markdown-wasm/dist-node/.gitignore
rm -f crates/html-to-markdown-wasm/dist-web/.gitignore
- uses: kreuzberg-dev/actions/setup-node-workspace@v1
with:
node-version: "24"
registry-url: "https://registry.npmjs.org/"
- uses: kreuzberg-dev/actions/publish-npm@v1
with:
package-dir: crates/html-to-markdown-wasm
npm-tag: ${{ needs.prepare.outputs.npm_tag }}
provenance: "true"
dry-run: ${{ needs.prepare.outputs.dry_run }}
publish-packagist:
name: Trigger Packagist update
needs: [prepare, upload-php-pie-release, check-packagist]
if: ${{ needs.prepare.outputs.release_php == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-packagist.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
steps:
- uses: kreuzberg-dev/actions/publish-packagist@v1
with:
packagist-username: kreuzberg-dev
package-name: kreuzberg-dev/html-to-markdown
version: ${{ needs.prepare.outputs.version }}
repository-url: https://github.com/kreuzberg-dev/html-to-markdown
dry-run: ${{ needs.prepare.outputs.dry_run }}
env:
PACKAGIST_API_TOKEN: ${{ secrets.PACKAGIST_API_TOKEN }}
publish-maven:
name: Publish Maven (Java) package
needs: [prepare, java-natives, check-maven]
if: ${{ needs.prepare.outputs.release_java == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.java-natives.result == 'success' && (needs.check-maven.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
submodules: recursive
- uses: actions/download-artifact@v8
with:
pattern: java-ffi-*
path: java-ffi-artifacts
merge-multiple: true
- name: Stage natives into resources
# Inline: copy classifier-bucketed libs from artifact tree into
# packages/java/src/main/resources/natives/<classifier>/. Tightly
# coupled to the Maven resource layout — no shared action covers it.
shell: bash
run: |
mkdir -p packages/java/src/main/resources/natives
rm -rf packages/java/src/main/resources/natives/*
for dir in java-ffi-artifacts/*/; do
classifier="$(basename "$dir")"
mkdir -p "packages/java/src/main/resources/natives/${classifier}"
cp -r "${dir}native/." "packages/java/src/main/resources/natives/${classifier}/"
done
- uses: actions/setup-java@v5.2.0
with:
distribution: temurin
java-version: "25"
cache: maven
server-id: ossrh
server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD
gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
gpg-passphrase: MAVEN_GPG_PASSPHRASE
- uses: kreuzberg-dev/actions/setup-maven@v1
- uses: kreuzberg-dev/actions/configure-maven-gpg@v1
with:
pom-file: packages/java/pom.xml
- uses: kreuzberg-dev/actions/publish-maven@v1
with:
pom-file: packages/java/pom.xml
maven-profile: publish
extra-args: -DskipTests
dry-run: ${{ needs.prepare.outputs.dry_run }}
env:
MAVEN_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
publish-nuget:
name: Publish NuGet package
needs: [prepare, csharp-natives, check-nuget]
if: ${{ needs.prepare.outputs.release_csharp == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.csharp-natives.result == 'success' && (needs.check-nuget.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
submodules: recursive
- uses: actions/download-artifact@v8
with:
pattern: csharp-ffi-*
path: dist/csharp-ffi
merge-multiple: true
- name: Stage runtimes/<rid>/native into NuGet project
# Inline: NuGet expects native libs in runtimes/<rid>/native/ adjacent
# to the project. Copy from artifact tree into the C# project.
shell: bash
run: |
mkdir -p packages/csharp/HtmlToMarkdown/runtimes
for dir in dist/csharp-ffi/*/; do
rid="$(basename "$dir")"
mkdir -p "packages/csharp/HtmlToMarkdown/runtimes/${rid}/native"
cp -v "${dir}native/"* "packages/csharp/HtmlToMarkdown/runtimes/${rid}/native/" || true
done
- uses: actions/setup-dotnet@v5.2.0
with:
dotnet-version: "8.0.x"
- name: Pack NuGet package
# Inline: dotnet pack with the version override against the inner csproj.
shell: bash
run: |
dotnet restore packages/csharp/HtmlToMarkdown/HtmlToMarkdown.csproj
dotnet pack packages/csharp/HtmlToMarkdown/HtmlToMarkdown.csproj \
-c Release \
-p:Version=${{ needs.prepare.outputs.version }} \
-o dist/nuget
- uses: kreuzberg-dev/actions/publish-nuget@v1
with:
packages-dir: dist/nuget
dry-run: ${{ needs.prepare.outputs.dry_run }}
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
publish-hex:
name: Publish Hex package
needs: [prepare, elixir-package, upload-elixir-release, check-hex]
# Skipped on dry-run: generate-elixir-checksums downloads NIF tarballs from
# the GitHub Release, which doesn't exist on dry-run (upload-release-assets
# only logs in dry-run mode). Real-release runs exercise this path.
if: ${{ needs.prepare.outputs.release_elixir == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.dry_run != 'true' && needs.elixir-package.result == 'success' && (needs.check-hex.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
steps:
# App token with contents:write so generate-elixir-checksums can
# download NIF assets from the draft release via the authenticated
# gh release download path. The default github.token (contents:read
# at workflow scope) sees only published releases.
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
submodules: recursive
- uses: erlef/setup-beam@v1
with:
elixir-version: "1.19"
otp-version: "28.1"
- name: Generate NIF checksum file
uses: kreuzberg-dev/actions/generate-elixir-checksums@v1
with:
github-repo: ${{ github.repository }}
tag: ${{ needs.prepare.outputs.tag }}
version: ${{ needs.prepare.outputs.version }}
lib-name: html_to_markdown_nif
nif-versions: "2.16,2.17"
targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu,aarch64-apple-darwin,x86_64-apple-darwin
output-path: packages/elixir/checksum-Elixir.HtmlToMarkdown.Native.exs
token: ${{ steps.app-token.outputs.token }}
- uses: kreuzberg-dev/actions/publish-hex@v1
with:
package-dir: packages/elixir
dry-run: ${{ needs.prepare.outputs.dry_run }}
env:
HEX_API_KEY: ${{ secrets.HEX_API_KEY }}
kotlin-android-natives:
name: Build Kotlin Android natives (${{ matrix.abi }})
needs: [prepare]
if: ${{ needs.prepare.outputs.release_kotlin == 'true' && (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
abi: [arm64-v8a, x86_64]
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
submodules: recursive
- uses: kreuzberg-dev/actions/setup-rust@v1
with:
targets: aarch64-linux-android,x86_64-linux-android
- uses: android-actions/setup-android@v4.0.1
- uses: kreuzberg-dev/actions/build-android-natives@v1
with:
crate-name: html-to-markdown-ffi
lib-name: html_to_markdown_ffi
abis: ${{ matrix.abi }}
- uses: actions/upload-artifact@v7
with:
name: kotlin-android-${{ matrix.abi }}
path: target/android-libs/${{ matrix.abi }}/libhtml_to_markdown_ffi.so
retention-days: 7
publish-kotlin-android:
name: Publish Kotlin Android to Maven Central
needs: [prepare, kotlin-android-natives, check-maven-kotlin]
if: ${{ needs.prepare.outputs.release_kotlin == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-maven-kotlin.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
submodules: recursive
- uses: actions/download-artifact@v8
with:
pattern: kotlin-android-*
path: kotlin-android-artifacts
merge-multiple: true
- name: Stage Android natives into Kotlin Android module
shell: bash
run: |
for abi in arm64-v8a x86_64; do
dest="packages/kotlin-android/src/main/jniLibs/${abi}"
mkdir -p "$dest"
src="kotlin-android-artifacts/${abi}/libhtml_to_markdown_ffi.so"
if [[ -f "$src" ]]; then
cp -v "$src" "$dest/"
fi
done
- uses: kreuzberg-dev/actions/publish-maven-gradle@v1
with:
working-directory: packages/kotlin-android
gradle-task: publishAndReleaseToMavenCentral
dry-run: ${{ needs.prepare.outputs.dry_run }}
env:
MAVEN_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
MAVEN_GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
trigger-pubdev:
name: Trigger pub.dev publish workflow
needs: [prepare, check-pub, assemble-dart-package]
# pub.dev OIDC rejects tokens from `release` events. We dispatch a separate
# workflow whose `workflow_dispatch` event produces an accepted token.
if: ${{ needs.prepare.outputs.release_dart == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.dry_run != 'true' && (needs.check-pub.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
- name: Dispatch publish-pubdev workflow
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_REPO: ${{ github.repository }}
run: |
# pub.dev OIDC validation requires refType=tag on the token. When this
# workflow is triggered by `release: published`, github.ref_name is the
# release-creating branch (e.g. main), which yields refType=branch and
# pub.dev rejects with "publishing is only allowed from 'tag' refType".
# Dispatch against the actual tag instead.
gh workflow run publish-pubdev.yaml \
--ref ${{ needs.prepare.outputs.tag }} \
-f run_id=${{ github.run_id }}
publish-zig:
name: Publish Zig package metadata
needs: [prepare, check-zig, assemble-zig-package]
if: ${{ needs.prepare.outputs.release_zig == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-zig.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') && needs.assemble-zig-package.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
- uses: actions/download-artifact@v8
with:
name: zig-package-assembled
path: .
- uses: kreuzberg-dev/actions/publish-zig@v1
with:
working-directory: packages/zig
tag: ${{ needs.prepare.outputs.tag }}
# Override the auto-detected package-name (`html_to_markdown_rs` from
# build.zig.zon) to match the alef-emitted test_app URL pattern
# `{crate-name}-zig-v{version}.tar.gz`. Without this, the asset
# uploads as `html_to_markdown_rs-v{version}.tar.gz` while
# test_apps/zig fetches `html-to-markdown-rs-zig-v{version}.tar.gz`
# → 404 + consumer build.zig.zon TODO hash.
package-name: html-to-markdown-rs-zig
update-release-notes: "true"
dry-run: ${{ needs.prepare.outputs.dry_run }}
# Pass the App token explicitly so the action sees the draft
# release. The action's step-level env: GH_TOKEN: ${{ inputs.token }}
# overrides any job-level GH_TOKEN, so the env: below would
# otherwise be ignored.
token: ${{ steps.app-token.outputs.token }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
# ─── Promote release out of draft (early) ─────────────────────────────
#
# homebrew-bottles runs `brew install --build-bottle` which fetches the CLI
# source tarball from the formula's URL block (https://github.com/.../releases/
# download/<tag>/cli-<triple>.tar.gz). Draft releases 404 on the public
# download URL, so the bottle build cannot succeed while the release is still
# draft. release-finalize at the end of the workflow is the canonical
# draft→published promoter, but it gates on homebrew-bottles itself — a
# deadlock. Promote the release here, once the CLI + FFI asset uploads have
# landed, then let release-finalize re-edit draft=false idempotently as the
# canonical home for Go-module tagging + prerelease flag.
promote-release:
name: Publish GitHub Release from draft
needs:
- prepare
- upload-cli-release
- upload-c-ffi-release
- upload-go-release
- upload-swift-release
- upload-elixir-release
- upload-php-pie-release
- upload-r-release
if: |
needs.prepare.outputs.is_tag == 'true' &&
needs.prepare.outputs.dry_run != 'true' &&
needs.upload-cli-release.result == 'success' &&
needs.upload-c-ffi-release.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
- name: Flip release out of draft
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_REPO: ${{ github.repository }}
run: gh release edit "${{ needs.prepare.outputs.tag }}" --draft=false
# ─── Homebrew (formula update + bottles) ──────────────────────────────
#
# Topology (mirrors kreuzberg's; html-to-markdown ships TWO formulas):
# 1. publish-homebrew-formula — update Formula/{html-to-markdown,libhtml-to-markdown}.rb
# (no bottles). Backed by the repo-quirky update-homebrew-formula.sh.
# 2. homebrew-bottles — build per-platform bottle JSON manifests
# 3. publish-homebrew-bottles — merge bottle DSL into formulas and push
publish-homebrew-formula:
name: Update Homebrew formulas
needs: [prepare, check-homebrew, upload-cli-release, upload-c-ffi-release]
if: ${{ needs.prepare.outputs.release_homebrew == 'true' && needs.upload-cli-release.result == 'success' && needs.upload-c-ffi-release.result == 'success' && (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && (needs.check-homebrew.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
runs-on: ubuntu-latest
steps:
# tap-token: scoped to homebrew-tap for the cross-repo checkout + push.
- uses: actions/create-github-app-token@v2
id: tap-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
repositories: homebrew-tap
# source-token: org-wide (no `repositories:`) so the homebrew action's
# `gh release download` against html-to-markdown can see the draft
# release. The tap-token above can't read this repo's releases.
- uses: actions/create-github-app-token@v2
id: source-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
- uses: actions/checkout@v6
with:
repository: kreuzberg-dev/homebrew-tap
token: ${{ steps.tap-token.outputs.token }}
path: homebrew-tap
persist-credentials: true
- uses: kreuzberg-dev/actions/publish-homebrew-source-formulas@v1
with:
tap-dir: ${{ github.workspace }}/homebrew-tap
config-file: scripts/publish/homebrew.json
tag: ${{ needs.prepare.outputs.tag }}
version: ${{ needs.prepare.outputs.version }}
github-repo: ${{ github.repository }}
dry-run: ${{ needs.prepare.outputs.dry_run }}
token: ${{ steps.source-token.outputs.token }}
- name: Commit and push to tap
if: ${{ needs.prepare.outputs.dry_run != 'true' }}
working-directory: homebrew-tap
env:
GH_TOKEN: ${{ steps.tap-token.outputs.token }}
# Inline: tap-side commit/push of the regenerated formulas.
run: |
git config user.name "kreuzberg-dev-publisher[bot]"
git config user.email "291994444+kreuzberg-dev-publisher[bot]@users.noreply.github.com"
if git diff --quiet Formula/; then
echo "No formula changes; skipping commit."
exit 0
fi
git add Formula/html-to-markdown.rb Formula/libhtml-to-markdown.rb
git commit -m "html-to-markdown ${{ needs.prepare.outputs.version }}"
git push origin HEAD
homebrew-bottles:
name: Build Homebrew bottle (${{ matrix.bottle_tag }})
needs: [prepare, publish-homebrew-formula, promote-release]
# Skipped on dry-run: `brew install --build-bottle` downloads CLI/FFI source
# tarballs from the GitHub Release, which doesn't exist on dry-run (formula
# is rendered locally only; tap isn't pushed). Real-release runs cover this.
# Gates on promote-release so the release is published (not draft) by the
# time `brew install` hits the formula's URL block; v3.6.0 saw all 4 bottles
# 404 because release-finalize was the only draft→public promoter and it
# waited on the bottles themselves.
if: ${{ needs.prepare.outputs.release_homebrew == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.dry_run != 'true' && needs.publish-homebrew-formula.result == 'success' && needs.promote-release.result == 'success' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { os: macos-latest, bottle_tag: arm64_sonoma, install_brew: false }
- { os: macos-15-intel, bottle_tag: sonoma, install_brew: false }
- { os: ubuntu-latest, bottle_tag: x86_64_linux, install_brew: true }
- { os: ubuntu-24.04-arm, bottle_tag: arm64_linux, install_brew: true }
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
# Pinned to checkout@v5: v6 hits an includeIf credential regression on
# macos-15-intel runners (same workaround liter-llm uses). Other jobs
# stay on @v6; this matrix is the one that includes Intel macOS.
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Install Homebrew (Linux)
if: ${{ matrix.install_brew }}
uses: kreuzberg-dev/actions/install-homebrew-linux@v1
- name: Build bottles
uses: kreuzberg-dev/actions/homebrew-build-bottles@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
version: ${{ needs.prepare.outputs.version }}
tap: kreuzberg-dev/tap
formulas: |
html-to-markdown
libhtml-to-markdown
out-dir: ${{ github.workspace }}/bottle-json
github-repo: kreuzberg-dev/html-to-markdown
token: ${{ steps.app-token.outputs.token }}
- uses: actions/upload-artifact@v7
with:
name: homebrew-bottle-json-${{ matrix.bottle_tag }}
path: ${{ github.workspace }}/bottle-json/*.json
if-no-files-found: error
retention-days: 14
publish-homebrew-bottles:
name: Merge Homebrew bottle DSL
needs: [prepare, publish-homebrew-formula, homebrew-bottles]
if: ${{ needs.prepare.outputs.release_homebrew == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.publish-homebrew-formula.result == 'success' && needs.homebrew-bottles.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: tap-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
repositories: homebrew-tap
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
- uses: actions/checkout@v6
with:
repository: kreuzberg-dev/homebrew-tap
token: ${{ steps.tap-token.outputs.token }}
path: homebrew-tap
persist-credentials: true
- uses: actions/download-artifact@v8
with:
path: ${{ github.workspace }}/bottle-json
pattern: homebrew-bottle-json-*
merge-multiple: true
- uses: kreuzberg-dev/actions/homebrew-merge-bottles@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
version: ${{ needs.prepare.outputs.version }}
tap-dir: ${{ github.workspace }}/homebrew-tap
json-dir: ${{ github.workspace }}/bottle-json
formulas: |
html-to-markdown
libhtml-to-markdown
github-repo: kreuzberg-dev/html-to-markdown
- name: Commit and push bottle DSL
if: ${{ needs.prepare.outputs.dry_run != 'true' }}
working-directory: homebrew-tap
env:
GH_TOKEN: ${{ steps.tap-token.outputs.token }}
# Inline: tap-side commit/push of the regenerated formula.
run: |
git config user.name "kreuzberg-dev-publisher[bot]"
git config user.email "291994444+kreuzberg-dev-publisher[bot]@users.noreply.github.com"
if git diff --quiet Formula/; then
echo "No bottle DSL changes; skipping commit."
exit 0
fi
git add Formula/html-to-markdown.rb Formula/libhtml-to-markdown.rb
git commit -m "html-to-markdown ${{ needs.prepare.outputs.version }}: add bottle DSL"
git push origin HEAD
# ─── Verification & finalization ──────────────────────────────────────
verify-assets:
name: Verify release assets
needs:
- prepare
- upload-cli-release
- upload-go-release
- upload-c-ffi-release
- upload-elixir-release
- upload-php-pie-release
if: ${{ always() && needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.dry_run != 'true' && !contains(needs.*.result, 'failure') }}
runs-on: ubuntu-latest
steps:
# App token with contents:write so the action's /releases listing can
# see the draft release (release-finalize hasn't published it yet).
# The default github.token has contents:read at workflow scope and so
# only sees published releases — that's the source of the chronic
# "release v3.6.0-rc.* not found after 20 attempts" failure.
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
- uses: kreuzberg-dev/actions/verify-release-assets@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
token: ${{ steps.app-token.outputs.token }}
expected-assets: |
# CLI
cli-*.tar.gz
cli-*.zip
# Go FFI
html-to-markdown-rs-go-*.tar.gz
# C FFI
html-to-markdown-rs-ffi-*.tar.gz
# Elixir NIF
libhtml_to_markdown_nif-*.tar.gz
update-swift-package-manifest:
name: Update Swift Package.swift manifest with artifact URL and checksum
needs: [prepare, upload-swift-release]
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.upload-swift-release.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
- uses: actions/download-artifact@v8
with:
name: swift-checksum
path: swift-checksums
- name: Update Package.swift with version and checksum
if: ${{ needs.prepare.outputs.dry_run != 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
VERSION="${{ needs.prepare.outputs.version }}"
CHECKSUM=$(cat swift-checksums/*.checksum | tr -d '[:space:]')
# Replace placeholders in Package.swift
sed -i "s/__ALEF_SWIFT_VERSION__/${VERSION}/g" Package.swift
sed -i "s/__ALEF_SWIFT_CHECKSUM__/${CHECKSUM}/g" Package.swift
echo "Updated Package.swift:"
head -30 Package.swift
# Commit the substituted Package.swift back to the rc tag.
# The checksum is only known after the artifact bundle is built, but consumers
# (test_apps) fetch the tagged tree and need the substituted manifest.
# Force-update the tag to point at this post-substitution commit.
#
# This workflow is the sole writer for this tag's substituted state, so
# `--force-with-lease` adds no safety and breaks against annotated→lightweight
# tag conversion (the lease's expected SHA is the annotated tag object, but the
# server compares it differently after we replace it with a lightweight tag).
# Use plain `--force` and retry transient network failures.
git config user.name "kreuzberg-dev-publisher[bot]"
git config user.email "291994444+kreuzberg-dev-publisher[bot]@users.noreply.github.com"
git add Package.swift
git commit -m "chore(release): substitute Swift checksum for ${VERSION}"
git tag -f "v${VERSION}" HEAD
for attempt in 1 2 3; do
if git push origin "v${VERSION}" --force; then
break
fi
if [ "$attempt" = 3 ]; then
echo "::error::Failed to push tag v${VERSION} after 3 attempts"
exit 1
fi
echo "Push attempt ${attempt} failed, retrying in 5s..."
sleep 5
done
- name: Update release notes with artifact URL
if: ${{ needs.prepare.outputs.dry_run != 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
CHECKSUM=$(cat swift-checksums/*.checksum | tr -d '[:space:]')
ASSET_URL="https://github.com/kreuzberg-dev/html-to-markdown/releases/download/v${VERSION}/HtmlToMarkdown-rs.artifactbundle.zip"
cat > /tmp/swift-notes.md << EOF
## Swift Package Manager
Add to your \`Package.swift\`:
\`\`\`swift
.package(url: "https://github.com/kreuzberg-dev/html-to-markdown", from: "${VERSION}")
\`\`\`
The Swift binding is distributed as a pre-built artifact bundle. No Rust toolchain required.
**Artifact**: [HtmlToMarkdown-rs.artifactbundle.zip](${ASSET_URL})
**Checksum**: \`${CHECKSUM}\`
EOF
gh release edit "v${VERSION}" --notes-file /tmp/swift-notes.md
build-r-package:
name: Build R source tarball
needs: prepare
if: ${{ needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.checkout_ref }}
submodules: recursive
- uses: r-lib/actions/setup-r@v2
with:
r-version: "release"
- uses: dtolnay/rust-toolchain@stable
- name: Install system deps
run: |
sudo apt-get update
sudo apt-get install -y python3
- name: Build R source tarball
# The packages/r/configure script vendors the html-to-markdown-rs core
# crate from the monorepo at build time; running `R CMD build` inside
# the repo therefore produces a self-contained source tarball.
working-directory: packages/r
run: |
R CMD build .
ls -la htmltomarkdown_*.tar.gz
- uses: actions/upload-artifact@v7
with:
name: r-source-tarball
path: packages/r/htmltomarkdown_*.tar.gz
if-no-files-found: error
retention-days: 7
upload-r-release:
name: Upload R source tarball to GitHub Release
needs: [prepare, build-r-package]
if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.build-r-package.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
name: r-source-tarball
path: dist/r
- uses: kreuzberg-dev/actions/upload-release-assets@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
working-directory: dist/r
dry-run: ${{ needs.prepare.outputs.dry_run }}
assets: |
htmltomarkdown_*.tar.gz
release-finalize:
name: Finalize GitHub Release
needs:
- prepare
- verify-assets
- publish-crates
- publish-pypi
- publish-rubygems
- publish-node
- publish-wasm
- publish-packagist
- publish-maven
- publish-nuget
- publish-hex
- publish-homebrew-bottles
- publish-kotlin-android
- trigger-pubdev
- publish-zig
- upload-cli-release
- upload-go-release
- upload-c-ffi-release
- upload-swift-release
- update-swift-package-manifest
- upload-r-release
if: |
always() &&
!cancelled() &&
needs.prepare.outputs.is_tag == 'true' &&
needs.prepare.outputs.dry_run != 'true' &&
!contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
- uses: kreuzberg-dev/actions/finalize-release@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
is-prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'true' || 'false' }}
go-module-path: packages/go/v3
token: ${{ steps.app-token.outputs.token }}
announce-discord:
name: Announce release on Discord
needs:
- prepare
- publish-crates
- publish-pypi
- publish-rubygems
- publish-node
- publish-wasm
- publish-packagist
- publish-maven
- publish-nuget
- publish-hex
- publish-homebrew-bottles
- publish-kotlin-android
- trigger-pubdev
- publish-zig
- upload-cli-release
- upload-go-release
- upload-c-ffi-release
- upload-elixir-release
- upload-php-pie-release
- upload-r-release
- release-finalize
if: |
always() &&
!cancelled() &&
needs.prepare.outputs.is_tag == 'true' &&
needs.prepare.outputs.dry_run != 'true' &&
needs.prepare.outputs.is_prerelease != 'true' &&
!contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: kreuzberg-dev
- uses: kreuzberg-dev/actions/announce-release-discord@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
project-name: html-to-markdown
token: ${{ steps.app-token.outputs.token }}