Skip to content

release

release #9

Workflow file for this run

name: release
# Builds the release artefacts on native runners and uploads them to the release
# for the tag that triggered this run.
#
# Why native runners rather than cross-compiling: the server carries twenty
# tree-sitter C grammars, a bundled SQLite and protobuf codegen, and macOS cannot
# be cross-targeted from Linux at all without an SDK. Native runners are the only
# way to produce a macOS binary anyone should install, and they exercise each
# target's own linker.
#
# What is built where, and why not everything everywhere:
# - mindex-index / mindex-watch are pure Rust over reqwest+rustls with no C
# grammars and no protoc. They cross platforms cleanly, and they are what a
# user actually runs on their own machine.
# - the mindex server is Linux x86-64 only as a *binary*. It is developed, run
# and released on Linux; a server binary nobody has exercised on a platform
# is worse on the release page than an absent one. Docker is what the README
# tells everyone else to use, so the image is built and pushed here — saying
# "use Docker" while publishing no image was the gap this closes.
# - deploy/embedder/{server.py,requirements.txt} ship as assets too. mindex no
# longer contains an embedder and cannot start without one, so the reference
# implementation of the contract belongs beside the binary that needs it.
#
# The release itself is created by hand (`gh release create`) so its notes are
# written rather than generated; these jobs only upload into it. `--clobber` makes
# a re-run idempotent.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Existing tag to build and upload to"
required: true
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
# A release build is not the place to discover a warning — but note the blast
# radius: RUSTFLAGS is process-global, so this denies warnings in every
# DEPENDENCY crate too, and one upstream deprecation in a transitive fails the
# release. Scoping it to this workspace means `[lints]` in Cargo.toml, which is
# the right place and a change to the build rather than to CI; until then, a
# release that fails here and nowhere else is almost always somebody else's
# crate.
RUSTFLAGS: "-D warnings"
jobs:
cli:
name: CLI tools (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: windows-latest
target: x86_64-pc-windows-msvc
# BOTH macOS targets build on macos-14 (Apple silicon), and the Intel
# one is a CROSS-COMPILE. It used to have its own `macos-13` runner,
# and that runner is retired: the job was never picked up, sat queued
# for GitHub's 24-hour maximum and was auto-cancelled — on v1.1.0 and
# again on v1.2.0. Two consequences, and the second is why this went
# unnoticed for two releases. `mindex-cli-x86_64-apple-darwin.tar.gz`
# was silently absent from both release pages while README.md promised
# "macOS (Intel and Apple silicon)". And because the other five jobs
# succeeded and uploaded, the only symptom was the run's overall
# conclusion reading `cancelled` a day after anyone stopped looking.
#
# Cross-compiling is safe for these two binaries specifically: they are
# pure Rust over reqwest+rustls with no tree-sitter C grammars and no
# protoc (the reason the server is Linux-only and these are not), and
# Xcode ships both architectures' SDK. If a C dependency ever does
# refuse, it fails the job in minutes rather than queueing for a day —
# which is itself the improvement.
- os: macos-14
target: x86_64-apple-darwin
- os: macos-14
target: aarch64-apple-darwin
steps:
- uses: actions/checkout@v5
# rust-toolchain.toml pins the channel; `rustup show` materialises it.
#
# `shell: bash` here is prophylactic rather than load-bearing — two bare
# commands parse under PowerShell too — but this matrix contains Windows,
# and the step below shows what one continuation costs when it does not say
# which shell it meant.
- name: Install the pinned toolchain
shell: bash
run: |
rustup show
rustup target add ${{ matrix.target }}
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
tools/indexer/target
tools/watcher/target
key: cli-${{ matrix.target }}-${{ hashFiles('tools/*/Cargo.lock') }}
# tools/indexer and tools/watcher are SEPARATE workspaces with their own
# Cargo.lock — they are not members of the root workspace, so they must be
# built by manifest path rather than with -p from the root.
# `--target` is passed on EVERY target, not only the cross one. Without it
# cargo builds for the host and writes to `target/release/`, so the archive
# named `mindex-cli-<triple>` carried whatever the runner happened to be —
# a label rather than a fact, and correct only by coincidence. With it the
# output is always `target/<triple>/release/`, which the packaging steps
# below now read, so the name and the binary cannot disagree.
# `shell: bash` is load-bearing on Windows and nowhere else. Without it the
# runner's default there is PowerShell, which does not read `\` as a line
# continuation — so the command below is a ParserError and the job dies
# before cargo is invoked. The continuations arrived with `--target` in the
# commit that fixed the macOS gap, which therefore broke the Windows archive
# in the same breath: shipped in v1.1.0 and v1.2.0, absent from the v2.0.0
# rebuild. Every other multi-line step in this file already says `shell:
# bash` for the same reason, including `Package (windows)` directly below.
- name: Build
shell: bash
run: |
cargo build --release --locked --target ${{ matrix.target }} \
--manifest-path tools/indexer/Cargo.toml
cargo build --release --locked --target ${{ matrix.target }} \
--manifest-path tools/watcher/Cargo.toml
# Staging and dist are separate directories on purpose: the upload step globs
# `dist/*`, so anything in there must be an artefact. Globbing by extension
# instead means naming both .tar.gz and .zip on every platform, and gh fails
# the job on the one that cannot match.
- name: Package (unix)
if: runner.os != 'Windows'
shell: bash
run: |
set -euo pipefail
name="mindex-cli-${{ matrix.target }}"
mkdir -p "staging/$name" dist
cp tools/indexer/target/${{ matrix.target }}/release/mindex-index "staging/$name/"
cp tools/watcher/target/${{ matrix.target }}/release/mindex-watch "staging/$name/"
cp LICENSE README.md "staging/$name/"
tar -czf "dist/$name.tar.gz" -C staging "$name"
(cd dist && shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256")
- name: Package (windows)
if: runner.os == 'Windows'
shell: bash
run: |
set -euo pipefail
name="mindex-cli-${{ matrix.target }}"
mkdir -p "staging/$name" dist
cp tools/indexer/target/${{ matrix.target }}/release/mindex-index.exe "staging/$name/"
cp tools/watcher/target/${{ matrix.target }}/release/mindex-watch.exe "staging/$name/"
cp LICENSE README.md "staging/$name/"
(cd staging && 7z a "../dist/$name.zip" "$name" > /dev/null)
(cd dist && sha256sum "$name.zip" > "$name.zip.sha256")
- name: Upload to the release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.inputs.tag || github.ref_name }}
run: gh release upload "$TAG" dist/* --clobber
server:
name: Server (linux x86-64)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
# protoc is qdrant-client's build-time dependency. rusqlite is `bundled`
# and utoipa-swagger-ui is `vendored`, so nothing else reaches the network.
- name: Install native build dependencies
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler cmake pkg-config
- name: Install the pinned toolchain
run: rustup show
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: server-${{ hashFiles('Cargo.lock') }}
- name: Build
run: cargo build --release --locked --bin mindex
- name: Package
shell: bash
run: |
set -euo pipefail
name="mindex-server-x86_64-unknown-linux-gnu"
mkdir -p "staging/$name" dist
cp target/release/mindex "staging/$name/"
cp LICENSE README.md config.example.toml "staging/$name/"
tar -czf "dist/$name.tar.gz" -C staging "$name"
(cd dist && shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256")
- name: Upload to the release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.inputs.tag || github.ref_name }}
run: gh release upload "$TAG" dist/* --clobber
vsix:
name: VS Code extension
runs-on: ubuntu-latest
defaults:
run:
working-directory: tools/vscode
steps:
- uses: actions/checkout@v5
# 22, not 20, and both halves of the toolchain insist on it: `npm run test`
# is `node --test "out/**/*.test.js"`, and glob expansion by `--test` landed
# in Node 21 — under 20 it reports the pattern as a missing file and exits 1.
# vsce's own Azure dependencies declare `node: >=22.0.0` besides.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: tools/vscode/package-lock.json
- run: npm ci
# `npm run package` runs the full check suite (prettier, eslint, tsc, the
# node:test suite) before vsce, so a broken extension fails the release
# rather than shipping. vsce runs vscode:prepublish, which rebuilds dist/ —
# the extension runs dist/, so a stale one would ship yesterday's code.
- run: npm run package
- name: Upload to the release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.inputs.tag || github.ref_name }}
run: gh release upload "$TAG" ./*.vsix --clobber
embedder:
name: Reference embedder
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
# No build: it is one Python file and its dependency list. It is published
# because mindex ships no embedder and refuses to start without one, so
# "download the server, then go and find something that speaks
# /v1/embeddings" is not a complete release. Staged into its own directory
# so the upload glob cannot catch anything else.
- name: Package
shell: bash
run: |
mkdir -p dist
cp deploy/embedder/server.py deploy/embedder/requirements.txt \
deploy/embedder/embedder.env.example \
deploy/embedder/mindex-embedder.service \
deploy/embedder/README.md dist/
(cd dist && sha256sum server.py > server.py.sha256)
- name: Upload to the release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.inputs.tag || github.ref_name }}
run: gh release upload "$TAG" dist/* --clobber
image:
name: Docker image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
# linux/amd64 only, matching the binary job and for the same reason: the
# server carries twenty tree-sitter C grammars and protobuf codegen, and an
# arm64 image built under emulation would take an hour to produce something
# nobody has run. Add a platform when someone runs it.
#
# Two tags: the version, and `latest`. `latest` moves only on a real
# release, because this workflow fires on `v*` tags and nothing else.
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.event.inputs.tag || github.ref_name }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# The release page is the deliverable, and until now nothing checked it. Every
# upload job is independent, so a release could be — and twice was — published
# short of an artefact while the run's own conclusion said nothing useful: on
# v1.1.0 and v1.2.0 the retired macos-13 runner never picked its job up, five
# siblings succeeded, and `mindex-cli-x86_64-apple-darwin.tar.gz` was silently
# absent from both pages while README.md promised it. v2.0.0 then published with
# ZERO assets.
#
# Two properties make this catch that class rather than describe it:
#
# `if: always()` — a job whose `needs` were CANCELLED is SKIPPED, not failed,
# and queued-then-cancelled is exactly the observed failure (on the v2.0.0
# rebuild five jobs were evicted after 15 minutes without ever being assigned
# a runner, `runner_name` empty). Without this the guard would go quiet in the
# one case it exists for.
#
# It reads the RELEASE, not the jobs. What matters is whether the artefact is
# downloadable now — which also covers an upload that succeeded into the wrong
# tag, and a re-run that was meant to backfill one missing file.
#
# The Docker image is deliberately not checked here: it is not a release asset,
# it lives in ghcr, and `docker/build-push-action` fails its own job on a failed
# push. Keep EXPECTED in step with the packaging steps above — it names files,
# so a renamed archive fails here rather than on somebody's download.
verify:
name: Verify the release page
runs-on: ubuntu-latest
needs: [cli, server, vsix, embedder, image]
if: always()
steps:
- name: Every promised asset is on the release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.inputs.tag || github.ref_name }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
EXPECTED=(
mindex-cli-x86_64-unknown-linux-gnu.tar.gz
mindex-cli-x86_64-apple-darwin.tar.gz
mindex-cli-aarch64-apple-darwin.tar.gz
mindex-cli-x86_64-pc-windows-msvc.zip
mindex-server-x86_64-unknown-linux-gnu.tar.gz
server.py
requirements.txt
)
echo "Assets on $TAG:"
gh release view "$TAG" --repo "$REPO" --json assets \
--jq '.assets[].name' | sort | tee assets.txt
missing=()
for want in "${EXPECTED[@]}"; do
grep -qxF "$want" assets.txt || missing+=("$want")
done
# The .vsix carries the version in its name, so it is matched by shape
# rather than spelled out — pinning the version here would mean editing
# this file every release, which is how a guard rots into a lie.
grep -qE '^mindex-vscode-.*\.vsix$' assets.txt || missing+=("mindex-vscode-<version>.vsix")
if [ ${#missing[@]} -ne 0 ]; then
echo
echo "::error::The release is short of ${#missing[@]} artefact(s). A release page missing what the notes promise is the failure this job exists to refuse; re-run the jobs that produce them."
printf ' missing: %s\n' "${missing[@]}"
exit 1
fi
echo
echo "All expected assets are present."