xlings Contributing Workflow
Overview
This skill defines the standard contribution flow for AI agents working on the xlings codebase. Follow this process for any code change — feature, bugfix, or refactoring.
Prerequisites
1. Build environment setup
# Install xlings itself (bootstrap)
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash
# From repo root — install build dependencies
xlings install # reads .xlings.json → installs mcpp
# Switch to the correct dev toolchain
xlings use gcc@16.1.0 # Linux dev build (avoids musl/glibc link conflicts)
2. Verify build works
mcpp build
mcpp test
3. Repository structure awareness
src/
├── main.cpp # entry point
├─��� cli.cppm # CLI command dispatch
├── core/
│ ├── config.cppm # 3-layer config (.xlings.json)
│ ├── subos.cppm # SubOS management (create/use/remove/fork)
│ ├── subos/keeper.cppm # Auto-keeper primitives
│ ├── xself.cppm # Self-install/update
│ ├── xim/
│ │ ├── installer.cppm # Package install orchestration
│ │ ├── resolver.cppm # DAG dependency resolution
│ │ ├── downloader.cppm # Parallel download + SHA256
│ │ └── libxpkg/types/ # Per-type handlers (script.cppm, subos.cppm)
│ └── xvm/ # Version management (shim, db, commands)
├── interface.cppm # NDJSON programmatic interface
└── platform.cppm # Cross-platform abstractions
tests/
├── e2e/ # End-to-end shell tests
│ ├── project_test_lib.sh # Shared test helpers
│ └── fixtures/ # Test fixture packages
└── (unit tests via `mcpp test`)
Standard Contribution Flow
Step 1: Issue
- Check existing issues:
gh issue list - If no issue exists for your change, create one:
gh issue create --title "feat/fix: <description>" --body "<details>" - Reference the issue number in your PR
Step 2: Branch
git fetch origin main
git switch -c <type>/<short-description> origin/main
Branch naming: feat/xxx, fix/xxx, chore/xxx, docs/xxx
Step 3: Implement
- Follow existing code patterns (C++23 modules,
import std;) - Type-specific dispatch: see
installer.cppmforpkgType == Npattern - CLI argparse: see
subos.cppmrun()function for manual arg parsing pattern - Keep changes minimal and focused
Step 4: Write tests
E2E tests (preferred for user-facing features):
# Create test file
touch tests/e2e/<feature>_test.sh
chmod +x tests/e2e/<feature>_test.sh
Test template:
#!/usr/bin/env bash
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh"
# Setup
RUNTIME_DIR="$ROOT_DIR/tests/e2e/runtime/<test_name>"
HOME_DIR="$RUNTIME_DIR/home"
cleanup() { rm -rf "$RUNTIME_DIR"; }
trap cleanup EXIT
cleanup
mkdir -p "$HOME_DIR/subos/default/bin"
cp "$(find_xlings_bin)" "$HOME_DIR/xlings"
# ... write .xlings.json, set up index, etc.
# Test
log "Testing <feature>..."
run_xlings "$HOME_DIR" "$ROOT_DIR" <command> || fail "<what failed>"
# Assertions
[[ <condition> ]] || fail "<what's wrong>"
log "PASS: <feature> works"
Key helpers from project_test_lib.sh:
find_xlings_bin— locates the built binaryrun_xlings "$HOME_DIR" "$ROOT_DIR" <args>— runs xlings with isolated XLINGS_HOMErequire_fixture_index— ensures test pkgindex is availablelog/fail— logging with consistent prefix
Assertions that cannot pass for the wrong reason
The bug class this repo keeps finding is never-happened and succeeded look identical. A test written carelessly reproduces it inside the test.
1. Assert the command succeeded before asserting what it printed.
out="$("$SUBOS/bin/g++" -print-sysroot 2>&1)"
[[ "$out" == *"/subos/probe"* ]] && ok # ← passes when g++ does not exist
# env: '.../subos/probe/bin/g++': No such file or directory
# ^^^^^^^^^^^^ the failure message contains the needle too
Capture the exit code, or compare the whole line ([[ "$out" == "$want" ]])
rather than searching inside it. Pick a needle that cannot appear in the
failure output.
2. Assert against a filesystem fact where one exists. [[ -d "$HOME_DIR/data/xpkgs/<ns>-x-<pkg>/<ver>" ]] cannot be satisfied by a warning
that happens to mention the version.
3. Check that a grep-based negative assertion is not vacuous. A fixture
that declares nothing makes grep -q ... && fail pass forever. Confirm the
positive case fires at least once in the same file — E2E-51's deps sits in
xpm.<os>.deps, and writing it one level deeper (inside a version entry)
declared no dependency at all while every assertion still passed.
4. Run every new assertion against a build that should fail it. The released binary is the reference:
curl -fsSL -o old.tar.gz \
https://github.com/xlings-res/xlings/releases/download/<prev>/xlings-<prev>-linux-x86_64.tar.gz
tar -xzf old.tar.gz
XLINGS_BIN=$PWD/xlings-<prev>-linux-x86_64/bin/xlings bash tests/e2e/<new>_test.sh
It must fail, and fail on the assertion the change is about. A new test that is green on both builds is testing something else.
Note set -e + cmd && fail "...": when cmd returns non-zero the whole
list is the statement's exit status, so the script dies instead of continuing.
Write if cmd; then fail "..."; fi.
Step 5: Build + test locally
# Build and unit tests
mcpp build
mcpp test
# Run your test
XLINGS_BIN=$(find target -path '*/bin/xlings' -type f | head -1) \
bash tests/e2e/<feature>_test.sh
# Run existing tests to check for regressions
for t in tests/e2e/subos_xpkg_*.sh; do
XLINGS_BIN=$XLINGS_BIN bash "$t" | tail -1
done
### Step 5.1: xpkg / resource changes
涉及 `xpm`、官方资源或 `xim-pkgindex` 的改动必须遵守以下契约:
- `libxpkg` 是解析、compat 和资源归一化的唯一入口;不要在 xlings 中新增第二套 Lua 解析器或 URL 模板展开器。
- 默认来源使用 `xpm.source = "xlings-res"` 或 URL template;版本项仍保持原有 `platform -> version` 模型。
- 官方二进制资源为每个受支持平台/架构提供 SHA256。多架构 hash 缺失时索引生成器应 fail closed。
- 保留并测试旧的 `"XLINGS_RES"`、`res = true`、显式 URL、mirror、`ref` 和旧单 hash 写法。
- 资源表达测试至少覆盖 x86_64/aarch64、根级/平台级 source、显式 URL 覆盖、mirror 和旧客户端兼容 fixture。
- 修改资源缓存、下载或发布链时,除 `mcpp build && mcpp test` 外,使用隔离 `XLINGS_HOME` 验证坏缓存自愈、SHA256 校验和实际 release 资产。
Step 6: Commit
git add <files>
git commit -m "<type>(<scope>): <short description>
<optional body explaining why>
Refs: #<issue-number>"
Commit message convention:
feat(subos): add --from flag for forkfix(xim): resolver handles empty namespacechore(0.4.37): bump version for releasedocs: update READMEtest(subos): cover --cmd exit code propagation
Step 7: Push + PR
git push -u origin <branch>
gh pr create --draft --title "<type>(<scope>): <description>" --body "..."
PR body should include:
- Summary (what + why)
- Test plan (which tests cover this)
- Link to issue (
Closes #NorRefs #N)
Step 8: CI verification
# Check CI status
gh pr checks <pr-number>
# If failing, read logs:
gh run view <run-id> --log-failed | tail -50
CI runs on 3 platforms (Linux + macOS + Windows). All must pass.
Common CI failures:
- Link error with musl: CI uses musl-gcc for static binary. Ensure new code doesn't introduce glibc-only symbols.
- Windows compile error: Check
#if defined(_WIN32)guards for POSIX-only code. - Test timeout: E2E tests have implicit timeouts; ensure no hanging processes.
Step 9: Review + merge
- Mark PR as "Ready for review" when CI passes
- For admin-privilege merge (if branch protection requires review):
gh pr merge <number> --squash --delete-branch --admin
Version bumping (release flow)
Version numbering
Releases are date-based: YYYY.M.D.N, e.g. 2026.7.28.1.
N starts at 1. Do NOT use .0 for an ordinary release.
.0 is reserved for a formal/milestone release on that date. A routine
same-day fix or feature release is .1, the next .2, and so on. When in
doubt, it is not a formal release — use .1.
2026.7.28.1 ← first release of the day (the normal case)
2026.7.28.2 ← second release of the day
2026.7.28.0 ← reserved: formal release, only when explicitly intended
This has been broken once. 2026.7.29.0 shipped as an ordinary bugfix
release. The rule was already written here; the release was cut by following
AGENTS.md, which did not mention it, and this skill was never opened. The
version is picked at the very start of a release — before anyone goes looking
for process docs — so the rule now also lives in AGENTS.md. Keep both in
sync; if you change the scheme, change it in both places.
2026.7.29.0 was left as-is rather than re-cut: the release was already
published, mirrored, and indexed, and renaming a published version costs more
than the wrong digit does.
Note that semver::parse rejects a four-component version, so resolution
falls back to lexicographic ordering — always publish and reference these
through an explicit latest ref rather than relying on version comparison.
Steps
After feature PRs merge, if a release is planned:
# On main:
# Edit BOTH: mcpp.toml `version` and src/core/config.cppm VERSION.
# (mcpp's target fingerprint includes the package version, so a bump moves
# the build output to a new target/<triple>/<fp>/bin/xlings — check
# `./that/binary --version` before concluding anything from a manual test.)
git commit -m "chore(2026.7.28.1): bump version for release"
git push origin main
# Trigger release
gh workflow run release.yml --ref main
# Monitor
gh run list --workflow=release.yml --limit 1
Then two steps that release.yml cannot do for you. A green workflow is
not a finished release:
# 1. Top up the CN mirror — from a CN machine, with a local gtc.
# The GitHub runner cannot push large assets to GitCode: the upload stalls
# on the cross-border OBS wall, the job logs it and moves on green.
bash tools/mirror-latest.sh xlings
# Verify with GET, never HEAD: GitCode answers 401 to HEAD and
# 302 -> CDN 200 to GET, so a HEAD check reports a healthy asset missing
# and a curl -I sweep "proves" the opposite of the truth.
curl -sSL -o /dev/null -w '%{http_code}\n' \
https://gitcode.com/xlings-res/xlings/releases/download/<ver>/xlings-<ver>-linux-x86_64.tar.gz
# 2. Bump xim-pkgindex/pkgs/x/xlings.lua — every platform block AND the
# ["latest"] ref, each with its sha256.
2026.7.30.1 skipped step 1. CN users got HTTP 404 for three hours while
the same files sat on GitHub, reachable and unlisted. 2026.7.30.2 added a
cross-region fallback (Config::all_resource_servers_for_) so one region's
gap is no longer fatal — the manual mirror is an accelerator again, not a
correctness requirement, but it is still expected every time.
Do not pin the released version into CI
tests/fresh-install/ deliberately installs whatever the published latest
resolves to. It is the only suite that tests what a first-time user actually
gets: the release artifact, quick_install, and the index that has to resolve
it on a cold home. Pinning the version turns it into a test of a snapshot
nobody installs, and it rots silently — one release at a time, with everything
still green.
So a release is not followed by a "pin the new version in CI" commit. The
pins that do exist there (MCPP_OLD / MCPP_NEW, GCC_*, LLVM_*,
NINJA_VERSION) hold the two ends of an upgrade still so an assertion can
name an exact expected version — those pin the packages under test, which
is the point. The xlings binary doing the installing is never one of them.
Enforced, not just written down:
bash tests/fresh-install/no_xlings_version_pin_check.sh
It runs in xlings-ci-linux.yml (which has a pull_request trigger) rather
than in xlings-ci-fresh-install.yml (which deliberately does not) — a rule
enforced only after merge is a rule that gets merged. It catches three shapes:
an xlings-named version variable, an xlings@<number> coordinate, and a
QUICK_INSTALL_URL pointing at a release tag instead of a branch.
Recipes: where a subos path may and may not appear
Two axes, and the correct answer is opposite for each. gcc.lua is the
worked example.
| axis | value belongs in | may it name a subos |
|---|---|---|
| LINK (ELF interpreter, rpath) | the payload's own config (gcc's specs) |
No — payload-direct |
HEADER (--sysroot) |
the xvm registration (alias) |
Yes — that is what a sysroot is |
Why LINK must not: one payload is shared by every subos in the home, and a
direct <install_dir>/bin/gcc (mcpp, downstream tools) never goes through a
shim at all. A subos path there points the shared copy at one subos and cannot
be repaired by anything — exec-time normalization only rewrites the xvm
record, and self doctor never reads payload file contents. The only fix is a
reinstall, which is why the specs stamp carries a schema suffix
(.specs-rewritten-<ver>-payload.stamp): it forces one.
Why HEADER must: gcc's header search needs an FHS-shaped tree and the subos is that composite view.
For the HEADER axis, write the portable spelling, not the install-time subos:
local dir = system.subos_sysrootdir()
return (dir:gsub("([/\\])subos([/\\])[^/\\]+", "%1subos%2current", 1))
<home>/subos/current is the symlink self init creates and
subos use --global maintains. It needs no placeholder, no capability probe
and no libxpkg change; an old client follows it instead of freezing at install
time, and a current client normalizes it like any other subos path (which is
what keeps XLINGS_ACTIVE_SUBOS and project subos correct — a symlink cannot
follow those). self doctor keys its baked-path check on this spelling, so a
recipe that writes it carries no standing warning.
Key conventions
No command may block on a prompt, and none may report success having done nothing. Whether a human is at the keyboard is not detectable — agents and terminal tooling routinely allocate a pty, so
stdin_is_terminal()is true for them. What is detectable is whether the command has a single correct outcome, and that is what decides:- unambiguous → do it, exit
0; - ambiguous → change nothing, print the candidates and the exact command,
exit
2; - interactive selection is opt-in (
--pick), never the default path, and when it cannot run it says so rather than falling back to a no-op.
A TTY may decide presentation (picker vs panel), never semantics.
tests/e2e/non_interactive_contract_test.shruns every prompting command understdin=/dev/null,XLINGS_NON_INTERACTIVE=1and a pseudo-TTY, each undertimeout, so a reintroduced prompt fails instead of hanging CI.- unambiguous → do it, exit
Build with xlings: always use
xlings install+xlings use gcc@16.1.0for dev envNo manual apt/brew: use
xlings install <tool>(dogfood the project)Test isolation: every e2e test uses a temp
XLINGS_HOME(never touches real user env)One feature per PR: keep PRs focused and reviewable
Squash merge: PRs are squash-merged to keep main history clean