clone by hand; the runner has no node

Every run died at the first step: "node: executable file not found in $PATH".
actions/checkout is a JavaScript action, and the runner executes it with node
*inside the job container* — which here is a Rust image that has never carried
one. actions/cache and actions/upload-artifact are the same.

The usual answer is an image carrying node as well, which for a Rust project
means installing the toolchain on every run. Cloning by hand is four lines and
keeps the lean image, and the repository is public so it needs no credential.

release.yml collapses to a single job for the same reason. Build and publish were
split across four, which meant handing wheels between them through
upload-artifact — another JS action. Everything a release needs fits in one job,
so nothing has to be handed over at all.
This commit is contained in:
funkpower
2026-09-18 12:50:56 -04:00
parent 47649983f4
commit bccf5c4ad8
2 changed files with 42 additions and 73 deletions
+12 -11
View File
@@ -21,17 +21,18 @@ jobs:
container: container:
image: rust:1.98-bookworm image: rust:1.98-bookworm
steps: steps:
- uses: actions/checkout@v4 # Not actions/checkout. That is a JavaScript action: the runner executes it
# with `node` *inside this container*, and a Rust image carries no node, so
- name: Cache cargo # the job dies at the first step with "node: executable file not found in
uses: actions/cache@v4 # $PATH". actions/cache and actions/upload-artifact are the same. Using an
with: # image that also carries node would mean installing Rust on every run;
path: | # cloning by hand costs four lines and keeps the lean image.
~/.cargo/registry - name: Checkout
~/.cargo/git run: |
target git init -q .
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
restore-keys: cargo-${{ runner.os }}- git fetch -q --depth 1 origin "$GITHUB_SHA"
git checkout -q FETCH_HEAD
# No `cargo fmt --check`. The tables in this crate are aligned by hand — # No `cargo fmt --check`. The tables in this crate are aligned by hand —
# MONTHS, JURISDICTIONS — and rustfmt wants one entry per line, which loses # MONTHS, JURISDICTIONS — and rustfmt wants one entry per line, which loses
+30 -62
View File
@@ -1,96 +1,64 @@
name: release name: release
# Tag-driven. Cargo.toml carries the version; the tag only points at it, and the # Tag-driven. Cargo.toml carries the version; the tag only points at it, and the
# first job refuses to go on if the two disagree — a release tagged v0.3.0 that # first step refuses to go on if the two disagree — a release tagged v0.3.0 that
# publishes 0.2.0 is not recoverable, because PyPI will not let a version be # publishes 0.2.0 cannot be undone, because PyPI will not let a version be reused
# reused even after it is yanked. # even after it is yanked.
on: on:
push: push:
tags: ['v*'] tags: ['v*']
# One job on purpose. Splitting build from publish would mean passing wheels
# between jobs through actions/upload-artifact, which is a JavaScript action: the
# runner executes it with `node` inside this container, and a Rust image has none.
# Everything a release needs fits in one job, so nothing has to be handed over.
jobs: jobs:
verify: publish:
runs-on: docker runs-on: docker
container: container:
image: rust:1.98-bookworm image: rust:1.98-bookworm
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
run: |
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
git fetch -q --depth 1 origin "$GITHUB_SHA"
git checkout -q FETCH_HEAD
- name: Tag must match Cargo.toml - name: Tag must match Cargo.toml
run: | run: |
tag="${GITHUB_REF_NAME#v}" tag="${GITHUB_REF_NAME#v}"
crate=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) crate=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
echo "tag=$tag crate=$crate" echo "tag=$tag crate=$crate"
[ "$tag" = "$crate" ] || { echo "::error::tag $tag != Cargo.toml $crate"; exit 1; } [ "$tag" = "$crate" ] || { echo "::error::tag $tag != Cargo.toml $crate"; exit 1; }
- name: Tests - name: Tests
run: cargo test run: cargo test
build: - name: Build wheels and sdist
needs: verify
runs-on: docker
container:
image: rust:1.98-bookworm
strategy:
matrix:
target: [x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu]
steps:
- uses: actions/checkout@v4
- name: Build wheel
run: | run: |
apt-get update && apt-get install -y --no-install-recommends python3 python3-venv apt-get update && apt-get install -y --no-install-recommends python3 python3-venv
python3 -m venv /tmp/venv python3 -m venv /tmp/venv
/tmp/venv/bin/pip install --quiet maturin[zig] /tmp/venv/bin/pip install --quiet 'maturin[zig]' twine
# --zig cross-compiles against an older glibc than the build image # --zig cross-compiles against an older glibc than this image ships, so
# ships, so the wheel is not silently pinned to bookworm's. # the wheel is not silently pinned to bookworm's.
/tmp/venv/bin/maturin build --release --zig \ for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
--target ${{ matrix.target }} --out dist rustup target add "$target"
- uses: actions/upload-artifact@v3 /tmp/venv/bin/maturin build --release --zig --target "$target" --out dist
with: done
name: wheels-${{ matrix.target }}
path: dist
sdist:
needs: verify
runs-on: docker
container:
image: rust:1.98-bookworm
steps:
- uses: actions/checkout@v4
- name: Build sdist
run: |
apt-get update && apt-get install -y --no-install-recommends python3 python3-venv
python3 -m venv /tmp/venv
/tmp/venv/bin/pip install --quiet maturin
/tmp/venv/bin/maturin sdist --out dist /tmp/venv/bin/maturin sdist --out dist
- uses: actions/upload-artifact@v3 ls -l dist
with:
name: sdist
path: dist
publish:
needs: [build, sdist]
runs-on: docker
container:
image: python:3.12-bookworm
steps:
- uses: actions/download-artifact@v3
with:
path: dist
- name: Collect
run: |
mkdir -p upload
find dist -name '*.whl' -o -name '*.tar.gz' | xargs -I{} cp {} upload/
ls -l upload
- name: Publish - name: Publish
env: env:
# A PROJECT-SCOPED token, not an account-wide one. PyPI's trusted # A project-scoped token, not the account-wide one. PyPI's trusted
# publishing is OIDC and supports GitHub, GitLab, Google Cloud Build and # publishing is OIDC and supports GitHub, GitLab, Google Cloud Build and
# ActiveState — not Forgejo — so this forge has to hold a long-lived # ActiveState — not Forgejo — so this forge has to hold a long-lived
# credential. Scoping it to this one project is the whole of the # credential. Scoping it to this one project is the whole of the
# mitigation available: if the forge is compromised, the blast radius is # mitigation available: if the forge is compromised, what is lost is the
# releases of typikon rather than every package the account owns. # ability to publish typikon rather than every package the account owns.
TWINE_USERNAME: __token__ TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: | run: |
pip install --quiet twine /tmp/venv/bin/twine check dist/*
twine check upload/* /tmp/venv/bin/twine upload --non-interactive dist/*
twine upload --non-interactive upload/*