Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
190 changes: 187 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
name: Release

# Triggered by pushing a vX.Y.Z tag: build → verify → publish to PyPI via
# Trusted Publisher (OIDC). No PyPI token needed; the trust relationship is bound
# in the PyPI project settings as "repo + workflow + environment".
# Trusted Publisher (OIDC), plus one self-contained binary per platform
# (packaging/skit.spec) attached to a GitHub Release with checksums and build
# provenance. No PyPI token needed; the trust relationship is bound in the PyPI
# project settings as "repo + workflow + environment".
#
# workflow_dispatch is the binary dry run: builds and smoke-tests every binary
# from the selected ref and stops there — no PyPI upload, no GitHub Release. The
# publishing jobs key on the EVENT (push), not just the ref type, so dispatching
# on a tag ref (the natural "rebuild v0.5.0" gesture) is still only a dry run.
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:

# Zero permissions by default; each job requests the minimum it needs.
permissions: {}
Expand Down Expand Up @@ -35,8 +43,10 @@ jobs:

# Final gate before publishing: the tag version must exactly match the version in
# pyproject, otherwise the wrong version number gets pinned on PyPI forever
# (releases cannot be overwritten).
# (releases cannot be overwritten). Skipped on workflow_dispatch dry runs, which
# build from a branch head that legitimately carries a .dev version.
- name: Verify tag matches project version
if: github.ref_type == 'tag'
run: |
tag="${GITHUB_REF_NAME#v}"
proj="$(uv version --short)"
Expand All @@ -61,10 +71,123 @@ jobs:
name: dist
path: dist/
if-no-files-found: error
# "Re-run all jobs" on a release must converge: replace attempt-1 artifacts
# instead of failing the upload with a name conflict.
overwrite: true

binaries:
name: Binary (${{ matrix.target }})
# Gated on build so a failing test suite or version mismatch stops every binary.
needs: build
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
# Explicit runner labels, not -latest: a release pipeline must not change
# platform floors because GitHub migrated an alias. The glibc floor comes from
# the uv-managed python-build-standalone interpreter (glibc 2.17 target), NOT
# the runner's system glibc — setup-uv's python-version installs the managed
# build, so no manylinux container is needed (verified against glibc 2.26).
include:
- { target: linux-x86_64, os: ubuntu-24.04, bin: skit }
- { target: linux-aarch64, os: ubuntu-24.04-arm, bin: skit }
- { target: darwin-arm64, os: macos-15, bin: skit }
# Free Intel macOS label (macos-13 successor), promised until ~Aug 2027.
- { target: darwin-x86_64, os: macos-15-intel, bin: skit }
- { target: windows-x86_64, os: windows-2025, bin: skit.exe }
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
# 3.13 = the newest interpreter in ci.yml's tested matrix. Managed (PBS)
# builds only — the runner's system Python would link the runner's glibc.
python-version: "3.13"
# No cache for release-artifact jobs: avoids cache poisoning of what ships.
enable-cache: false
# --no-editable: package data must be collected from the built wheel's layout
# (an editable install would leak maintainer-only .po sources into the bundle).
# --no-dev: the packaging group plus the project is all a build needs.
- name: Install project + packaging group
run: uv sync --group packaging --no-editable --no-dev
- name: Build binary
run: uv run pyinstaller --noconfirm packaging/skit.spec
- name: Smoke-test binary
shell: bash
run: uv run python packaging/smoke.py "dist/${{ matrix.bin }}"
# Belt-and-braces for the glibc floor promised above: the x86_64 binary must boot
# on glibc 2.26 (amazonlinux:2). Runs only where that container can (linux x64).
- name: Verify glibc floor
if: matrix.target == 'linux-x86_64'
run: docker run --rm -v "$PWD/dist:/d:ro" amazonlinux:2 /d/skit --version
- name: Rename to release asset
shell: bash
env:
TARGET: ${{ matrix.target }}
BIN: ${{ matrix.bin }}
run: |
ext=""
case "$BIN" in *.exe) ext=".exe";; esac
mv "dist/$BIN" "dist/skit-${TARGET}${ext}"
- name: Upload binary artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bin-${{ matrix.target }}
path: dist/skit-*
if-no-files-found: error
# See build job: required for "Re-run all jobs" to converge.
overwrite: true

binary-musl:
name: Binary (linux-x86_64-musl)
needs: build
runs-on: ubuntu-24.04
permissions:
contents: read
# The official python image builds CPython with --enable-shared, which PyInstaller
# requires; uv-managed musl interpreters are statically linked and will not work here.
container:
image: python:3.13-alpine
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
Comment on lines +154 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

GitHub Actions container alpine musl node20 checkout "no such file or directory" workaround

💡 Result:

The error "no such file or directory" when running GitHub Actions in an Alpine Linux container occurs because GitHub Actions attempts to use a pre-packaged Node.js binary (often in /e/) that is dynamically linked against glibc [1][2]. Alpine Linux uses musl libc, which is incompatible with glibc-linked binaries, causing the OS to report that the file does not exist even though the path is correct [1][2]. There are several common workarounds depending on your requirements: 1. Install Node.js inside the container: You can install Node.js manually using your distribution's package manager (e.g., apk add nodejs npm). To ensure GitHub Actions uses this version instead of the internal one, you can link it to the expected location: yaml - name: Fix Node path run: | mkdir -p /e/node20/bin ln -sf $(which node) /e/node20/bin/node ln -sf $(which npm) /e/node20/bin/npm ln -sf $(which npx) /e/node20/bin/npx [3] 2. Trick the Runner into Detecting Alpine: If your container is Alpine-based (or another musl-based system) but the runner fails to detect it correctly, you can temporarily modify /etc/os-release to identify the system as Alpine before the checkout step [1]: yaml - name: Force Alpine identification run: | sed '/^ID="/s/original-distro-name/alpine/' /etc/os-release > /tmp/os-release mv /tmp/os-release /etc/os-release Replace original-distro-name with your current distribution's ID [1]. 3. Use a different base image: If possible, use a Debian-based container (e.g., ubuntu or debian) instead of Alpine, as these distributions use glibc and are natively compatible with the Node.js binaries provided by GitHub Actions [4][2]. 4. Check your architecture: In some cases, this error also appears on 32-bit (i386) architectures because the standard Node.js binaries provided by GitHub Actions are often 64-bit only [3][5]. If you are running on non-x64_86 hardware, installing Node.js manually (as in step 1) is the recommended approach [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow file context =="
if [ -f .github/workflows/release.yml ]; then
  nl -ba .github/workflows/release.yml | sed -n '130,180p'
  echo "== action refs in workflow =="
  rg -n 'uses: actions/checkout|uses: actions/upload-artifact|js' .github/workflows/release.yml || true
  echo "== python image and container context =="
  rg -n 'python:3\.13-alpine|container:|steps:' .github/workflows/release.yml || true
else
  echo "workflow file missing"
fi

Repository: t41372/skit

Length of output: 217


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow context =="
if [ -f .github/workflows/release.yml ]; then
  awk 'NR>=130 && NR<=180 {printf "%d\t%s\n", NR, $0}' .github/workflows/release.yml
  echo
  echo "== relevant uses/ref comments =="
  grep -nE 'uses: actions/(checkout|upload-artifact|setup-node)|python:3\.13-alpine|container:|workdir' .github/workflows/release.yml || true
else
  echo "workflow file missing"
fi

Repository: t41372/skit

Length of output: 3109


🌐 Web query:

GitHub Actions container Node20 Alpine python:3.13-alpine no such file or directory

💡 Result:

The error "exec /e/node20/bin/node: no such file or directory" in GitHub Actions when using a container (such as Alpine-based images) occurs because the GitHub Actions runner attempts to execute its own pre-packaged, dynamically linked Node.js binary inside your container [1][2][3]. This error typically arises for one of two reasons: 1. Dynamic Linker Mismatch (glibc vs. musl): The GitHub Actions runner often injects a Node.js binary compiled against glibc (the standard C library on most Linux distributions). Alpine Linux, however, uses musl libc [4][2][5]. Because the glibc-based binary cannot find the required dynamic linker (e.g., /lib64/ld-linux-x86-64.so.2) on an Alpine system, the operating system reports that the file does not exist, even though the binary is physically present [4][2]. 2. Environment/Volume Issues: GitHub Actions mounts the runner's tool directory (often located at /e) into the container [1][6]. If the container environment is misconfigured or lacks the expected directory structure, the shell will be unable to locate the injected binary [3]. To resolve this issue, consider these approaches: Workaround 1: Install Node.js natively in your Dockerfile Instead of relying on the runner to inject its own Node.js version, install Node.js directly in your container image [1][7]. - Add the following to your Dockerfile: RUN apk add --no-cache nodejs npm Workaround 2: Create a Symlink (Quick Fix) If you cannot modify the base image, you can try to create a symlink in a setup step to point the runner to the Node.js binary installed within the container [1]. - In your workflow, add a setup step: - name: Fix Node path run: | mkdir -p /e/node20/bin ln -sf $(which node) /e/node20/bin/node ln -sf $(which npm) /e/node20/bin/npm Workaround 3: Adjust /etc/os-release In some cases, the runner detects Alpine incorrectly or experiences issues because of the distribution name. Some users have successfully "tricked" the runner by temporarily modifying /etc/os-release to match a different distribution ID, though this is fragile and generally discouraged [4]. It is recommended to avoid using custom container actions on Alpine-based images if they rely heavily on pre-injected Node.js tools, as this incompatibility is a known architectural limitation of how GitHub Actions runners currently interact with non-glibc container environments [2][8].

Citations:


Run the musl job with a glibc-compatible container or a Node-provisioned Alpine image.

.github/workflows/release.yml uses python:3.13-alpine for binary-musl, but the actions/checkout and actions/upload-artifact steps are Node.js actions that run using the GitHub runner’s glibc-linked Node binary; on this musl-only container, these steps typically fail with no such file or directory before/after the musl build. Use a base image that contains the Node binary GitHub Actions expects, run the PyInstaller build inside docker run python:3.13-alpine … from a normal ubuntu job, or add gcompat/libstdc++ coverage earlier in jobs that can reach the first Node action.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 154 - 160, Update the binary-musl
job’s container configuration to use a glibc-compatible or Node-provisioned
Alpine image so the Node-based Checkout and upload-artifact actions execute
successfully; alternatively, remove the job-level container and run the
PyInstaller build explicitly inside python:3.13-alpine while keeping the
surrounding GitHub Actions steps on the normal Ubuntu runner.

- name: Install build toolchain
shell: sh
# binutils: PyInstaller needs objcopy/strip on Linux; gcc/musl-dev/zlib-dev
# cover any dependency that has to compile from sdist on musl.
run: apk add --no-cache bash binutils gcc musl-dev zlib-dev
- name: Install uv
run: pip install --no-cache-dir uv
- name: Install project + packaging group
# The container's shared-libpython interpreter, not a managed download (see above).
run: uv sync --group packaging --no-editable --no-dev --python "$(command -v python3)"
- name: Build binary
run: uv run pyinstaller --noconfirm packaging/skit.spec
- name: Smoke-test binary
run: uv run python packaging/smoke.py dist/skit
- name: Rename to release asset
run: mv dist/skit dist/skit-linux-x86_64-musl
- name: Upload binary artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bin-linux-x86_64-musl
path: dist/skit-*
if-no-files-found: error
# See build job: required for "Re-run all jobs" to converge.
overwrite: true

publish:
name: Publish to PyPI
needs: build
# Event AND ref type: a workflow_dispatch pointed at a tag ref must stay a dry run.
if: github.event_name == 'push' && github.ref_type == 'tag'
runs-on: ubuntu-latest
# Bound to a protected environment; add required reviewers there for a manual release gate.
environment:
Expand All @@ -81,3 +204,64 @@ jobs:
path: dist/
- name: Publish
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
# A re-run of a release whose PyPI upload already succeeded (e.g. binaries
# failed on attempt 1) must be a no-op success, not a 400 duplicate-file error.
skip-existing: true

github-release:
name: GitHub Release (binaries + checksums + provenance)
# After publish: the PyPI environment (with any required reviewers) is the single
# human gate for the whole release; binaries never ship if PyPI didn't.
needs: [publish, binaries, binary-musl]
# Event AND ref type: a workflow_dispatch pointed at a tag ref must stay a dry run.
if: github.event_name == 'push' && github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
# Create the release and upload assets.
contents: write
# Sign build provenance via the public Sigstore instance (OIDC).
id-token: write
attestations: write
steps:
- name: Download binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: bin-*
merge-multiple: true
path: assets/
- name: Download wheel + sdist
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: assets/
# checksums.txt catches corrupt/truncated/stale mirror downloads without any
# GitHub API access — but a hostile mirror serves checksums.txt too, so it is NOT
# tamper-evidence on its own; authenticity comes from the provenance attestation
# below (gh attestation verify, needs api.github.com) or an off-mirror checksum
# comparison.
- name: Generate checksums
working-directory: assets
run: sha256sum * | tee checksums.txt
# Provenance binds every asset to this repo + workflow + commit; verify with:
# gh attestation verify skit-linux-x86_64 --repo t41372/skit
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-checksums: assets/checksums.txt
- name: Create release and upload assets
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
# Draft-first: `releases/latest` (which scripts/install.sh downloads from) must
# not flip to this release until every asset is attached, so create as draft,
# upload, then publish as the last step. --verify-tag refuses to mint a release
# for a tag that isn't on the server; view-before-create + --clobber +
# edit --draft=false make a re-run of a half-failed job converge instead of
# erroring on "release already exists" (gh resolves drafts by tag name too).
run: |
gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1 \
|| gh release create "$TAG" --repo "$REPO" --verify-tag --generate-notes --draft
gh release upload "$TAG" assets/* --repo "$REPO" --clobber
gh release edit "$TAG" --repo "$REPO" --draft=false
39 changes: 35 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ positive pilot test.
Key grammar: a chord keeps one meaning per context class — Ctrl+E always opens `$EDITOR`
on the screen's current subject, Ctrl+N always creates the screen's primary object (a
script on the add step, an agent on a runner picker), Ctrl+T always inserts a value,
Ctrl+R re-runs/refreshes the screen's subject (the run form runs it; Script settings
Ctrl+R re-runs/refreshes the screen's subject (the run form runs it; Entry settings
resyncs its definitions from the script), Ctrl+S saves/commits the screen's work
(the run form's save-as-preset included), Ctrl+O always restores the screen's current
field to its source default (README documents it on the run form — it must never mean
anything else), and Ctrl+L always opens the screen's variable/candidate picker (the
prompt review panel and Script settings). A new screen action takes an UNCLAIMED chord,
never a second meaning for a claimed one. Ctrl+A (cursor-home) and — while an Input has
prompt review panel and Entry settings). A new screen action takes an UNCLAIMED chord,
never a second meaning for a claimed one — check the candidate against every screen
before it lands (the three-way Ctrl+O collision happened because each screen picked it
in isolation). Ctrl+A (cursor-home) and — while an Input has
focus — Ctrl+E (end-of-line) belong to the Input: screen chords for them are never
priority-bound; the chip is the path mid-edit.
Never bind a text-editing chord (Ctrl+K and friends) with `priority=True` on a screen
Expand Down Expand Up @@ -173,6 +175,31 @@ enforces the sync byte-for-byte, validates the frontmatter against the spec, and
every `skit …` invocation the skill teaches against the real command tree — renaming a
command or flag fails that test until the skill is updated too.

## Binary release

Releases also ship self-contained single-file binaries (issue #16): PyInstaller onefile,
built per platform by the `binaries`/`binary-musl` jobs in `.github/workflows/release.yml`
and attached to the GitHub Release with `checksums.txt` + provenance attestation.
Contributor rules that follow:

- **`packaging/skit.spec` is a contract, not defaults** — its header documents why each
collect/hidden-import line exists. Build locally with
`uv sync --group packaging --no-editable --no-dev` (never an editable install — package
data must come from the built wheel) and a **uv-managed** Python (the glibc floor comes
from python-build-standalone, not the build host; a system interpreter would pin the
binary to the host's glibc).
- **Every binary must pass `packaging/smoke.py` before it ships.** Frozen-app failures are
silent by design (a lost tree-sitter grammar degrades analyzers to None; lost metadata
becomes version `0.0.0+unknown`), so the smoke suite asserts positive outcomes — CI runs
it on every target, and `workflow_dispatch` on the Release workflow is the dry run.
- **Child environments go through `childenv.child_env()`, never raw `os.environ`.** The
frozen bootloader poisons `LD_LIBRARY_PATH` (and friends) for its own libraries; skit
spawns user scripts, uv, editors, and installers, and every child-env assembly point
must scrub that. New subprocess call sites inherit this rule — and so does anything
that turns the environment into *delivery values*: `{env:X}` token expansion
(tokens/flows defaults) and the TUI env picker read `child_env()` too, so a frozen
install never exposes bundle paths or `_PYI_*` bookkeeping through skit's own UI.

## Demo assets

The README's demo videos (`docs/assets/demo-*.mp4`) and screenshot grid (`docs/assets/tui-*.png`)
Expand Down Expand Up @@ -207,5 +234,9 @@ broken internal link or `#anchor`. Preview with `npm run dev` (http://localhost:
Gotcha: `<include>` and Turbopack only resolve files **inside** `docs/` — never reference a
path above the project root. The docs are English-only for now and sit **outside** the i18n
coverage gate; the scaffolding (`docs/lib/i18n.ts`) is ready for zh content later. README copy
vocabulary applies — the run screen is the "launch menu", never a "form". `docs/assets/` and
vocabulary applies — the run screen's product name is the "launch menu": READMEs and docs
introduce it by that name, and in-app copy may say "form" only for the mechanism (the
fields being filled), never as the screen's name. README's first "launch menu" mention
bridges the two ("the launch menu (the run form)") so a user meeting "form" strings
in-app can connect them. `docs/assets/` and
`docs/design/` live beside the site and are not published to it.
Loading