363 lines
15 KiB
YAML
363 lines
15 KiB
YAML
name: Release
|
|
|
|
# Distribution Stage 1 — binaries on tag. See
|
|
# docs/distribution-stage1-framing.md.
|
|
#
|
|
# Scope is deliberately one stage: build the two shipped binaries when a
|
|
# `v*` tag is pushed and attach them to a GitHub Release. There are no
|
|
# channels, no rollback, no update-in-place, no signing. Those are named
|
|
# as out of scope in the framing's §5 rather than forgotten.
|
|
#
|
|
# Nothing here runs on a branch push or a pull request. A tag is the
|
|
# only trigger, because a release built from anything else would publish
|
|
# artifacts for a commit nobody reviewed as a release.
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
|
|
# A release must never race itself. Two tags pushed close together would
|
|
# otherwise both mutate the same release page.
|
|
concurrency:
|
|
group: release-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
CARGO_TERM_COLOR: always
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
jobs:
|
|
# ---------------------------------------------------------------
|
|
# Preflight runs BEFORE any build, because both of its checks catch
|
|
# mistakes that are cheap now and expensive once artifacts exist: a
|
|
# published release is a public URL, and unpublishing one is not the
|
|
# same as never having published it.
|
|
# ---------------------------------------------------------------
|
|
preflight:
|
|
name: Preflight (tag/version, ancestry)
|
|
runs-on: ubuntu-22.04
|
|
timeout-minutes: 10
|
|
outputs:
|
|
version: ${{ steps.check.outputs.version }}
|
|
prerelease: ${{ steps.check.outputs.prerelease }}
|
|
steps:
|
|
# Full history: the ancestry check below cannot run on a shallow
|
|
# clone, and the default checkout depth is 1.
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- id: check
|
|
name: Tag must match the root crate version, and be on main
|
|
run: |
|
|
set -euo pipefail
|
|
tag="${GITHUB_REF_NAME}"
|
|
|
|
# Strip the leading v, then strip any prerelease suffix:
|
|
# v1.1.0-rc.1 and v1.1.0 must both match a crate version of
|
|
# 1.1.0. Cargo has no place to record "rc.1", so requiring the
|
|
# manifest to carry it would make prereleases impossible.
|
|
version="${tag#v}"
|
|
base="${version%%-*}"
|
|
|
|
# Read the ROOT package version specifically. A grep for
|
|
# `^version` across the workspace would match whichever
|
|
# manifest sorted first, and pmacs-protocol deliberately holds
|
|
# a different number.
|
|
manifest=$(cargo metadata --no-deps --format-version 1 \
|
|
| python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"]=="pmacs"))')
|
|
|
|
echo "tag=$tag base=$base manifest=$manifest"
|
|
if [ "$base" != "$manifest" ]; then
|
|
echo "::error::tag $tag implies version $base but the pmacs crate is $manifest." \
|
|
"Bump Cargo.toml or fix the tag; publishing this would ship a binary whose" \
|
|
"--version disagrees with its release."
|
|
exit 1
|
|
fi
|
|
|
|
# A tag on a branch is the realistic mistake. Re-running the
|
|
# test suite here would be duplicated cost -- CI already tested
|
|
# this commit -- but nothing otherwise enforces that the tagged
|
|
# commit is one CI ever saw on main.
|
|
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
|
|
echo "::error::tagged commit $GITHUB_SHA is not an ancestor of origin/main." \
|
|
"Releases are cut from main."
|
|
exit 1
|
|
fi
|
|
|
|
# Anything with a suffix (-rc.1, -beta) is a prerelease. The
|
|
# framing requires the RC to be marked so it cannot be mistaken
|
|
# for the real thing on the releases page.
|
|
if [ "$version" = "$base" ]; then
|
|
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
|
|
# ---------------------------------------------------------------
|
|
# Both runners are PINNED, never `-latest`. Two different reasons,
|
|
# both about a silent choice:
|
|
#
|
|
# * ubuntu-22.04 sets the glibc floor at 2.35, which covers Ubuntu
|
|
# 22.04 and Debian 12. `ubuntu-latest` (24.04, glibc 2.39) would
|
|
# silently produce binaries that fail to load on both with a bare
|
|
# `GLIBC_2.39 not found`. Note the floor does NOT reach RHEL 9
|
|
# (glibc 2.34) -- that needs a container or cross-build and is
|
|
# parked in the framing's §5.
|
|
#
|
|
# * macos-15 is arm64. `macos-latest` drifts, so a future runner
|
|
# change could move the minimum supported macOS with no commit in
|
|
# this repository to point at.
|
|
# ---------------------------------------------------------------
|
|
build:
|
|
name: Build ${{ matrix.target-label }}
|
|
needs: preflight
|
|
runs-on: ${{ matrix.os }}
|
|
timeout-minutes: 45
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- os: ubuntu-22.04
|
|
target-label: linux-x86_64
|
|
- os: macos-15
|
|
target-label: macos-arm64
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
- uses: Swatinem/rust-cache@v2
|
|
|
|
# CRDT is not optional. `run_gpu` refuses outright without it
|
|
# ("--gpu requires pmacs built with --features crdt"), and
|
|
# InstanceCapabilities::default advertises multi_frontend /
|
|
# crdt_replica / semantic_render only under the feature. A
|
|
# non-CRDT release would ship an editor that cannot use the GPU
|
|
# frontend shipped beside it.
|
|
#
|
|
# EXPLICIT --bin TARGETS, layer 1 of 2. Cargo auto-discovers
|
|
# src/bin/*.rs, so a plain `--workspace` release build also
|
|
# produces pmacs-audit, pmacs_fake_lsp and pmacs_fake_mcp -- the
|
|
# last two being test fixtures. Naming targets keeps them from
|
|
# being built at all.
|
|
- name: Build the shipped binaries only
|
|
run: |
|
|
set -euo pipefail
|
|
cargo build --release --bin pmacs --features crdt
|
|
cargo build --release -p pmacs-gpu
|
|
|
|
# EXPLICIT STAGED ASSET LIST, layer 2 of 2. Layer 1 alone is not
|
|
# enough: a cached target/release can still hold binaries from an
|
|
# earlier build, and archiving that directory would publish them.
|
|
# Copying named files into a clean staging dir makes the archive's
|
|
# contents a decision rather than a directory's residue.
|
|
#
|
|
# The two binaries are staged into the SAME directory on purpose.
|
|
# `pmacs --gpu` prefers a co-located pmacs-gpu and only then falls
|
|
# back to a PATH lookup, so co-location is what makes an unpacked
|
|
# release self-contained for someone who unpacks it off PATH.
|
|
- name: Stage the archive
|
|
id: stage
|
|
run: |
|
|
set -euo pipefail
|
|
name="pmacs-${{ needs.preflight.outputs.version }}-${{ matrix.target-label }}"
|
|
mkdir -p "staging/$name"
|
|
cp target/release/pmacs "staging/$name/"
|
|
cp target/release/pmacs-gpu "staging/$name/"
|
|
cp README.md LICENSE* "staging/$name/" 2>/dev/null || true
|
|
chmod +x "staging/$name/pmacs" "staging/$name/pmacs-gpu"
|
|
tar -C staging -czf "$name.tar.gz" "$name"
|
|
echo "archive=$name.tar.gz" >> "$GITHUB_OUTPUT"
|
|
echo "name=$name" >> "$GITHUB_OUTPUT"
|
|
|
|
# Assert the archive rather than the build command. The build
|
|
# could change, a target could be added, a cache could leak -- the
|
|
# only thing that decides what users receive is what is inside
|
|
# this file.
|
|
- name: Assert archive contents
|
|
run: |
|
|
set -euo pipefail
|
|
archive="${{ steps.stage.outputs.archive }}"
|
|
name="${{ steps.stage.outputs.name }}"
|
|
|
|
members=$(tar -tzf "$archive" | sed "s#^$name/##" | grep -v '^$' | sort)
|
|
echo "members:"; echo "$members" | sed 's/^/ /'
|
|
|
|
for forbidden in pmacs-audit pmacs_fake_lsp pmacs_fake_mcp; do
|
|
if echo "$members" | grep -qx "$forbidden"; then
|
|
echo "::error::$forbidden is present in $archive and must never ship"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
for required in pmacs pmacs-gpu; do
|
|
echo "$members" | grep -qx "$required" \
|
|
|| { echo "::error::$required missing from $archive"; exit 1; }
|
|
done
|
|
|
|
# Executable bits, read back out of the archive itself.
|
|
for required in pmacs pmacs-gpu; do
|
|
mode=$(tar -tvzf "$archive" | awk -v f="$name/$required" '$NF==f {print $1}')
|
|
case "$mode" in
|
|
*x*) : ;;
|
|
*) echo "::error::$required is not executable in the archive (mode $mode)"; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
# Run the staged binaries. `--version` is the cheapest end-to-end
|
|
# proof that what was built is what will ship, and it is the check
|
|
# that would have caught a tag/manifest mismatch reaching this far.
|
|
- name: Assert the staged binaries report the tagged version
|
|
run: |
|
|
set -euo pipefail
|
|
expected="${{ needs.preflight.outputs.version }}"
|
|
base="${expected%%-*}"
|
|
name="${{ steps.stage.outputs.name }}"
|
|
|
|
got_pmacs=$("staging/$name/pmacs" --version)
|
|
got_gpu=$("staging/$name/pmacs-gpu" --version)
|
|
echo "pmacs: $got_pmacs"
|
|
echo "pmacs-gpu: $got_gpu"
|
|
|
|
echo "$got_pmacs" | grep -qx "pmacs $base" \
|
|
|| { echo "::error::pmacs --version reported '$got_pmacs', expected 'pmacs $base'"; exit 1; }
|
|
echo "$got_gpu" | grep -q "^pmacs-gpu $base " \
|
|
|| { echo "::error::pmacs-gpu --version reported '$got_gpu', expected 'pmacs-gpu $base ...'"; exit 1; }
|
|
|
|
# The glibc floor, MACHINE-CHECKED rather than trusted.
|
|
#
|
|
# Pinning ubuntu-22.04 is what sets the floor, but nothing about a
|
|
# pinned runner *proves* the resulting binary honours it, and the
|
|
# failure is invisible at build time -- it surfaces as a bare
|
|
# `GLIBC_2.xx not found` on a user's machine, with no clue which
|
|
# commit caused it. Reading the required symbol versions out of
|
|
# the binary turns a runner choice into an assertion, so switching
|
|
# this job to `ubuntu-latest` fails HERE instead of shipping.
|
|
#
|
|
# Linux only: Mach-O has no equivalent versioned-symbol scheme.
|
|
- name: Assert the glibc floor
|
|
if: runner.os == 'Linux'
|
|
run: |
|
|
set -euo pipefail
|
|
floor="2.35" # Ubuntu 22.04 / Debian 12; see the framing §1.6
|
|
name="${{ steps.stage.outputs.name }}"
|
|
fail=0
|
|
for bin in pmacs pmacs-gpu; do
|
|
max=$(objdump -T "staging/$name/$bin" \
|
|
| grep -oE 'GLIBC_[0-9]+\.[0-9]+' \
|
|
| sed 's/GLIBC_//' | sort -uV | tail -1)
|
|
echo "$bin requires at most glibc $max (floor $floor)"
|
|
highest=$(printf '%s\n%s\n' "$floor" "$max" | sort -V | tail -1)
|
|
if [ "$highest" != "$floor" ]; then
|
|
echo "::error::$bin requires glibc $max, above the declared floor $floor." \
|
|
"It will not load on Ubuntu 22.04 or Debian 12. Check the runner image."
|
|
fail=1
|
|
fi
|
|
done
|
|
[ "$fail" -eq 0 ]
|
|
|
|
- name: Checksum
|
|
run: |
|
|
set -euo pipefail
|
|
archive="${{ steps.stage.outputs.archive }}"
|
|
if command -v sha256sum >/dev/null; then
|
|
sha256sum "$archive" > "$archive.sha256"
|
|
else
|
|
shasum -a 256 "$archive" > "$archive.sha256"
|
|
fi
|
|
cat "$archive.sha256"
|
|
|
|
- uses: actions/upload-artifact@v4
|
|
with:
|
|
name: ${{ steps.stage.outputs.name }}
|
|
path: |
|
|
${{ steps.stage.outputs.archive }}
|
|
${{ steps.stage.outputs.archive }}.sha256
|
|
if-no-files-found: error
|
|
|
|
publish:
|
|
name: Publish release
|
|
needs: [preflight, build]
|
|
runs-on: ubuntu-22.04
|
|
timeout-minutes: 15
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/download-artifact@v4
|
|
with:
|
|
path: dist
|
|
merge-multiple: true
|
|
|
|
# One SHA256SUMS covering every published artifact, alongside the
|
|
# per-archive files. Without it a download cannot be verified by
|
|
# anyone; with it, verification is one command.
|
|
- name: Collect checksums
|
|
run: |
|
|
set -euo pipefail
|
|
cd dist
|
|
ls -la
|
|
cat ./*.sha256 | sed 's#\./##' | sort -k2 > SHA256SUMS
|
|
echo "--- SHA256SUMS ---"; cat SHA256SUMS
|
|
sha256sum -c SHA256SUMS
|
|
|
|
- name: Release notes
|
|
run: |
|
|
set -euo pipefail
|
|
cat > notes.md <<'NOTES'
|
|
## Install
|
|
|
|
Download the archive for your platform, unpack it, and put both
|
|
binaries somewhere on your `PATH` — **keep them together**:
|
|
`pmacs --gpu` looks for `pmacs-gpu` beside itself first, then
|
|
falls back to `PATH`.
|
|
|
|
Verify a download against `SHA256SUMS`:
|
|
|
|
```sh
|
|
sha256sum -c SHA256SUMS --ignore-missing
|
|
```
|
|
|
|
## Platform support
|
|
|
|
- **Linux x86_64** — built on Ubuntu 22.04, so the floor is
|
|
**glibc ≥ 2.35**. Covers Ubuntu 22.04+ and Debian 12+.
|
|
**RHEL 9 (glibc 2.34) is below the floor and not supported
|
|
yet.**
|
|
- **macOS arm64 (Apple Silicon)** — Intel macOS is not built
|
|
yet. The binaries are **unsigned and not notarized**, so
|
|
Gatekeeper will quarantine them; you will need to allow them
|
|
explicitly.
|
|
|
|
## Runtime dependencies
|
|
|
|
Not checked at startup — the editor assumes them:
|
|
`/bin/sh`, `stty` and coreutils (PTY raw-mode trampoline, used
|
|
by the REPL and terminal), plus `git` and `tar` for package
|
|
installation. The Lua VM is statically vendored, so there is no
|
|
external Lua dependency.
|
|
|
|
`pmacs-gpu` additionally needs a working Vulkan (Linux) or
|
|
Metal (macOS) adapter. There is no software-rasterizer
|
|
fallback in a shipped binary.
|
|
|
|
## Not in this release
|
|
|
|
Channels, in-place update, rollback, signing, reproducible
|
|
builds, package-manager distribution, and Windows. See
|
|
`docs/distribution-stage1-framing.md` §5.
|
|
NOTES
|
|
echo "notes written"
|
|
|
|
- name: Publish
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
flags=(--title "pmacs ${{ needs.preflight.outputs.version }}" --notes-file notes.md)
|
|
if [ "${{ needs.preflight.outputs.prerelease }}" = "true" ]; then
|
|
flags+=(--prerelease)
|
|
fi
|
|
gh release create "${GITHUB_REF_NAME}" "${flags[@]}" \
|
|
dist/*.tar.gz dist/SHA256SUMS
|