From f76789358d7edd0fd4158543c46efa95c12ce4f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 12:31:50 -0500 Subject: [PATCH] ci(gitea): gate scripts/ with shellcheck and the selftests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo had eight scripts, six selftest suites and nothing that ran any of them. It is a composite action consumed at `@v1` — a moving tag — by three repos' CI, so a bad edit here reaches zemyna, emowheel and lublub at once and is discovered by whichever of them builds next. One job: `shellcheck -x --source-path=scripts scripts/*.sh`, then `bash scripts/selftest.sh`. `-x` follows the `. cache-lib.sh` every script sources, which is where most of the logic being checked lives; without it shellcheck reports SC1091 and analyses each file with a hole in it. The full suite, not `--fast`: `hardlink-clone-selftest.sh` and `restore-mtimes-selftest.sh` are the two that drive a real Cargo rather than a fixture, and selftest.sh's own header says `--fast` is for iterating, not for signing off a change. So the job installs a stable toolchain. The scratch workspaces they build use path dependencies only, so nothing reaches crates.io, and the job references no credentials at all. Modelled on daniel/gitdan's workflow, including the two clauses of the draft-skip guard (`github.event_name != 'pull_request' || !github.event.pull_request.draft` — the first is what stops the second from also skipping pushes to `main`, where there is no `pull_request` context) and the per-commit concurrency group for `push`. No `container.volumes:` entry, deliberately: the suites want a cold target dir every run, since "did this rebuild?" is exactly what restore-mtimes-selftest asks. This job takes no share of the shared CI cache disk budget. Turning the gate on surfaced three pre-existing findings, all fixed here rather than suppressed or waived: - `restore-mtimes-selftest.sh` SC2038: `find | xargs touch` -> `-print0 | xargs -0`. - `hardlink-clone-selftest.sh` SC2295: `${f#$base_fix/}` -> `${f#"$base_fix"/}`. - `cache-lib.sh` SC2016: a per-line disable with the rationale. The single quotes are load-bearing — the program is for the inner shell, where `$f` is its loop variable, `$rc` its accumulator and `$$` its pid — so this is the documented-false-positive case, not a stopgap. --- .gitea/workflows/ci.yaml | 83 ++++++++++++++++++++++++++++++ README.md | 17 +++++- scripts/cache-lib.sh | 3 ++ scripts/hardlink-clone-selftest.sh | 2 +- scripts/restore-mtimes-selftest.sh | 2 +- 5 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/ci.yaml diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..89331bd --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + # Spelled out only to keep `ready_for_review` in the list — naming any type + # replaces the whole default set, so the other three have to be restated. + # It is inert on this instance (draft state here is the `WIP:` title + # prefix, so un-drafting is a title edit and raises no + # `ready_for_review` action) and costs nothing. + # + # The consequence, which is the part that bites: the `if:` guard below is + # evaluated when a run is CREATED, and un-drafting creates no run. A PR + # opened as a draft keeps its skip decision until something else produces + # one. Push an empty commit after un-WIP'ing. + types: [opened, synchronize, reopened, ready_for_review] + +# gitdan-ci runs four repos' CI on two capacity slots, and the compiler-backed +# suites below are multi-minute. A superseded run costs a slot in front of +# somebody's build, so drop it. +# +# `push` groups on `github.sha` rather than `github.ref`: a constant per-branch +# group is what let this Gitea (1.26.0) cancel two of daniel/gitdan's merge +# runs outright while `cancel-in-progress` was gated away from `push` entirely +# — see the long note in that repo's ci.yaml for the evidence. Giving every +# commit its own group leaves that behaviour nothing to act on. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: true + +jobs: + selftest: + name: shellcheck + selftests + # Two clauses, both load-bearing. The second skips draft PRs: Gitea sets + # draft:true when the title starts with `WIP:`, so work-in-progress pushes + # cost the shared runner nothing until the PR is un-WIP'd. The first is + # what keeps that from also skipping pushes to `main` — a `push` event has + # no `pull_request` context, so `github.event.pull_request.draft` is empty + # there and the negation alone would be unreliable. Never drop it. + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + timeout-minutes: 20 + # Deliberately no `container.volumes:` entry, unlike every repo that + # CONSUMES this action. The suites below build throwaway workspaces under + # `mktemp -d` and want a cold target dir every time — a persistent cache + # would make "did this run rebuild?" unanswerable, which is the question + # restore-mtimes-selftest.sh exists to ask. So this job takes no share of + # the shared CI cache disk budget. + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Install shellcheck + uses: taiki-e/install-action@v2 + with: + tool: shellcheck + + # `hardlink-clone-selftest.sh` and `restore-mtimes-selftest.sh` drive a + # real Cargo against a real scratch workspace — they are the only things + # here that verify the hardlink-aliasing and mtime-freshness behaviour + # against the compiler rather than against a fixture, and selftest.sh's + # own header says `--fast` is for iterating, not for signing off a + # change. So CI installs a toolchain and runs the full set. + # + # The scratch workspaces use path dependencies only, so nothing here + # reaches crates.io. + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + # Every script, including the suites themselves. `-x` follows the + # `. cache-lib.sh` each one sources, which is where most of the logic + # being checked actually lives; without it shellcheck reports SC1091 and + # analyses each file with a hole in it. + - name: shellcheck + run: shellcheck -x --source-path=scripts scripts/*.sh + + # One command, not six: selftest.sh is the entry point a developer runs, + # so a suite added there is gated here without a matching edit in this + # file. + - name: Selftests + run: bash scripts/selftest.sh diff --git a/README.md b/README.md index 235bca9..7a11859 100644 --- a/README.md +++ b/README.md @@ -445,10 +445,25 @@ not automatic. ## Development ```bash -bash scripts/selftest.sh # everything (~1 min; needs cargo) +shellcheck -x --source-path=scripts scripts/*.sh +bash scripts/selftest.sh # everything (needs cargo) bash scripts/selftest.sh --fast # fixture-only suites, no compiler ``` +Both run in CI — `.gitea/workflows/ci.yaml`, one job, on pushes to `main` and +on PRs that were non-draft when the run was created. It installs shellcheck +and a stable Rust toolchain and references no credentials; the scratch +workspaces the compiler-backed suites build use path dependencies only, so +nothing reaches crates.io. It runs the full suite rather than `--fast`, +because the two compiler-backed suites are the ones that check this scheme +against real Cargo instead of against a fixture. Draft (`WIP:`-titled) PRs +skip it, and un-drafting does **not** un-skip them — the guard is evaluated +when a run is created and un-drafting creates none, so push an empty commit +after un-WIP'ing. + +This repo is consumed by three other repos' CI at `@v1`, a moving tag, so a +change here reaches all of them at once. That is what the gate is for. + | suite | covers | |---|---| | `hardlink-clone-selftest.sh` | that a build in a clone cannot mutate its source — with a control proving a raw `cp -al` does. Needs a real compiler. | diff --git a/scripts/cache-lib.sh b/scripts/cache-lib.sh index 2f94aea..6eabffa 100755 --- a/scripts/cache-lib.sh +++ b/scripts/cache-lib.sh @@ -210,6 +210,9 @@ _unshare_files() { # The inner shell propagates a failure of any individual copy-and-rename out # through xargs (which exits 123 if any invocation exits 1-125), so a # partially-unshared tree is reported rather than silently accepted. + # shellcheck disable=SC2016 # the quoted program is for the INNER shell: $f + # is its loop variable, $rc its accumulator and $$ its pid. Expanding any of + # them here is what the single quotes exist to prevent. find "$@" -links +1 -print0 2>/dev/null | xargs -0 -r -n 64 bash -c 'rc=0; for f; do cp -p -- "$f" "$f.unshare.$$" && mv -f -- "$f.unshare.$$" "$f" || rc=1; done; exit $rc' _ } diff --git a/scripts/hardlink-clone-selftest.sh b/scripts/hardlink-clone-selftest.sh index a066c2b..7364a49 100755 --- a/scripts/hardlink-clone-selftest.sh +++ b/scripts/hardlink-clone-selftest.sh @@ -133,7 +133,7 @@ hardlink_clone_into "$base_fix" "$clone_fix" "selftest" || fail "hardlink_clone_ # rebuild would prove nothing — the rebuild replaces those files anyway. shared=0; unshared=0 while IFS= read -r f; do - rel="${f#$base_fix/}" + rel="${f#"$base_fix"/}" [ -e "$clone_fix/$rel" ] || continue if [ "$(stat -c '%i' "$f")" = "$(stat -c '%i' "$clone_fix/$rel")" ]; then case "$rel" in diff --git a/scripts/restore-mtimes-selftest.sh b/scripts/restore-mtimes-selftest.sh index cc57414..66db0ea 100755 --- a/scripts/restore-mtimes-selftest.sh +++ b/scripts/restore-mtimes-selftest.sh @@ -122,7 +122,7 @@ assert_log_lacks() { # every run — simulate that before each restore-mtimes.sh pass, exactly as # CI would see it, so this test exercises the script the same way CI does. stamp_checkout_now() { - find . -path ./.git -prune -o -type f -print | xargs touch + find . -path ./.git -prune -o -type f -print0 | xargs -0 touch } echo "=== building scratch workspace ==="