GitHub composite Action that bootstraps a CI runner with x-cmd, git, docker and SSH — so your workflow only contains the build step.
x-cmd/action is a composite GitHub Action from x-cmd/action. It collapses the repetitive "spin up a runner" steps — installing x-cmd, configuring git/docker/SSH, optionally cloning a workspace repo, and uploading artifacts — into a single reusable step.
┌──────────────┐ ┌──────────────┐ ┌────────────────────┐
│ init step │ → │ run step │ → │ upload-artifact │
│ ssh / x-cmd │ │ prehook → │ │ (always last) │
│ docker / git │ │ script → │ │ ~/ws/.artifact │
│ │ │ code → │ │ │
│ │ │ posthook │ │ │
└──────────────┘ └──────────────┘ └────────────────────┘
You just write a step. You don't worry about how x-cmd gets onto the runner.
Most GitHub Actions are tightly coupled to GitHub's runtime. actions/checkout checks out a repo in a GitHub-specific way. actions/setup-node downloads Node in a GitHub-specific way. The moment you stitch your CI together out of these, your script only runs on GitHub — and often only on GitHub's hosted runners.
x-cmd/action takes the opposite approach: write your script in plain POSIX shell, run it anywhere.
The action does exactly three things:
- Install x-cmd — a POSIX-compatible shell library (works in
dash,ash,bash,zsh) into~/.x-cmd.root/. Once installed,x cowsay,x ws build,x eget, etc. are just shell commands onPATH. - Run your script —
sourceit (orevalthe inlinecode:), with x-cmd already loaded. This is the same file you'd run locally with./scripts/ci.sh. No${{ github.* }}interpolation, noactions/checkoutshim, no YAML quoting gymnastics. - Upload the artifact — the only step that's truly GitHub-specific, because artifact storage is GitHub's service. Anything you write to
~/ws/.artifactgets uploaded.
That's the whole design. git is just git, docker is just docker, ssh is just ssh. The action installs the shell library you already know how to use.
Why this matters:
- Your CI script is your dev script. Same file, same syntax, same commands. Debug locally with
./scripts/ci.sh— noact, no Docker, no PR-then-watch-logs loop. - CI-provider-agnostic. Switching from GitHub Actions to another provider means changing one line (
- uses: x-cmd/action@main→ the new provider's equivalent). The script body is untouched. - The artifact is the bridge between local and CI. Locally you keep state on disk; in CI the runner is ephemeral, so the action uploads
~/ws/.artifactautomatically — you get back the same files you would have had if you'd run the script yourself.
In other words: CI = install x-cmd + run my script + upload artifact. That's it.
Skip everything below if you came here just to plug
x-cmd/actioninto a workflow. This part is for users who don't need GitHub Actions, or want to understand what the action is doing under the hood.
The same one-liner works in both a script body and a shell init file:
if [ -f "$HOME/.x-cmd.root/X" ]; then
. "$HOME/.x-cmd.root/X"
else
eval "$(curl -s https://get.x-cmd.com)"
fi- In a script — paste at the top. Idempotent: re-runs fine because
Xitself guards against double-loading. - Globally (per user) — paste into
~/.bashrc/~/.zshrc. After login,x <module>works in every interactive shell.
The two invocations styles look interchangeable but are not equivalent. Pick deliberately based on whether you need shell state to survive across calls.
# Mode 1: as an external command — fresh subshell each call
x-cmd cowsay "hello"
# Mode 2: load x-cmd into the current shell, then call x as a function
. "$HOME/.x-cmd.root/X"
x cowsay "hello"
x ws build
x sysinfoMode 1: x-cmd <module> |
Mode 2: . X then x <module> |
|
|---|---|---|
What x is |
an external program on PATH |
a shell function defined by sourcing X |
| Setup needed | none | must . X first |
| Per-call cost | fork + exec | direct function call |
| Sees shell-local vars / functions / aliases | no (fresh shell each call) | yes |
Can mutate the calling shell (set vars, define aliases, modify PATH) |
no | yes |
Available in shells that haven't sourced X |
yes, if x-cmd is on PATH |
no |
| Typical use | cron jobs, sh -c "...", before login scripts, one-off CLI |
interactive shell, scripts that call x many times |
The non-equivalence in one sentence: Mode 2 lets x participate in your shell's state; Mode 1 cannot. If your script needs x to see (or change) variables defined outside the call, you need Mode 2.
Note also that the two are not always both available — Mode 1 requires x-cmd to have been installed as an external command on PATH; Mode 2 only needs the X source file to exist. The if [ -f "$HOME/.x-cmd.root/X" ] pattern in §A.1 covers Mode 2's bootstrap regardless of whether Mode 1 is wired up.
x-cmd/action's init step is just A.1 in disguise: it ensures ~/.x-cmd.root/X exists (by eval-installing via x-cmd/get), then the run step . ${___X_CMD_ROOT}/X switches you into Mode 2. The artifact upload at the end is the only piece that's GitHub-specific. Everything else is plain x-cmd.
The rest of this README documents
x-cmd/actionitself.
# .github/workflows/hello.yml
name: Hello
on: [push]
jobs:
hello:
runs-on: ubuntu-latest
steps:
- uses: x-cmd/action@main
with:
code: x cowsay "hello from x-cmd"That's the whole thing. The action installs x-cmd, then evals whatever you put in code.
# inspect the runner
code: x sysinfo
# grab a binary via x eget
code: x eget use jq && jq --version
# fetch weather
code: x wttr 'Beijing'
# disk usage
code: x dfWhen script: is omitted, the action defaults to .x-cmd/<github.job> and sources it with bash. So a job named build automatically picks up .x-cmd/build:
# .github/workflows/build.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: x-cmd/action@main# .x-cmd/build
type x
x ws buildOverride the path explicitly:
- uses: x-cmd/action@main
with:
script: scripts/ci.shPick one based on how serious the script is.
Quickest. Use when the commands are short and tied to the workflow file.
- uses: x-cmd/action@main
with:
code: |
x cowsay "step 1"
x sysinfo | head
x ws buildEach invocation is independent. Good when steps are conceptually unrelated.
steps:
- uses: x-cmd/action@main
with:
code: x ws build
- uses: x-cmd/action@main
with:
code: x ws test
- uses: x-cmd/action@main
with:
code: x ws publishWrite the script once, run it locally, in CI, anywhere. One line at the top loads x-cmd into the current shell.
# scripts/ci.sh — portable: works in any shell on any machine
. "$HOME/.x-cmd.root/X"
x cowsay "step 1"
x sysinfo | head
x ws build# .github/workflows/build.yml
- uses: x-cmd/action@main
with:
script: scripts/ci.shWhy this is the recommended pattern:
- Same file runs
./scripts/ci.shon your laptop and inside GitHub Actions. - The script is the single source of truth — no copy-paste between local dev and CI.
- When x-cmd is missing, the script fails loudly with a clear error — which is what you want.
- When the script grows, refactor freely without touching
action.yml.
Run shell code before and after the main script:
- uses: x-cmd/action@main
with:
prehook: |
x log :setup "preparing"
mkdir -p build
script: .x-cmd/build
posthook: |
x log :teardown "cleaning up"
rm -rf buildExecution order: prehook → script → code → posthook.
Set commit identity with git_user / git_email, and pass a private key with ssh_key. The action starts ssh-agent, writes known_hosts from x-cmd/knownhost, and ssh-adds your key — no manual plumbing.
- uses: x-cmd/action@main
env:
ssh_key: ${{ secrets.SSH_PRIVATE_KEY }}
with:
git_user: ci-bot
git_email: ci@example.com
code: |
git clone git@github.com:me/notes.git
cd notes
echo hello >> README.md
git commit -am "ci: update"
git pushSet ws_owner_repo + ws_repo_ref and the action clones that repo, creates a ws/ symlink, then cds into it before running your script.
- uses: x-cmd/action@main
env:
ssh_key: ${{ secrets.SSH_PRIVATE_KEY }}
with:
ws_owner_repo: x-cmd/ws
ws_repo_ref: main
script: .x-cmd/buildDefaults: ws_owner_repo → ${{ github.repository }}, ws_repo_ref → ${{ github.head_ref || github.ref_name }}.
Authenticated HTTPS clone when github_token is set.
- uses: x-cmd/action@main
with:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_password: ${{ secrets.DOCKERHUB_TOKEN }}
docker_buildx_init: 'true'
code: docker buildx build --platform linux/amd64,linux/arm64 -t me/app .The final step is always actions/upload-artifact@v4. Anything under ~/ws/.artifact is uploaded.
- uses: x-cmd/action@main
with:
code: x ws art # populates ~/ws/.artifact
artifact_name: release
artifact_path: ~/ws/.artifact
artifact_retention_days: 30| Knob | Default | Notes |
|---|---|---|
artifact_name |
artifact |
|
artifact_path |
~/ws/.artifact |
|
artifact_not_found |
ignore |
warn / error / ignore |
artifact_retention_days |
10 |
1–90 |
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
image: [xcmd/ubuntu-dev, xcmd/centos-dev]
container:
image: ${{ matrix.image }}
steps:
- uses: x-cmd/action@main
with:
code: x ws build ${{ matrix.image }}By default the action pulls the stable installer (index.html). To test a non-release stream, set ___X_CMD_GHACTION_X:
- uses: x-cmd/action@main
env:
___X_CMD_GHACTION_X: x7 # x0 (community-dev) / x1-x6 (experimental) / x7 (alpha)
with:
code: x --versionAny input falls back to a matching environment variable if it isn't set explicitly. Useful for matrix jobs that share most knobs:
jobs:
build:
runs-on: ubuntu-latest
env:
ssh_key: ${{ secrets.SSH_PRIVATE_KEY }}
git_user: ci-bot
git_email: ci@example.com
strategy:
matrix: { os: [ubuntu-latest, macos-latest] }
steps:
- uses: x-cmd/action@main # no `with:` needed; uses job-level env
with:
code: x ws build ${{ matrix.os }}| Input | Default | Description |
|---|---|---|
prehook |
— | Shell code evaluated before the main script. |
script |
.x-cmd/<github.job> |
Script file (source-d with bash). |
code |
— | Inline shell code evaluated after the script. |
posthook |
— | Shell code evaluated after the main script. |
ws_owner_repo |
${{ github.repository }} |
<owner>/<repo> cloned into ws/. |
ws_repo_ref |
${{ github.head_ref || github.ref_name }} |
Branch / tag / SHA. |
github_token |
secrets.GITHUB_TOKEN |
Used for authenticated HTTPS clone. |
ssh_key |
— | Private SSH key (PEM) loaded into ssh-agent. |
git_user |
head-commit author | git config user.name. |
git_email |
head-commit author email | git config user.email. |
docker_username |
— | docker login username. |
docker_password |
— | docker login password. |
docker_buildx_init |
false |
If truthy, runs docker buildx create --use. |
artifact_name |
artifact |
Name passed to upload-artifact. |
artifact_path |
~/ws/.artifact |
Path passed to upload-artifact. |
artifact_not_found |
ignore |
warn / error / ignore. |
artifact_retention_days |
10 |
1–90. |
artifact_* knobs are forwarded to the always-last actions/upload-artifact@v4 step — the action always runs it.
At the user level, the action is just three steps: install x-cmd → run your script → upload artifact. The rest of this section is the mechanical view of how those three steps are wired up.
action.yml composite-action metadata & steps
lib/index.sh core init / run dispatcher (pulled via curl)
.github/workflows/ example workflows (art, build-docker, build-node, build-os, cowsay)
.x-cmd/ convention: scripts named after the job, e.g. .x-cmd/build
LICENSE Apache 2.0
The three user-facing steps map onto three composite steps: init runs the install, run executes the script, and the artifact upload is a third. Composite-action steps are independent shell processes — variables and functions don't carry over — so the dispatcher script is downloaded to ~/xghaction once, then re-sourced in each step so its functions come back.
- run: |
curl -s https://raw.githubusercontent.com/x-cmd/action/main/lib/index.sh > ~/xghaction
. ~/xghaction init___x_cmd_ghaction_init runs four sub-steps:
- SSH — start
ssh-agent, write~/.ssh/known_hostsfromx-cmd/knownhost,ssh-addthe key. - x-cmd —
eval "$(curl ... x-cmd/get/main/<stream>)"installs to~/.x-cmd.root/. - Docker — optional
docker loginanddocker buildx create --use. - Git — set
user.name/user.email, optionally clonews_owner_repo→ws/.
All wrapped in set +o errexit and || true, so a sub-step failure doesn't kill the job.
Conditional execution. Every sub-step is gated by its inputs — without the matching input (or env var), the sub-step is skipped entirely. So a minimal workflow using only code: only pays for the x-cmd install:
| Sub-step | Runs when |
|---|---|
| x-cmd install | always |
| SSH (known_hosts, ssh-agent, ssh-add) | ssh_key is set |
| Docker login | docker_username and docker_password are both set |
| Docker buildx init | docker_buildx_init is truthy |
Git user.name / user.email |
git_user or git_email is set |
| Clone workspace repo | ws_owner_repo and ws_repo_ref are both set |
- run: . ~/xghaction run___x_cmd_ghaction_run:
. "${___X_CMD_ROOT:-~/.x-cmd.root}/X" # actually load x-cmd into PATH
cd ws
eval "$prehook"
source "$script"
eval "$code"
eval "$posthook"Note that init is for setup; x-cmd is only loaded into the shell here, after step 1 has finished installing.
Always runs. Forwards artifact_* inputs verbatim.
x-cmd/action only handles a small set of "first-class" infrastructure: SSH, Docker, AI credentials, and the auto-available GITHUB_TOKEN. Everything else — language toolchains, package managers, linters, anything you'd apt install or brew install — is deliberately not the action's job.
Why this split:
- SSH, Docker, AI, and
gh-token are both essential and security-sensitive. They each get a dedicated input so secrets don't have to be pasted into acode:string, and so the action can wire them into the system correctly (loadingssh-agent, writing~/.docker/config.json, etc.) rather than leaving the script to do it ad-hoc. - Other tools can be installed on-demand from inside the script. x-cmd itself ships modules for most ecosystems; if the action tried to provide all of them, every invocation would re-download dozens of toolchains. Better to let the script pull what it needs, when it needs it.
- Goal: parity with local dev, with minimum config. When you sit down at your laptop you already have
git,ssh,docker, plus whatever languages you happen to use (node,python, etc.).x-cmd/actionreplicates the first half out of the box; the second half is one line in the script —x env use node,x env use python, etc. - Future direction. x-cmd's hub will surface
npm,crates.io,pip,jsras first-class x-cmd modules. Once that lands, no action input is needed for them —x npm,x cargo, etc. will work the same way inside the action as they do locally.
In short: the action's job is to remove infrastructure friction. Toolchain friction is x-cmd's job.
No — set them once per job and they persist. The action runs git config --global user.name/email, which writes to ~/.gitconfig. The runner filesystem is shared across all steps in a single job, so any subsequent git commit in the same job uses the identity already on disk:
steps:
- uses: x-cmd/action@main
with:
git_user: ci-bot
git_email: ci@example.com
code: |
echo a >> README.md
git add . && git commit -m "a"
- uses: x-cmd/action@main
# no git_user/git_email here — still commits as ci-bot
with:
code: |
echo b >> README.md
git add . && git commit -m "b"Across jobs in the same workflow, runners are usually fresh VMs and ~/.gitconfig doesn't carry. Set git_user / git_email per job (or hoist them to job-level env: — the action falls back to env vars). On self-hosted persistent runners the file does carry over, which can be surprising — set explicitly when in doubt.
No. The action pulls its own dispatcher via curl and clones the workspace repo via git clone only if you set ws_owner_repo. If you need the current repo checked out for your own scripts, add actions/checkout@v4 separately.
No. The init step runs with errexit disabled and the eval has || true. Failures show up in logs but the workflow continues. To debug, look for -------------------------------HOME[...] and the curl output.
~/.x-cmd.root/. It's per-runner, per-job — gone the moment the job ends.
Indirectly — by pinning this action (x-cmd/action@v1.2.3) and switching ___X_CMD_GHACTION_X to a known stream. There's no per-commit hash pinning for the x-cmd installer itself.
bash + curl + git + ssh-agent + docker must be present. GitHub-hosted ubuntu-latest and macos-latest are tested. windows-latest may work but is not a primary target — wrap with shell: bash and verify.
Yes. Each invocation is independent — a fresh init then a fresh run. The first one pays the install cost; subsequent ones reuse the same ~/.x-cmd.root/ (it's idempotent).
Composite-action steps don't share shell state, and init (slow, installs x-cmd) and run (fast, executes user code) belong to different stages. Splitting them makes the action composable — you can uses: x-cmd/action for the run only, after doing your own setup.
When ws_owner_repo and ws_repo_ref are both set, the action does git clone --branch <ref> <url> and then ln -s $(pwd)/<repo> $(pwd)/ws. The symlink is what your script: path resolves against after cd ws.
Functionally identical — both end up with the same x-cmd installed under ~/.x-cmd.root/. The action just knows it's inside GitHub Actions, so it pins the install to a specific URL.
From lib/index.sh, the action does:
eval "$(curl "https://raw.githubusercontent.com/x-cmd/get/main/$___X_CMD_GHACTION_X")" || true…where ___X_CMD_GHACTION_X defaults to index.html (stable), with x0 (community-dev) / x1-x6 (experimental) / x7 (alpha) available for non-release builds.
The generic eval "$(curl -s https://get.x-cmd.com)" is the same content served via a different endpoint — a CDN-fronted domain that ultimately serves x-cmd/get's index.html. In the action's git history you can see the older versions of this line used x-bash/get and get.x-cmd.com before settling on the direct raw URL.
Why the action uses a direct GitHub URL:
For GitHub-hosted runners (the default — ubuntu-latest, macos-latest, etc.), the runner runs inside GitHub's own data centers. raw.githubusercontent.com is also GitHub's infrastructure, so the curl is effectively an internal fetch — same network, no external hop, no cross-ISP routing, no third-party DNS. By contrast, get.x-cmd.com is an external CDN: the request leaves GitHub's network, hits the public DNS, traverses the public internet, and lands at a third-party edge. Same content either way; on hosted runners the internal path is shorter and more controlled.
For self-hosted runners, this assumption doesn't hold — your runner is wherever you put it. Connectivity to raw.githubusercontent.com and to get.x-cmd.com will depend on your own network. In that case the CDN URL may actually be faster, but you can still use the action and let it pick the raw URL; just don't assume the topology benefit applies.
One important counterpoint. get.x-cmd.com carries a regional routing layer that raw.githubusercontent.com does not — explicit support for connectivity from China is part of why the CDN exists. So:
- For hosted runners (mostly in US/EU data centers),
raw.githubusercontent.comwins on the internal-fetch argument. - For self-hosted runners in China, or for users in China installing x-cmd locally,
get.x-cmd.comis often the more reliable choice — its routing is tuned for that network.
The action's hard-coded raw URL is the right default for the common case (hosted runners worldwide), but it's worth knowing the trade-off if you're running x-cmd from inside China.
The two real differences in summary:
x-cmd/action |
eval "$(curl ... get.x-cmd.com)" |
|
|---|---|---|
| Install URL | direct raw: raw.githubusercontent.com/x-cmd/get/main/<stream> |
CDN: get.x-cmd.com |
| Network path (on GitHub-hosted runners) | internal to GitHub's data center | leaves GitHub's network → public internet → CDN edge |
| Network path (on self-hosted runners) | depends on your network | depends on your network |
| Stream selection | explicit via ___X_CMD_GHACTION_X env var (index.html / x0 / x1-x6 / x7) |
whatever the CDN serves at request time |
| Failure handling | || true + init context has errexit off — install failure doesn't kill the job |
up to the caller |
Use x-cmd/action inside GitHub Actions; use the generic install (§A.1 pattern) everywhere else.
x eget use drops the binary into $HOME/.local/bin/. x env use (and other x-cmd package installs) drop into a per-package path under ~/.x-cmd.root/local/data/pkg/sphere/.../bin/. Neither directory is on PATH by default in a fresh shell — installing a tool is not the same as putting it on PATH.
Fix: either configure PATH explicitly, or load x-cmd into the current shell — . "$HOME/.x-cmd.root/X" does the PATH setup for you.
# Option 1: source x-cmd first — PATH is wired up
. "$HOME/.x-cmd.root/X"
x eget use jq
jq --version # works
# Option 2: prepend the install paths yourself
export PATH="$HOME/.local/bin:$PATH"Inside x-cmd/action: the run step already does . $___X_CMD_ROOT/X, so binaries installed by x eget use / x env use inside code: are callable from the same code: block (and from the same step's subsequent invocations):
- uses: x-cmd/action@main
with:
code: |
x eget use jq
jq --version # works — PATH was set up by the action's . XAcross separate action steps: each step is an independent bash process, so PATH does not carry over. If step A does x eget use jq and step B wants to call jq, step B must load x-cmd again (which the action does automatically) — and since the install in $HOME/.local/bin survives between steps, jq will be found as long as x-cmd is loaded in step B.
A transient dispatcher file downloaded by the first step and re-sourced by the second. Safe to ignore; don't put your own file at that path.
Three options, see §1.4:
-
Multi-line
code:for throwaway inline sequences. -
Repeat the action for cleanly separated, unrelated steps.
-
Portable script (recommended) — one file, runs locally and in CI. Add this line at the top:
. "$HOME/.x-cmd.root/X"
In CI via
x-cmd/action, x-cmd is installed by theinitstep before the script runs, so thissourcesucceeds. Locally, runeval "$(curl -s https://get.x-cmd.com)"once to install, then the same line works thereafter.Xis idempotent — sourcing it again on a shell where x-cmd is already loaded is a no-op, so it's safe to leave the line at the top of every script (and safe to source the script itself multiple times).
Most common causes:
artifact_pathis wrong (default is~/ws/.artifact, not./artifact).code:ran but didn't write anything to the path.artifact_not_foundis set toerror, hiding the actual issue. Trywarn.
- x-cmd project: https://www.x-cmd.com/
- x-cmd installer: https://github.com/x-cmd/get
- x-cmd known hosts: https://github.com/x-cmd/knownhost
- Composite Actions docs: https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
actions/upload-artifact@v4: https://github.com/actions/upload-artifact
The .github/workflows/ directory is a demonstration gallery, not production CI for this repo (the action itself is just action.yml + lib/index.sh). Each workflow is workflow_dispatch-only — never auto-runs on push — and you can trigger it by hand from the Actions tab to see the pattern working end-to-end.
See .github/workflows/README.md for the full list. Highlights:
test-git-push.yml— push a commit back usingssh_key+git_user+git_email.test-workspace-repo.yml— clone a separate repo intows/viaws_owner_repo.test-docker-buildx.yml— Docker login + buildx init + multi-arch build.test-hooks.yml— traceprehook→script→code→posthookorder.test-pipeline.yml— multi-jobbuild → test → publishwith artifact handoff.test-toolchain.yml—x env use node/pythoninstead ofactions/setup-*.test-portable-script.yml—script:input sourcing.x-cmd/<job-name>(Pattern C).test-channels.yml— matrix across___X_CMD_GHACTION_Xstreams (index.html/x0/x1-x6/x7).
Apache License 2.0 — see LICENSE.