fluffos/.github/workflows/release.yml
Claude 3bdbe9676e www: real ws backpressure fix, teardown leak fix, src/www docs, websocket smoke test in CI
The lws output wedge addressed in the previous commit was incompletely
understood: under genuine backpressure (peer slow or paused, kernel
send buffer full) a connection still froze permanently. Root cause,
established by tracing the writeable-request plumbing end to end:
lws_send_pipe_choked() is true not only when lws holds a truncated
send (lws re-arms the writeable callback itself then) but also when a
zero-timeout poll(POLLOUT) reports the socket simply full -- and in
that case every lws_write() has fully succeeded, lws has nothing
pending, and nobody re-arms anything. Fix, per lws README.coding.md:
whenever the drain loop exits with data still queued in pss->buffer,
request the next writeable callback. Queued data always has a callback
requested, so no exit path can strand output.

Also:
- LWS_CALLBACK_CLOSED frees the session evbuffer unconditionally: on
  driver-initiated closes (e.g. the mudlib destructing the interactive)
  close_user_websocket() nulls pss->user first, and the old early
  return leaked the buffer every time.
- src/www/README.md + src/www/AGENTS.md: architecture doc and agent
  checklist for the web terminal pages (xterm.js/telnet.js layering,
  vendor policy, packaging, testing, the wedge mechanism).
- tools/ws-smoke.js, wired into CI on the Clang Debug matrix entries
  (with and without sanitizers): a dependency-free node websocket
  client that boots the real driver and exercises the http mount,
  telnet + ascii subprotocols through the shared src/www/telnet.js,
  SGA char-mode switching, live TUI streaming, TLS, and -- the actual
  regression gate -- forced-backpressure bursts (paused socket, ~4.8MB)
  on the plain and TLS ports plus a destruct-while-choked teardown
  check. All three backpressure checks fail on the unfixed driver;
  neither GTest nor the LPC suite exercises any websocket client
  traffic.
- Fix stale src/www/wasm/vendor/ path references left from the vendor
  directory move (src/wasm/README.md, docs/build-wasm.md, release.yml).

Validated on the ASan Debug build: forced-backpressure repros recover
the full burst on both subprotocols, destruct-while-choked clean under
ASan, ws-smoke 17/17, GTest 312/312, LPC testsuite clean.
2026-07-14 09:53:26 -05:00

488 lines
17 KiB
YAML

name: Release
on:
workflow_dispatch:
inputs:
prerelease:
description: 'Mark as pre-release'
required: false
type: boolean
default: false
schedule:
# Monthly auto-release: 09:00 UTC on the 1st. Skipped automatically
# when master has no new commits since the latest release tag
# (see the check-changes job).
- cron: '0 9 1 * *'
push:
# Pushing a version tag also cuts a release for that tag; pushing a
# release/vX branch cuts a release named vX (the workflow creates the
# tag and deletes the trigger branch) -- useful for tooling that can
# push refs but lacks the actions:write scope to dispatch workflows.
tags:
- 'v*'
branches:
- 'release/v*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# Per-platform build configuration lives in the composite actions under
# .github/actions/, shared with the CI workflow.
permissions:
contents: write
packages: write
jobs:
check-changes:
name: Check for Changes Since Last Release
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.check.outputs.should_release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for changes
id: check
run: |
git fetch --tags
# Manual dispatch and tag pushes always release; only the
# monthly schedule is gated on changes
if [ "${{ github.event_name }}" != "schedule" ]; then
echo "${{ github.event_name }} event: releasing"
echo "should_release=true" >> $GITHUB_OUTPUT
exit 0
fi
# Most recent release tag by creation date (tag formats have
# varied over the years, so date order beats version sort)
LAST_TAG=$(git tag -l 'v*' --sort=-creatordate | head -n 1)
if [ -z "$LAST_TAG" ]; then
echo "No release tag found: releasing"
echo "should_release=true" >> $GITHUB_OUTPUT
exit 0
fi
COUNT=$(git rev-list --count "$LAST_TAG"..HEAD)
echo "$COUNT commit(s) since $LAST_TAG"
if [ "$COUNT" -gt 0 ]; then
echo "should_release=true" >> $GITHUB_OUTPUT
else
echo "No changes since $LAST_TAG: skipping release"
echo "should_release=false" >> $GITHUB_OUTPUT
fi
generate-version:
name: Generate Version Number
needs: check-changes
if: needs.check-changes.outputs.should_release == 'true'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.generate.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate Version
id: generate
run: |
# A pushed tag (or release/vX trigger branch) IS the version
if [ "${{ github.event_name }}" = "push" ]; then
VERSION="${GITHUB_REF_NAME#release/}"
echo "Using pushed ref version: ${VERSION}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
exit 0
fi
# Get current date in YYYY.MMDD format
DATE_VERSION=$(date -u '+%Y.%m%d')
# Fetch all tags
git fetch --tags
# Find existing tags for today's date
EXISTING_TAGS=$(git tag -l "v${DATE_VERSION}.*" | sort -V)
# Find the highest increment for today
if [ -z "$EXISTING_TAGS" ]; then
INCREMENT=0
else
LAST_TAG=$(echo "$EXISTING_TAGS" | tail -n 1)
LAST_INCREMENT=$(echo "$LAST_TAG" | sed -E "s/v${DATE_VERSION}\.([0-9]+)/\1/")
INCREMENT=$((LAST_INCREMENT + 1))
fi
# Generate new version
VERSION="v${DATE_VERSION}.${INCREMENT}"
echo "Generated version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
create-release:
name: Create Release
needs: generate-version
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
release_id: ${{ steps.create_release.outputs.id }}
version: ${{ needs.generate-version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create Git Tag
# A pushed tag already exists; every other trigger tags HEAD here
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
run: |
VERSION="${{ needs.generate-version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$VERSION" -m "Release $VERSION"
git push origin "$VERSION"
# A release/vX trigger branch has done its job once the tag exists
case "${GITHUB_REF}" in
refs/heads/release/*)
git push origin --delete "${GITHUB_REF_NAME}" || true
;;
esac
- name: Create Release
id: create_release
uses: actions/github-script@v7
with:
script: |
const version = '${{ needs.generate-version.outputs.version }}';
const release = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: version,
name: `FluffOS ${version}`,
generate_release_notes: true,
prerelease: ${{ inputs.prerelease || false }},
draft: false
});
core.setOutput('upload_url', release.data.upload_url);
core.setOutput('id', release.data.id);
return release.data.id;
# Windows binary. The build+test is the same build-windows composite the
# CI `windows` job runs; this job adds packaging + release upload.
build-windows:
name: Build Windows Binary
needs: create-release
runs-on: windows-latest
defaults:
run:
shell: msys2 {0}
steps:
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
update: true
- uses: actions/checkout@v4
with:
ref: ${{ needs.create-release.outputs.version }}
- name: Build & test
uses: ./.github/actions/build-windows
with:
build-type: Release
db-sqlite: '1'
extra-packages: zip # for packaging the release archive below
- name: Package
run: |
ASSET_NAME="fluffos-${{ needs.create-release.outputs.version }}-windows-x86_64.zip"
cd build/bin && zip -r "../../${ASSET_NAME}" .
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ./fluffos-${{ needs.create-release.outputs.version }}-windows-x86_64.zip
asset_name: fluffos-${{ needs.create-release.outputs.version }}-windows-x86_64.zip
asset_content_type: application/zip
# Linux static binary. The build+test is the same build-alpine-static
# composite the CI `alpine-static` job runs; this job adds packaging +
# release upload.
build-linux:
name: Build Linux Binary
needs: create-release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.create-release.outputs.version }}
- name: Build & test
uses: ./.github/actions/build-alpine-static
with:
build-type: Release
- name: Package
run: |
ASSET_NAME="fluffos-${{ needs.create-release.outputs.version }}-linux-x86_64-static.tar.gz"
cd build/bin && tar czf "../../${ASSET_NAME}" .
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ./fluffos-${{ needs.create-release.outputs.version }}-linux-x86_64-static.tar.gz
asset_name: fluffos-${{ needs.create-release.outputs.version }}-linux-x86_64-static.tar.gz
asset_content_type: application/gzip
# WebAssembly driver: cross-compile with the pinned emsdk (see
# .github/actions/build-wasm/action.yml), gate on the LPC testsuite
# running inside the wasm driver under node, then ship driver + web
# terminal + mudlib packer as a self-contained zip. See docs/build-wasm.md
# for the workflow this asset supports.
build-wasm:
name: Build WASM Binary
needs: create-release
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.create-release.outputs.version }}
# Same build-wasm composite the CI `wasm` job runs; this job adds
# packaging + release upload.
- name: Build & test
uses: ./.github/actions/build-wasm
- name: Package
run: |
VERSION="${{ needs.create-release.outputs.version }}"
ASSET_NAME="fluffos-${VERSION}-wasm.zip"
STAGE=$(mktemp -d)
cp build-wasm/src/fluffos.js build-wasm/src/fluffos.wasm "$STAGE/"
cp src/www/wasm/index.html "$STAGE/"
cp src/www/telnet.js "$STAGE/"
cp -r src/www/vendor "$STAGE/"
cp tools/wasm/pack-mudlib.sh "$STAGE/"
cat > "$STAGE/README.md" << EOF
# FluffOS ${VERSION} -- WebAssembly driver
The full FluffOS driver compiled to WebAssembly: it runs your
mudlib inside a browser tab and speaks telnet with the page.
Contents:
- fluffos.js / fluffos.wasm -- the driver
- index.html -- the web terminal (xterm.js under vendor/,
telnet client in telnet.js)
- pack-mudlib.sh -- bundles YOUR mudlib with this driver into a
static site (requires emscripten's file_packager, i.e. emsdk on PATH)
Quick start:
./pack-mudlib.sh --mudlib /path/to/mylib --config etc/config
python3 -m http.server -d dist 8080
Docs: https://www.fluffos.info/build-wasm
Cookbook (packer + JsBridge): https://www.fluffos.info/driver/wasm
EOF
(cd "$STAGE" && zip -r - .) > "$ASSET_NAME"
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ./fluffos-${{ needs.create-release.outputs.version }}-wasm.zip
asset_name: fluffos-${{ needs.create-release.outputs.version }}-wasm.zip
asset_content_type: application/zip
build-docker:
name: Build and Push Docker Image
needs: create-release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.create-release.outputs.version }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},value=${{ needs.create-release.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.create-release.outputs.version }}
type=semver,pattern={{major}},value=${{ needs.create-release.outputs.version }}
type=raw,value=${{ needs.create-release.outputs.version }},enable=true
type=raw,value=latest,enable=${{ !inputs.prerelease }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
finalize-release:
name: Finalize Release
needs: [create-release, build-windows, build-linux, build-wasm, build-docker]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.create-release.outputs.version }}
- name: Generate Release Documentation
id: docs
run: |
VERSION="${{ needs.create-release.outputs.version }}"
cat > release-notes.md << EOF
## Release Assets
This release includes the following pre-built distributions:
### Binary Distributions
- **Windows x86_64**: \`fluffos-${VERSION}-windows-x86_64.zip\`
- Built with MSYS2/MinGW64
- Complete distribution with all tools and libraries
- SQLite support enabled
- **Linux x86_64 (Static)**: \`fluffos-${VERSION}-linux-x86_64-static.tar.gz\`
- Statically linked binaries (no external dependencies)
- Built on Alpine Linux
- Complete distribution with all tools and libraries
- Portable across most Linux distributions
- **WebAssembly**: \`fluffos-${VERSION}-wasm.zip\`
- The driver compiled to WebAssembly: runs a full mudlib in a
browser tab (the page is the telnet client)
- Contains \`fluffos.js\`/\`fluffos.wasm\`, a self-contained web
terminal (\`index.html\`), and \`pack-mudlib.sh\` to bundle your
mudlib into a static site
- Validated by running the LPC testsuite inside the wasm driver
- See the [WebAssembly build guide](https://www.fluffos.info/build-wasm)
and the [WASM driver cookbook](https://www.fluffos.info/driver/wasm)
### What's Included
Each distribution contains:
- **driver** - Main FluffOS driver executable
- **lpcc** - LPC compiler
- **symbol** - Symbol table utility
- **o2json/json2o** - Object file converters
- **portbind** - Port binding helper (Linux only)
- **std/** - LPC standard library
- **include/** - LPC header files
- **www/** - WebSocket client files
- **keywords.json** - Language keywords database
### Docker Images
Docker images are available at \`ghcr.io/${{ github.repository }}\`:
- \`${VERSION}\` - This release version
${{ !inputs.prerelease && '- `latest` - Latest stable release' || '' }}
Pull with:
\`\`\`bash
docker pull ghcr.io/${{ github.repository }}:${VERSION}
\`\`\`
## Installation
### Windows
\`\`\`bash
# Extract distribution
unzip fluffos-${VERSION}-windows-x86_64.zip -d fluffos
cd fluffos
# Run driver with your config
./driver.exe /path/to/mudlib/config.cfg
\`\`\`
### Linux
\`\`\`bash
# Extract distribution
mkdir fluffos && cd fluffos
tar xzf ../fluffos-${VERSION}-linux-x86_64-static.tar.gz
# Run driver with your config
./driver /path/to/mudlib/config.cfg
\`\`\`
### Docker
\`\`\`bash
docker run -v /path/to/mudlib:/mudlib ghcr.io/${{ github.repository }}:${VERSION} /mudlib/config.cfg
\`\`\`
### WebAssembly (browser)
\`\`\`bash
# Bundle your mudlib with the wasm driver into a static site
# (needs emscripten's file_packager, i.e. emsdk on PATH)
unzip fluffos-${VERSION}-wasm.zip -d fluffos-wasm
cd fluffos-wasm
./pack-mudlib.sh --mudlib /path/to/mudlib --config etc/config
python3 -m http.server -d dist 8080 # then open http://localhost:8080/
\`\`\`
---
EOF
echo "RELEASE_NOTES<<EOF" >> $GITHUB_OUTPUT
cat release-notes.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Update Release Notes
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const release = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: ${{ needs.create-release.outputs.release_id }}
});
const additionalNotes = fs.readFileSync('release-notes.md', 'utf8');
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: ${{ needs.create-release.outputs.release_id }},
body: release.data.body + '\n\n' + additionalNotes
});
- name: Release Summary
run: |
VERSION="${{ needs.create-release.outputs.version }}"
cat << EOF
✅ Release ${VERSION} created successfully!
📦 Windows binary: fluffos-${VERSION}-windows-x86_64.zip
📦 Linux static binary: fluffos-${VERSION}-linux-x86_64-static.tar.gz
📦 WebAssembly driver + packer: fluffos-${VERSION}-wasm.zip
🐳 Docker image: ghcr.io/${{ github.repository }}:${VERSION}
🔗 View release: https://github.com/${{ github.repository }}/releases/tag/${VERSION}
EOF