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