name: Release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
validate:
name: Validate release metadata
runs-on: ubuntu-latest
outputs:
crate_name: ${{ steps.meta.outputs.crate_name }}
tag: ${{ steps.meta.outputs.tag }}
version: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Read crate metadata and validate tag
id: meta
shell: bash
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
version="${tag#v}"
crate_name="$(sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
cargo_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
lock_version="$(
awk -v crate_name="${crate_name}" '
$0 == "[[package]]" { in_pkg = 0; next }
$0 == "name = \"" crate_name "\"" { in_pkg = 1; next }
in_pkg && $1 == "version" {
gsub(/"/, "", $3)
print $3
exit
}
' Cargo.lock
)"
if [[ -z "${crate_name}" || -z "${cargo_version}" || -z "${lock_version}" ]]; then
echo "Could not read crate metadata from Cargo.toml" >&2
exit 1
fi
if [[ "${cargo_version}" != "${lock_version}" ]]; then
echo "Cargo.toml version ${cargo_version} does not match Cargo.lock version ${lock_version}" >&2
exit 1
fi
if [[ "${version}" != "${cargo_version}" ]]; then
echo "Tag ${tag} does not match Cargo.toml version ${cargo_version}" >&2
exit 1
fi
{
echo "crate_name=${crate_name}"
echo "tag=${tag}"
echo "version=${version}"
} >> "$GITHUB_OUTPUT"
- name: Verify tag points to mainline history
shell: bash
run: |
set -euo pipefail
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "${GITHUB_SHA}" "origin/main"; then
echo "Tagged commit ${GITHUB_SHA} is not reachable from origin/main" >&2
exit 1
fi
- name: Extract release notes from changelog
shell: bash
run: |
set -euo pipefail
version="${{ steps.meta.outputs.version }}"
awk -v version="${version}" '
$0 ~ "^## \\[" version "\\]" { in_section = 1; next }
in_section && $0 ~ "^## \\[" { exit }
in_section && $0 ~ "^\\[[^]]+\\]: " { exit }
in_section { print }
' CHANGELOG.md > RELEASE_NOTES.md
sed -i '/./,$!d' RELEASE_NOTES.md
if ! grep -q '[^[:space:]]' RELEASE_NOTES.md; then
echo "Failed to extract release notes for ${version} from CHANGELOG.md" >&2
exit 1
fi
- name: Upload release notes
uses: actions/upload-artifact@v6
with:
name: release-notes
path: RELEASE_NOTES.md
- name: Install preview test tooling
run: |
set -euxo pipefail
sudo apt-get update
sudo apt-get install -y \
ffmpeg \
genisoimage \
imagemagick \
libarchive-tools \
poppler-utils \
xz-utils
if ! command -v 7z >/dev/null 2>&1; then
if sudo apt-get install -y p7zip-full; then
:
else
sudo apt-get install -y 7zip
if command -v 7zz >/dev/null 2>&1 && ! command -v 7z >/dev/null 2>&1; then
sudo ln -s "$(command -v 7zz)" /usr/local/bin/7z
fi
fi
fi
- name: Install Rust 1.93.0
uses: dtolnay/rust-toolchain@1.93.0
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
- name: Run release gate tests
run: cargo test --locked
build-linux:
name: Build release artifact (Linux)
needs: validate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install Rust 1.93.0
uses: dtolnay/rust-toolchain@1.93.0
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
- name: Build release binary
run: cargo build --locked --release
- name: Package Linux artifact
shell: bash
run: |
set -euo pipefail
version="${{ needs.validate.outputs.version }}"
host_target="$(rustc -vV | sed -n 's/^host: //p')"
bundle_dir="dist/elio-${version}-${host_target}"
mkdir -p "${bundle_dir}"
cp target/release/elio "${bundle_dir}/"
cp README.md LICENSE-MIT "${bundle_dir}/"
tar -C dist -czf "dist/elio-${version}-${host_target}.tar.gz" "$(basename "${bundle_dir}")"
- name: Upload Linux artifact
uses: actions/upload-artifact@v6
with:
name: release-asset-linux
path: dist/*.tar.gz
build-macos:
name: Build release artifact (macOS)
needs: validate
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install Rust 1.93.0
uses: dtolnay/rust-toolchain@1.93.0
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
- name: Build release binary
run: cargo build --locked --release
- name: Package macOS artifact
shell: bash
run: |
set -euo pipefail
version="${{ needs.validate.outputs.version }}"
host_target="$(rustc -vV | sed -n 's/^host: //p')"
bundle_dir="dist/elio-${version}-${host_target}"
mkdir -p "${bundle_dir}"
cp target/release/elio "${bundle_dir}/"
cp README.md LICENSE-MIT "${bundle_dir}/"
tar -C dist -czf "dist/elio-${version}-${host_target}.tar.gz" "$(basename "${bundle_dir}")"
- name: Upload macOS artifact
uses: actions/upload-artifact@v6
with:
name: release-asset-macos
path: dist/*.tar.gz
build-windows:
name: Build release artifact (Windows)
needs: validate
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install Rust 1.93.0
uses: dtolnay/rust-toolchain@1.93.0
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
- name: Build release binary
run: cargo build --locked --release
- name: Package Windows artifact
shell: pwsh
env:
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
run: |
$hostTarget = ((rustc -vV | Select-String '^host: ').ToString().Split(':')[1]).Trim()
$bundleDir = "dist/elio-$env:RELEASE_VERSION-$hostTarget"
New-Item -ItemType Directory -Path $bundleDir -Force | Out-Null
Copy-Item "target/release/elio.exe" "$bundleDir/"
Copy-Item "README.md" "$bundleDir/"
Copy-Item "LICENSE-MIT" "$bundleDir/"
Compress-Archive -Path $bundleDir -DestinationPath "dist/elio-$env:RELEASE_VERSION-$hostTarget.zip" -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v6
with:
name: release-asset-windows
path: dist/*.zip
github-release:
name: Create GitHub release
needs:
- validate
- build-linux
- build-macos
- build-windows
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download release notes
uses: actions/download-artifact@v7
with:
name: release-notes
path: release-notes
- name: Download release artifacts
uses: actions/download-artifact@v7
with:
pattern: release-asset-*
path: dist
merge-multiple: true
- name: Create or update GitHub release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
tag="${{ needs.validate.outputs.tag }}"
if gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
gh release edit "${tag}" \
--repo "${GITHUB_REPOSITORY}" \
--title "${tag}" \
--notes-file release-notes/RELEASE_NOTES.md
else
gh release create "${tag}" \
--repo "${GITHUB_REPOSITORY}" \
--title "${tag}" \
--notes-file release-notes/RELEASE_NOTES.md
fi
gh release upload "${tag}" dist/* --clobber --repo "${GITHUB_REPOSITORY}"
publish-crates:
name: Publish crate
needs:
- validate
- github-release
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install Rust 1.93.0
uses: dtolnay/rust-toolchain@1.93.0
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
- name: Check whether version exists on crates.io
id: crates
env:
CRATE_NAME: ${{ needs.validate.outputs.crate_name }}
VERSION: ${{ needs.validate.outputs.version }}
shell: bash
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
import os
import sys
import urllib.error
import urllib.request
crate_name = os.environ["CRATE_NAME"]
version = os.environ["VERSION"]
url = f"https://crates.io/api/v1/crates/{crate_name}"
try:
with urllib.request.urlopen(url) as response:
payload = json.load(response)
except urllib.error.HTTPError as error:
if error.code == 404:
print("published=false")
sys.exit(0)
raise
versions = {entry["num"] for entry in payload.get("versions", [])}
if version in versions:
print("published=true")
else:
print("published=false")
PY
- name: Authenticate with crates.io
id: auth
if: steps.crates.outputs.published != 'true'
uses: rust-lang/crates-io-auth-action@v1.0.4
- name: Publish to crates.io
if: steps.crates.outputs.published != 'true'
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: cargo publish --locked
- name: Skip crates.io publish
if: steps.crates.outputs.published == 'true'
env:
CRATE_NAME: ${{ needs.validate.outputs.crate_name }}
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
shell: bash
run: echo "${CRATE_NAME} ${RELEASE_VERSION} is already published on crates.io"
update-aur:
name: Publish AUR package (${{ matrix.package.name }})
needs:
- validate
- publish-crates
runs-on: ubuntu-latest
environment: release
permissions: {}
container:
image: archlinux:base-devel
strategy:
fail-fast: false
matrix:
package:
- name: elio
repo: ssh://aur@aur.archlinux.org/elio.git
dir: aur-elio
- name: elio-bin
repo: ssh://aur@aur.archlinux.org/elio-bin.git
dir: aur-elio-bin
steps:
- name: Install packaging tools
shell: bash
run: |
set -euxo pipefail
pacman -Syu --noconfirm --needed \
git \
openssh \
pacman-contrib
useradd -m builder
- name: Configure AUR SSH key
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${AUR_SSH_PRIVATE_KEY:-}" ]]; then
echo "AUR_SSH_PRIVATE_KEY is not configured" >&2
exit 1
fi
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
known_hosts="$(mktemp)"
ssh-keyscan -H aur.archlinux.org > "${known_hosts}"
if [[ ! -s "${known_hosts}" ]]; then
echo "Failed to fetch AUR SSH host keys" >&2
exit 1
fi
mv "${known_hosts}" ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
{
echo "Host aur.archlinux.org"
echo " HostName aur.archlinux.org"
echo " User aur"
echo " IdentityFile ${HOME}/.ssh/aur"
echo " IdentitiesOnly yes"
echo " UserKnownHostsFile ${HOME}/.ssh/known_hosts"
echo " StrictHostKeyChecking yes"
} > ~/.ssh/config
chmod 600 ~/.ssh/config
- name: Clone AUR package repository
env:
AUR_REPO: ${{ matrix.package.repo }}
AUR_DIR: ${{ matrix.package.dir }}
shell: bash
run: |
set -euo pipefail
export GIT_SSH_COMMAND="ssh -F ${HOME}/.ssh/config"
git clone "${AUR_REPO}" "${AUR_DIR}"
chown -R builder:builder "${AUR_DIR}"
- name: Update PKGBUILD and .SRCINFO
env:
AUR_DIR: ${{ matrix.package.dir }}
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
shell: bash
run: |
set -euo pipefail
runuser -u builder -- env \
AUR_DIR="${AUR_DIR}" \
RELEASE_VERSION="${RELEASE_VERSION}" \
bash <<'BASH'
set -euo pipefail
cd "${AUR_DIR}"
current_pkgver="$(sed -n 's/^pkgver=//p' PKGBUILD | head -n1)"
current_pkgrel="$(sed -n 's/^pkgrel=//p' PKGBUILD | head -n1)"
if [[ -z "${current_pkgver}" || -z "${current_pkgrel}" ]]; then
echo "Failed to read pkgver/pkgrel from ${AUR_DIR}/PKGBUILD" >&2
exit 1
fi
if [[ ! "${current_pkgrel}" =~ ^[0-9]+$ ]]; then
echo "Unsupported non-numeric pkgrel '${current_pkgrel}' in ${AUR_DIR}/PKGBUILD" >&2
exit 1
fi
same_version=false
target_pkgrel=1
if [[ "${current_pkgver}" == "${RELEASE_VERSION}" ]]; then
same_version=true
target_pkgrel="${current_pkgrel}"
fi
update_pkgbuild() {
local pkgrel="$1"
sed -i "s/^pkgver=.*/pkgver=${RELEASE_VERSION}/" PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=${pkgrel}/" PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('SKIP')/" PKGBUILD
for attempt in {1..12}; do
if updpkgsums; then
break
fi
if [[ "${attempt}" -eq 12 ]]; then
echo "Failed to generate AUR checksum after 12 attempts" >&2
exit 1
fi
sleep 10
done
makepkg --printsrcinfo > .SRCINFO
}
update_pkgbuild "${target_pkgrel}"
if [[ "${same_version}" == "true" ]] && ! git diff --quiet -- PKGBUILD .SRCINFO; then
update_pkgbuild "$((current_pkgrel + 1))"
fi
BASH
- name: Commit and push AUR update
working-directory: ${{ matrix.package.dir }}
env:
AUR_PACKAGE: ${{ matrix.package.name }}
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
shell: bash
run: |
set -euo pipefail
git config --global --add safe.directory "${PWD}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO
if git diff --cached --quiet; then
echo "AUR package is already up to date"
exit 0
fi
git commit -m "Update ${AUR_PACKAGE} to ${RELEASE_VERSION}"
export GIT_SSH_COMMAND="ssh -F ${HOME}/.ssh/config"
git push
update-copr:
name: Publish COPR package
needs:
- publish-crates
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
container:
image: fedora:43
env:
COPR_PROJECT: elio
steps:
- name: Install COPR build tooling
shell: bash
run: |
set -euxo pipefail
dnf -y install \
cargo \
copr-cli \
git \
make \
rpm-build \
zstd
- name: Checkout
uses: actions/checkout@v5
- name: Trust checked-out repository
shell: bash
run: |
set -euo pipefail
git config --global --add safe.directory "${GITHUB_WORKSPACE}"
- name: Configure COPR credentials
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${COPR_CONFIG:-}" ]]; then
echo "COPR_CONFIG is not configured" >&2
exit 1
fi
mkdir -p ~/.config
printf '%s\n' "${COPR_CONFIG}" > ~/.config/copr
chmod 600 ~/.config/copr
- name: Build and submit COPR SRPM
shell: bash
run: |
set -euo pipefail
srpm_dir="$(mktemp -d)"
trap 'rm -rf "${srpm_dir}"' EXIT
make -f .copr/Makefile srpm \
outdir="${srpm_dir}" \
spec=packaging/fedora/elio.spec \
release=1
srpm_path="$(find "${srpm_dir}" -maxdepth 1 -name '*.src.rpm' -print -quit)"
if [[ -z "${srpm_path}" ]]; then
echo "No source RPM was created in ${srpm_dir}" >&2
exit 1
fi
copr-cli build "${COPR_PROJECT}" "${srpm_path}"
update-homebrew:
name: Publish Homebrew tap
needs:
- validate
- github-release
- publish-crates
runs-on: ubuntu-latest
environment: release
permissions: {}
env:
HOMEBREW_TAP_REPO: elio-fm/homebrew-elio
steps:
- name: Install tooling
shell: bash
run: |
set -euxo pipefail
sudo apt-get update
sudo apt-get install -y curl git
gh --version
- name: Clone Homebrew tap
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${HOMEBREW_TAP_TOKEN:-}" ]]; then
echo "HOMEBREW_TAP_TOKEN is not configured" >&2
exit 1
fi
auth_header="$(printf 'x-access-token:%s' "${HOMEBREW_TAP_TOKEN}" | base64 | tr -d '\n')"
echo "::add-mask::${auth_header}"
git -c "http.https://github.com/.extraheader=AUTHORIZATION: Basic ${auth_header}" \
clone "https://github.com/${HOMEBREW_TAP_REPO}.git" homebrew-elio
- name: Prepare Homebrew update branch
working-directory: homebrew-elio
env:
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
shell: bash
run: |
set -euo pipefail
git switch -c "automation/elio-${RELEASE_VERSION}"
- name: Update formula
working-directory: homebrew-elio
env:
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
shell: bash
run: |
set -euo pipefail
formula="Formula/elio.rb"
source_url="https://github.com/elio-fm/elio/archive/refs/tags/${RELEASE_TAG}.tar.gz"
sha256="$(curl -fsSL "${source_url}" | sha256sum | awk '{print $1}')"
SOURCE_URL="${source_url}" SOURCE_SHA256="${sha256}" python3 - <<'PY'
import os
import re
from pathlib import Path
formula = Path("Formula/elio.rb")
text = formula.read_text()
original = text
source_url = os.environ["SOURCE_URL"]
sha256 = os.environ["SOURCE_SHA256"]
release_version = os.environ["RELEASE_VERSION"]
url_match = re.search(r'^ url "https://github\.com/elio-fm/elio/archive/refs/tags/v([^"]+)\.tar\.gz"$', text, re.MULTILINE)
current_version = url_match.group(1) if url_match else None
new_version = current_version != release_version
revision_match = re.search(r'^ revision ([0-9]+)$', text, re.MULTILINE)
current_revision = int(revision_match.group(1)) if revision_match else 0
def strip_bottle_block(value):
return re.sub(r'\n+ bottle do\n(?: .*\n)* end\n+', '\n\n', value, count=1)
text = strip_bottle_block(text)
original_without_bottle = strip_bottle_block(original)
text = re.sub(r'^ url ".*"$', f' url "{source_url}"', text, count=1, flags=re.MULTILINE)
text = re.sub(r'^ sha256 ".*"$', f' sha256 "{sha256}"', text, count=1, flags=re.MULTILINE)
old = ''' test do
assert_predicate bin/"elio", :executable?
end
'''
new = ''' test do
assert_match "elio #{version}", shell_output("#{bin}/elio --version")
end
'''
if old in text:
text = text.replace(old, new)
text_without_revision = re.sub(r'^ revision [0-9]+\n', '', text, flags=re.MULTILINE)
if new_version:
text = text_without_revision
elif text_without_revision != re.sub(r'^ revision [0-9]+\n', '', original_without_bottle, flags=re.MULTILINE):
next_revision = current_revision + 1
if revision_match:
text = re.sub(r'^ revision [0-9]+$', f' revision {next_revision}', text, count=1, flags=re.MULTILINE)
else:
text = re.sub(r'^( sha256 "[^"]+"\n)', rf'\1 revision {next_revision}\n', text, count=1, flags=re.MULTILINE)
else:
text = original
formula.write_text(text)
PY
- name: Commit, push, and open Homebrew PR
working-directory: homebrew-elio
env:
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
shell: bash
run: |
set -euo pipefail
branch="automation/elio-${RELEASE_VERSION}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/elio.rb
if git diff --cached --quiet; then
echo "Homebrew formula is already up to date"
exit 0
fi
git commit -m "Update elio to ${RELEASE_VERSION}"
auth_header="$(printf 'x-access-token:%s' "${HOMEBREW_TAP_TOKEN}" | base64 | tr -d '\n')"
echo "::add-mask::${auth_header}"
git -c "http.https://github.com/.extraheader=AUTHORIZATION: Basic ${auth_header}" \
push --force-with-lease origin "${branch}"
pr_body="$(cat <<EOF
Automated Homebrew formula update for elio ${RELEASE_VERSION}.
This is a trusted internal release PR. The tap bottle workflow will build and publish bottles after CI passes.
EOF
)"
pr_number="$(gh pr list \
--repo "${HOMEBREW_TAP_REPO}" \
--head "${branch}" \
--state open \
--json number \
--jq '.[0].number // empty')"
if [[ -n "${pr_number}" ]]; then
gh pr edit "${pr_number}" \
--repo "${HOMEBREW_TAP_REPO}" \
--title "Update elio to ${RELEASE_VERSION}" \
--body "${pr_body}"
echo "Updated Homebrew PR #${pr_number}"
else
gh pr create \
--repo "${HOMEBREW_TAP_REPO}" \
--base main \
--head "${branch}" \
--title "Update elio to ${RELEASE_VERSION}" \
--body "${pr_body}"
fi