Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,12 @@ review evidence. Custom output and cache locations need their own cleanup.

## Pull requests

Apply the `no-preview` label before pushing a revision that needs CI validation
while publication awaits approval. CI still builds and audits the artifact and
runs the publisher dry run; the label holds the preview publishing job.
The guard reads the triggering event's labels. Adding the label later does
not stop an existing run.

Keep commits focused on one topic. Explain the problem, resulting behavior,
and verification, including failures or skipped checks. Keep unrelated
cleanup in separate work.
Expand Down
81 changes: 79 additions & 2 deletions .github/workflows/port-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,27 @@ jobs:
if: matrix.port == 'elixir'
run: python3 docs/scripts/read-elixir-toolchain.py port

- name: Read selected Erlang toolchain
id: erlang-runtime
if: matrix.port == 'erlang'
run: |
python3 - <<'PY'
import os, pathlib, re
entries = [line.split() for line in pathlib.Path('port/.tool-versions').read_text().splitlines()]
versions = {}
for tool, supported in [('erlang', '29.1.2'), ('rebar', '3.28.0')]:
selected = [entry for entry in entries if entry and entry[0] == tool]
if len(selected) != 1 or len(selected[0]) != 2 or not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', selected[0][1]):
raise ValueError(f'Erlang documentation requires one exact {tool} version')
if selected[0][1] != supported:
raise ValueError(f'Unsupported Erlang documentation {tool} version: {selected[0][1]}')
versions[tool] = selected[0][1]
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f"otp-version={versions['erlang']}\nrebar3-version={versions['rebar']}\n")
PY

- name: Install native system dependencies
if: steps.mcp.outputs.required == 'true' || matrix.port == 'cxx'
if: steps.mcp.outputs.required == 'true' || matrix.port == 'cxx' || matrix.port == 'erlang'
env:
PORT: ${{ matrix.port }}
run: |
Expand All @@ -326,6 +345,9 @@ jobs:
if [[ "$PORT" == julia ]]; then
packages=(build-essential pkg-config libevent-dev libncurses-dev bison)
fi
if [[ "$PORT" == erlang ]]; then
packages=(build-essential)
fi
if [[ "$PORT" == cxx ]]; then
packages+=(clang-18 libc++-18-dev libc++abi-18-dev ninja-build)
fi
Expand All @@ -341,7 +363,7 @@ jobs:
bin="$stage/tmux/3.7c/bin"
"$bin/tmux" -V
printf '%s\n' "$bin" >> "$GITHUB_PATH"
else
elif [[ "$PORT" != erlang ]]; then
tmux -V
fi

Expand Down Expand Up @@ -497,6 +519,61 @@ jobs:
mix deps.get --only prod
mix run --no-start "$EXPORTER" _build/docs/api.json

- name: Install selected Erlang runtime
if: matrix.port == 'erlang'
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
with:
otp-version: ${{ steps.erlang-runtime.outputs.otp-version }}
version-type: strict
install-hex: false
install-rebar: false

- name: Install pinned Erlang Rebar3
if: matrix.port == 'erlang'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REBAR3_VERSION: ${{ steps.erlang-runtime.outputs.rebar3-version }}
run: |
set -euo pipefail
case "$REBAR3_VERSION" in
3.28.0)
rebar_asset=664140441
rebar_sha=5a28b1cca082d3b023ec0f5df9d9f9d52557036c6f2a21c1ffa11b93a984dd42
;;
*) echo "::error::Unsupported pinned Rebar3 version: $REBAR3_VERSION" >&2; exit 1 ;;
esac
rebar_dir=$(mktemp -d "$RUNNER_TEMP/libtmux-erlang-rebar3.XXXXXXXX")
rebar_ready=false
cleanup_rebar() {
if [[ "$rebar_ready" != true ]]; then
rm -rf -- "$rebar_dir"
fi
}
trap cleanup_rebar EXIT
gh api -H 'Accept: application/octet-stream' \
"repos/erlang/rebar3/releases/assets/$rebar_asset" > "$rebar_dir/rebar3"
printf '%s %s\n' "$rebar_sha" "$rebar_dir/rebar3" | sha256sum --check --strict
chmod 755 "$rebar_dir/rebar3"
rebar_version=$(REBAR_GLOBAL_CONFIG_DIR="$RUNNER_TEMP/rebar3-empty-config" \
REBAR_CONFIG="$RUNNER_TEMP/rebar3-empty.config" "$rebar_dir/rebar3" version)
printf '%s\n' "$rebar_version"
[[ "$rebar_version" == "rebar $REBAR3_VERSION "* ]] || {
echo "::error::Expected Rebar3 $REBAR3_VERSION, got $rebar_version" >&2
exit 1
}
printf '%s\n' "$rebar_dir" >> "$GITHUB_PATH"
rebar_ready=true

- name: Export Erlang API from the selected source
if: matrix.port == 'erlang'
working-directory: port
env:
EXPORTER: ${{ github.workspace }}/docs/packages/api-model/scripts/export-erlang.escript
run: |
set -euo pipefail
escript "$EXPORTER" . libtmux_erlang _build/docs/api.json

- if: matrix.port == 'ruby'
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
Expand Down
11 changes: 7 additions & 4 deletions .github/workflows/reusable-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ jobs:
echo "::error::path-prefix '$p' must be an unrooted path using letters, digits, '.', '_', '-' and '/', without '..'" >&2
exit 1
fi
reserved=(py ruby lua powershell julia elixir ts rs go java kotlin scala csharp fsharp cxx swift manifest _shell)
reserved=(py ruby lua powershell julia erlang elixir ts rs go java kotlin scala csharp fsharp cxx swift manifest _shell)
for r in "${reserved[@]}"; do
if [[ "$p" == "$r" ]]; then
echo "::error::path-prefix '$p' is a bare reserved top-level name — qualify it, e.g. '$r/latest'" >&2
Expand All @@ -153,7 +153,7 @@ jobs:
done
if [[ -n "${PORT_IN:-}" ]]; then
case "$PORT_IN" in
py|ruby|lua|powershell|julia|elixir|ts|rs|go|java|kotlin|scala|csharp|fsharp|cxx|swift) ;;
py|ruby|lua|powershell|julia|erlang|elixir|ts|rs|go|java|kotlin|scala|csharp|fsharp|cxx|swift) ;;
*) echo "::error::unknown port '$PORT_IN'" >&2; exit 1 ;;
esac
if [[ ! "$VERSION_IN" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ || "$VERSION_IN" == *..* ]]; then
Expand Down Expand Up @@ -297,7 +297,8 @@ jobs:

# Keep this check inside the job that uploads. A partial rerun can reuse
# upstream job outputs, but must query current PR state on every upload
# attempt. Match the verified artifact source to the tested merge too.
# attempt. Honor current preview opt-outs and match the artifact source
# to the tested merge too.
- name: Require current tested pull request
if: inputs.require-current-pr && inputs.version-kind == 'pr'
env:
Expand All @@ -323,10 +324,12 @@ jobs:
jq -e --slurp --argjson number "$PR" --arg repo "$REPOSITORY" --arg head "$EXPECTED_HEAD" --arg merge "$TESTED_MERGE" '
length == 1 and (.[0] |
type == "object" and .number == $number and .state == "open" and
.draft == false and (.labels | type == "array") and
all(.labels[]; (.name | type) == "string" and (.name | ascii_downcase) != "no-preview") and
.head.repo.full_name == $repo and .head.sha == $head and
.merge_commit_sha == $merge)
' <<< "$response" > /dev/null || {
echo '::error::current PR is not open at the tested source; refusing preview publication' >&2
echo '::error::current PR is ineligible or differs from the tested source; refusing preview publication' >&2
exit 1
}

Expand Down
17 changes: 13 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
echo "powershell=$(jq -r .revision site/src/data/api/powershell.json)" >> "$GITHUB_OUTPUT"
echo "julia=$(jq -r .revision site/src/data/api/julia.json)" >> "$GITHUB_OUTPUT"
echo "elixir=$(jq -r .revision site/src/data/api/elixir.json)" >> "$GITHUB_OUTPUT"
echo "erlang=$(jq -r .revision site/src/data/api/erlang.json)" >> "$GITHUB_OUTPUT"

- uses: actions/checkout@v7
with:
Expand Down Expand Up @@ -89,6 +90,12 @@ jobs:
ref: ${{ steps.integrated-sources.outputs.elixir }}
path: .port-sources/elixir
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: libtmux/libtmux-erlang
ref: ${{ steps.integrated-sources.outputs.erlang }}
path: .port-sources/erlang
persist-credentials: false

# The exporter tests compile a fixture with the package's own toolchain:
# the exact pair the integrated Elixir source pins, read as port-docs.yml
Expand Down Expand Up @@ -155,6 +162,7 @@ jobs:
LIBTMUX_DOCS_CHECKOUT_POWERSHELL: ${{ github.workspace }}/.port-sources/powershell
LIBTMUX_DOCS_CHECKOUT_JULIA: ${{ github.workspace }}/.port-sources/julia
LIBTMUX_DOCS_CHECKOUT_ELIXIR: ${{ github.workspace }}/.port-sources/elixir
LIBTMUX_DOCS_CHECKOUT_ERLANG: ${{ github.workspace }}/.port-sources/erlang
run: |
node scripts/stage-port-docs.mjs --from-source
node scripts/stage-port-docs.mjs --cached --check
Expand All @@ -174,12 +182,12 @@ jobs:
PREVIEW_PREFIX: pr-${{ github.event.pull_request.number }}
LIBTMUX_DOCS_CHECKOUT_RUBY: ${{ github.workspace }}/.port-sources/ruby
LIBTMUX_DOCS_CHECKOUT_LUA: ${{ github.workspace }}/.port-sources/lua
# PowerShell, Julia and Elixir paths stay scoped to guide staging
# PowerShell, Julia, Elixir and Erlang paths stay scoped to guide staging
# above, which checks the cache used by ordinary builds. Native API
# and MCP export freshness remain owning-port checks, not claims of
# this root audit. Elixir's exporter tests run in the unit step; its
# committed model is derived again by port-docs.yml at publication.
LIBTMUX_DOCS_SKIP_NATIVE_MODEL_PORTS: ruby,lua,powershell,julia,elixir
LIBTMUX_DOCS_SKIP_NATIVE_MODEL_PORTS: ruby,lua,powershell,julia,elixir,erlang

- name: Upload audited preview
if: github.event_name == 'pull_request'
Expand Down Expand Up @@ -237,12 +245,13 @@ jobs:
set -euo pipefail
PATH="$PWD/bin:$PATH" bash publish-default-versions.sh

# Forks are audited but never enter an AWS environment or receive secrets.
# Forks and no-preview PRs keep the audit and dry-run without AWS publication.
publish-preview:
needs: [test, check-publish-preview]
if: |
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
github.event.pull_request.head.repo.full_name == github.repository &&
!contains(github.event.pull_request.labels.*.name, 'no-preview')
permissions:
contents: read
id-token: write
Expand Down
60 changes: 60 additions & 0 deletions packages/api-model/src/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3823,12 +3823,72 @@ for (const [id, binding] of Object.entries(ELIXIR_CONCEPTS)) {
concept.symbols.elixir = binding.symbol
if (binding.note) (concept.notes ??= {}).elixir = binding.note
}
const ERLANG_CONCEPTS: Record<string, { symbol: string; note?: string }> = {
server: { symbol: 'tmux_server', note: '`open/1` observes an existing daemon and starts an endpoint owner. Domain rows remain immutable values.' },
session: { symbol: '#tmux_model:session{}' },
window: { symbol: '#tmux_model:window{}', note: 'A physical window can have several session memberships. `window_link` holds the session-relative index and active flag.' },
pane: { symbol: '#tmux_model:pane{}' },
snapshot: { symbol: 'tmux_server:snapshot/1', note: 'Captures a new graph over an observation interval. Retained snapshots never refresh.' },
'server-cmd': { symbol: 'tmux_server:command/3', note: 'Executes an explicit command value with bounded output and separate outcome and cleanup evidence.' },
'list-sessions': { symbol: 'tmux_snapshot:sessions/1', note: 'Returns captured rows without tmux I/O.' },
'list-server-windows': { symbol: 'tmux_snapshot:windows/1', note: 'Returns unique captured physical windows. `window_links/1` lists session memberships.' },
'list-server-panes': { symbol: 'tmux_snapshot:panes/1', note: 'Returns unique captured physical panes without tmux I/O.' },
'list-windows': { symbol: 'tmux_snapshot:related/3', note: 'Pass `windows`, a captured session and its snapshot for a local relationship read.' },
'list-panes': { symbol: 'tmux_snapshot:related/3', note: 'Pass `panes`, a captured physical window and its snapshot for a local relationship read.' },
'capture-pane': { symbol: 'tmux_pane:capture/2' },
'send-keys': { symbol: 'tmux_pane:send_keys/2' },
'split-pane': { symbol: 'tmux_pane:split/2' },
'respawn-pane': { symbol: 'tmux_pane:respawn/2' },
'resize-pane': { symbol: 'tmux_pane:resize/2' },
'kill-pane': { symbol: 'tmux_pane:kill/1' },
'break-pane': { symbol: 'tmux_pane:break/4' },
'join-pane': { symbol: 'tmux_pane:join/3' },
'move-pane': { symbol: 'tmux_pane:move/3' },
'swap-pane': { symbol: 'tmux_pane:swap/3' },
'pipe-pane': { symbol: 'tmux_pane:pipe/3' },
'new-session': { symbol: 'tmux_session:create/2' },
'kill-session': { symbol: 'tmux_session:kill/1' },
'rename-session': { symbol: 'tmux_session:rename/2' },
'new-window': { symbol: 'tmux_window:create/2' },
'kill-window': { symbol: 'tmux_window:kill/1', note: 'Removes the physical window and its memberships.' },
'rename-window': { symbol: 'tmux_window:rename/2' },
'select-layout': { symbol: 'tmux_window:select_layout/2' },
'respawn-window': { symbol: 'tmux_window:respawn/2' },
'link-window': { symbol: 'tmux_window_link:link/3' },
'unlink-window': { symbol: 'tmux_window_link:unlink/2', note: 'Acts on one session-relative membership.' },
'move-window': { symbol: 'tmux_window_link:move/3' },
'swap-window': { symbol: 'tmux_window_link:swap/3' },
'select-window': { symbol: 'tmux_window_link:select/1', note: 'Selects a captured membership in its session.' },
'get-session': { symbol: 'tmux_session:get/2' },
'get-window': { symbol: 'tmux_window:get/2' },
'get-pane': { symbol: 'tmux_pane:get/2' },
'list-buffers': { symbol: 'tmux_buffer:list/1' },
'show-buffer': { symbol: 'tmux_buffer:show/2' },
'load-buffer': { symbol: 'tmux_buffer:load/3' },
'delete-buffer': { symbol: 'tmux_buffer:delete/2' },
'paste-buffer': { symbol: 'tmux_buffer:paste/3' },
'list-keys': { symbol: 'tmux_key:listing/1' },
'bind-key': { symbol: 'tmux_key:bind/3' },
'unbind-key': { symbol: 'tmux_key:unbind/2' },
'start-server': { symbol: 'tmux_server:start_owned/1', note: 'Starts a private daemon and returns an ownership token.' },
'kill-server': { symbol: 'tmux_server:stop_owned/1', note: 'Requires the ownership token for a library-owned daemon. Closing a borrowed endpoint preserves its daemon.' },
'server-version': { symbol: 'tmux_server:version/1' },
'source-file': { symbol: 'tmux_command:source_file/2', note: 'Builds a trusted command value. Execute it explicitly with `tmux_server:command/3`.' },
'wait-for': { symbol: 'tmux_command:wait_for/2', note: 'Builds a command value; construction performs no I/O.' },
}
for (const [id, binding] of Object.entries(ERLANG_CONCEPTS)) {
const concept = CONCEPTS[id]
if (!concept) throw new Error(`concepts: Erlang maps unknown concept ${id}`)
concept.symbols.erlang = binding.symbol
if (binding.note) (concept.notes ??= {}).erlang = binding.note
}
CONCEPTS['list-session-panes']!.absent!.julia = 'Traverse `windows(session)` and `panes(window)` in the captured snapshot. `panes(snapshot)` has server-wide scope.'
for (const concept of Object.values(CONCEPTS)) {
concept.absent ??= {}
if (!concept.symbols.powershell) concept.absent.powershell ??= 'No source-verified PowerShell equivalent is recorded for this operation.'
if (!concept.symbols.julia) concept.absent.julia ??= 'No source-verified Julia equivalent is recorded for this operation.'
if (!concept.symbols.elixir) concept.absent.elixir = 'No source-verified Elixir equivalent is recorded for this operation.'
if (!concept.symbols.erlang) concept.absent.erlang = 'No source-verified Erlang equivalent is recorded for this operation.'
if (!concept.symbols.ruby) concept.absent.ruby = 'No source-verified Ruby equivalent is recorded for this operation.'
if (!concept.symbols.lua) concept.absent.lua = 'No source-verified Lua equivalent is recorded for this operation.'
if (!concept.symbols.kotlin) concept.absent.kotlin = 'No source-verified Kotlin equivalent is recorded for this operation.'
Expand Down
5 changes: 4 additions & 1 deletion packages/api-model/src/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ export class SymbolIndex {
* forgets to say produces fewer links, not wrong ones.
*/
private readonly lang: string | undefined
private readonly docHref: ((href: string) => string) | undefined

/** The port this index describes, which decides how a page writes a symbol. */
get language(): string | undefined {
Expand All @@ -375,9 +376,10 @@ export class SymbolIndex {
return symbol.parent ? this.byDeclared.get(symbol.parent) : undefined
}

constructor(symbols: ApiSymbol[], hrefFor: (symbol: ApiSymbol) => string, lang?: string) {
constructor(symbols: ApiSymbol[], hrefFor: (symbol: ApiSymbol) => string, lang?: string, docHref?: (href: string) => string) {
this.lang = lang
this.hrefFor = hrefFor
this.docHref = docHref
for (const s of symbols) {
if (!this.byDeclared.has(s.id) || !s.inheritedFrom) this.byDeclared.set(s.id, s)
const pub = s.publicId ?? s.id
Expand Down Expand Up @@ -1103,6 +1105,7 @@ export class SymbolIndex {

linkDoc(spans: DocSpan[], context?: ApiSymbol): (DocSpan & { link?: LinkTarget })[] {
return spans.map((span) => {
if (span.kind === 'link' && this.docHref) return { ...span, href: this.docHref(span.href) }
if (span.kind !== 'ref') return span
const resolved = this.resolve(span.target, span.role, context)
const lone = /^([A-Za-z_$][\w$]*)(\(.*\))?$/s.exec(span.target)
Expand Down
19 changes: 18 additions & 1 deletion packages/api-model/src/nav-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,23 @@ const LANGUAGE_PLACEMENTS: Record<string, Record<string, string[]>> = {
errors: ['CrossServerReference', 'StaleReference', 'ObservationLost', 'UnsupportedCriterion', 'Base.showerror']
.map((name) => `LibTmux.${name}`).concat('LibTmuxWorkspace.Base.showerror'),
},
erlang: {
server: ['tmux_server'],
session: ['tmux_session'],
window: ['tmux_window', 'tmux_window_link'],
pane: ['tmux_pane'],
buffers: ['tmux_buffer'],
capabilities: ['tmux_capability'],
commands: ['tmux_command', 'tmux_operation', 'tmux_scope'],
control: ['tmux_control'],
queries: ['tmux_filter', 'tmux_matcher', 'tmux_schema', 'tmux_select', 'tmux_query', 'tmux_json'],
snapshots: ['tmux_model', 'tmux_identity', 'tmux_snapshot'],
hooks: ['tmux_hook'],
keys: ['tmux_key'],
options: ['tmux_option'],
version: ['tmux_version'],
errors: ['tmux_error'],
},
// Elixir's modules are named for what they do rather than for a tmux object.
elixir: {
queries: ['Selection', 'Schema', 'Wire'].map((name) => `LibTmux.${name}`),
Expand Down Expand Up @@ -854,7 +871,7 @@ function languageBucket(bucket: Bucket, port: string, root = bucket.id): Bucket
}

export const NAV: Record<string, PortNav> = Object.fromEntries(
['powershell', 'julia', 'elixir', 'py', 'ruby', 'lua', 'ts', 'rs', 'go', 'java', 'kotlin', 'scala', 'fsharp', 'csharp', 'cxx', 'swift'].map((port) => [
['powershell', 'julia', 'elixir', 'erlang', 'py', 'ruby', 'lua', 'ts', 'rs', 'go', 'java', 'kotlin', 'scala', 'fsharp', 'csharp', 'cxx', 'swift'].map((port) => [
port,
{
port,
Expand Down
Loading
Loading