Skip to content

feat(installer): implement install.sh per 07-spec-installer - #645

Merged
twitchyliquid64 merged 1 commit into
mainfrom
tom/release
Jul 6, 2026
Merged

feat(installer): implement install.sh per 07-spec-installer#645
twitchyliquid64 merged 1 commit into
mainfrom
tom/release

Conversation

@twitchyliquid64

@twitchyliquid64 twitchyliquid64 commented Jul 6, 2026

Copy link
Copy Markdown
Member
  • Implements the shell-based installer as per docs/specs/07-spec-installer.
  • Linux: Tested in an exe.dev fresh VM
  • MacOS: TODO test
  • Temporarily strips the quarantine bit + selfsigns on MacOS
  • Implements CI for the shell installer, including shellcheck for posix compat + a medium-rare shit test suite.
  • Stamps the install.sh into the minimal-one/<version bucket.

Summary by CodeRabbit

  • New Features
    • Added a portable POSIX installer (curl|sh) with OS/arch filtering, SHA-256 verified downloads, atomic installs, and XDG-aware paths.
    • Added a gated CI workflow that shell-checks and runs installer tests across shells/OSes.
    • Added release staging to publish a versioned installer artifact before advancing the stable channel.
    • Added a just target to run installer conformance tests.
  • Bug Fixes
    • Strengthened destination/path safety checks and improved rerun/tamper recovery behavior.
  • Tests
    • Added a POSIX test harness covering reruns, checksum failures, manifest validation, and macOS-specific signing/quarantine handling.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a POSIX sh installer with versioned manifest fetching, checksum-verified atomic installs, and macOS handling; a mock-backed test harness; CI and local recipes for shell coverage; and a release step that stages the installer into versioned storage.

Changes

Curl|sh Installer and Verification

Layer / File(s) Summary
Installer script core logic
scripts/install.sh
POSIX sh installer with strict mode, downloader/hash-tool selection, host/prefix resolution, target/version/manifest resolution, checksum-verified atomic installs, macOS signing, and install-record persistence.
Installer test harness
scripts/install_test.sh
Mock bucket, stubbed curl/wget/uname/codesign/xattr, isolated run helper, and unit tests covering install, skip/rerun, tampering, checksum failures, validation, XDG steering, records, and macOS signing.
CI workflow and local test recipe
.github/workflows/ci-shell-installer.yml, justfile
New CI workflow (shellcheck + matrix test + gating status job) and test-installer recipe for local shell coverage.
Release staging step
.github/workflows/release.yml
Stages scripts/install.sh into the versioned GCS path before the unstable channel update.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • gominimal/minimal#644: Defines the staged release flow that this PR extends by publishing the versioned installer file.
  • gominimal/minimal#621: Introduces the installer distribution behavior that this PR implements in scripts/install.sh.

Suggested reviewers: norrietaylor, msample, bryan-minimal

Poem

A bunny hops through shells and sh,
Checks each hash with careful swish. 🐇
Dash and macOS both say "yes",
Signed bits land in tidy rest.
The release bin gleams, neat and bright—
hop, hop, ship it into light!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: implementing the shell installer from the spec.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@twitchyliquid64

Copy link
Copy Markdown
Member Author

Failing CI / cargo-deny (pull_request) will be fixed by #646

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
.github/workflows/release.yml (1)

520-530: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider staging install.sh via scripts/stage-release.sh instead of a standalone gcloud storage cp.

The step immediately above (Line 516-519) already stages release artifacts through scripts/stage-release.sh, which presumably centralizes the immutable cache-control header and bucket-path conventions. This new step duplicates that header inline instead of extending the shared script, so the two staging paths can drift (e.g. if the cache-control policy changes later, only one call site gets updated).

🤖 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 520 - 530, The installer staging
step duplicates release-upload logic and cache-control settings instead of using
the shared staging flow. Update the release workflow to stage install.sh through
scripts/stage-release.sh, reusing the same bucket/path conventions and immutable
header handling already used for the other release artifacts. Keep the change
aligned with the existing release artifact staging steps so the logic stays
centralized and consistent.
scripts/install_test.sh (2)

241-250: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Traversal test uses ../evil, which is rejected only because of the /, not because .. is blocked.

This test doesn't cover a bare ".." target (no slash), which — per the companion finding on scripts/install.sh Lines 124-127 — currently passes the charset validation unmodified since every character in .. is individually allowed. Once that validation gap is fixed, please add a case asserting run traversal2 "$H3" ".." also exits non-zero, to lock in the fix.

🤖 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 `@scripts/install_test.sh` around lines 241 - 250, The traversal coverage in
install_test.sh only exercises "../evil", which can fail for the slash rather
than for dot-dot traversal. Update the Unit 2 validation block to also test the
bare ".." target using the existing run traversal2 helper, and assert it exits
non-zero like the other invalid target cases. Keep the new assertion alongside
run traversal and run emptytarget so the install.sh charset validation fix is
locked in by a direct check on the traversal2 path.

102-134: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fallback downloader/hasher branches (wget, shasum, openssl) are never exercised.

The stub wget always exits 1 (Line 128-132), so install.sh's DL_TOOL=wget branch and the wget case in fetch() (install.sh Line 58) are dead code as far as this suite goes — curl is always found first. Likewise, whichever SHA_TOOL the CI host happens to have wins, so the shasum/openssl branches of install.sh's sha256() are only covered opportunistically, not deliberately, depending on the runner.

Consider adding a scenario that hides the curl stub from PATH (functional wget stub) to force and assert the wget path, and one that forces SHA_TOOL selection order via a similar PATH trick, so all of Unit 1's fallback branches get direct coverage rather than relying on host happenstance.

🤖 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 `@scripts/install_test.sh` around lines 102 - 134, The test setup currently
never exercises the fallback downloader/hasher paths because the stubbed curl is
always preferred and the stubbed wget always fails, so the install.sh fetch()
wget branch and sha256() shasum/openssl branches are not directly covered.
Update the install_test.sh scenario around the curl/wget stubs to add a case
that removes curl from PATH and provides a working wget stub so install.sh is
forced through the DL_TOOL=wget path, and add a separate PATH-controlled
scenario that forces SHA_TOOL selection to verify both shasum and openssl
branches deterministically. Use the existing install.sh fetch() and sha256()
logic, plus the stubbin curl/wget setup, to locate and adjust the test coverage.
scripts/install.sh (1)

167-169: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Install record write is not same-filesystem atomic, unlike per-component installs.

Per-component installs are deliberately made atomic by writing the temp file as a sibling of target_file (Lines 226-228 comment) before mv -f. The install record, however, is built at $tmpdir/installed (under /tmp typically) and then mv -f'd to $prev_record under $state_dir (typically $HOME/.local/state/...) — usually a different filesystem, so mv falls back to copy+unlink and is not atomic. An interruption mid-write could leave a truncated/corrupt record.

Impact is bounded (a corrupted line just fails the hash match on the next run, causing an extra download/re-sign — not silent corruption of installed binaries), but this undermines the "reruns are cheap" goal (Goal 2) referenced by the macOS signing tests.

♻️ Proposed fix: build the record in-place under `state_dir`
+mkdir -p "$state_dir"
-records="$tmpdir/installed"
+records="$state_dir/installed.tmp.$$"
 : >"$records"

And drop the later mkdir -p "$state_dir" before the final mv -f "$records" "$prev_record", which now becomes a same-directory atomic rename.

Also applies to: 263-269

🤖 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 `@scripts/install.sh` around lines 167 - 169, The install record is being
written to a temp file under $tmpdir and later moved into $state_dir, which
makes the final rename non-atomic across filesystems. Update the install record
flow in scripts/install.sh so the record is created in-place under state_dir as
a sibling of the final $prev_record target, then keep the final mv -f as a
same-directory atomic rename. Use the existing records/$prev_record handling in
the install logic and remove the extra mkdir -p step that was only needed for
the cross-filesystem move.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@scripts/install.sh`:
- Around line 124-127: The target/version validation in install.sh still allows
literal dot segments, so update the checks around target="${1-stable}" and the
version/pointer handling to explicitly reject "." and ".." in addition to the
existing character whitelist. Keep the current allowlist in place, but add a
direct guard before any URL/path construction so scripts like install and the
version resolution logic cannot pass dot-segment values into curl-based
downloads or bucket prefix assembly.
- Around line 43-60: The wget fallback in fetch is not enforcing redirect-scheme
restrictions, so it can still follow a 3xx to http:// despite --https-only.
Update the fetch() branch for wget to explicitly reject or validate redirect
targets so it matches the curl path’s HTTPS-only behavior, using the DL_TOOL and
fetch symbols as the place to make the change; if that’s not feasible, remove
the wget fallback from the downloader selection block.

---

Nitpick comments:
In @.github/workflows/release.yml:
- Around line 520-530: The installer staging step duplicates release-upload
logic and cache-control settings instead of using the shared staging flow.
Update the release workflow to stage install.sh through
scripts/stage-release.sh, reusing the same bucket/path conventions and immutable
header handling already used for the other release artifacts. Keep the change
aligned with the existing release artifact staging steps so the logic stays
centralized and consistent.

In `@scripts/install_test.sh`:
- Around line 241-250: The traversal coverage in install_test.sh only exercises
"../evil", which can fail for the slash rather than for dot-dot traversal.
Update the Unit 2 validation block to also test the bare ".." target using the
existing run traversal2 helper, and assert it exits non-zero like the other
invalid target cases. Keep the new assertion alongside run traversal and run
emptytarget so the install.sh charset validation fix is locked in by a direct
check on the traversal2 path.
- Around line 102-134: The test setup currently never exercises the fallback
downloader/hasher paths because the stubbed curl is always preferred and the
stubbed wget always fails, so the install.sh fetch() wget branch and sha256()
shasum/openssl branches are not directly covered. Update the install_test.sh
scenario around the curl/wget stubs to add a case that removes curl from PATH
and provides a working wget stub so install.sh is forced through the
DL_TOOL=wget path, and add a separate PATH-controlled scenario that forces
SHA_TOOL selection to verify both shasum and openssl branches deterministically.
Use the existing install.sh fetch() and sha256() logic, plus the stubbin
curl/wget setup, to locate and adjust the test coverage.

In `@scripts/install.sh`:
- Around line 167-169: The install record is being written to a temp file under
$tmpdir and later moved into $state_dir, which makes the final rename non-atomic
across filesystems. Update the install record flow in scripts/install.sh so the
record is created in-place under state_dir as a sibling of the final
$prev_record target, then keep the final mv -f as a same-directory atomic
rename. Use the existing records/$prev_record handling in the install logic and
remove the extra mkdir -p step that was only needed for the cross-filesystem
move.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2c49943-26cb-4610-b1d9-a2b190628ee5

📥 Commits

Reviewing files that changed from the base of the PR and between 11a6f0b and 455dd97.

📒 Files selected for processing (5)
  • .github/workflows/ci-shell-installer.yml
  • .github/workflows/release.yml
  • justfile
  • scripts/install.sh
  • scripts/install_test.sh

Comment thread scripts/install.sh
Comment thread scripts/install.sh

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
scripts/install_test.sh (1)

312-320: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicated invocation logic instead of extending run().

This block re-implements run()'s env -i ... "$SH" "$installer" invocation just to add $H1/bin to PATH, and unlike run() it doesn't capture rc. Consider adding an optional PATH-prefix parameter to run() to cover this case and keep a single source of truth for the invocation.

🤖 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 `@scripts/install_test.sh` around lines 312 - 320, The PATH advisory test block
duplicates the shell invocation logic already centralized in run(), instead of
extending that helper. Update run() to accept an optional PATH-prefix argument
so it can prepend $H1/bin when needed, keep the env -i and "$SH" "$installer"
invocation in one place, and preserve rc capture there for callers like this
PATH advisory check.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@scripts/install_test.sh`:
- Around line 312-320: The PATH advisory test block duplicates the shell
invocation logic already centralized in run(), instead of extending that helper.
Update run() to accept an optional PATH-prefix argument so it can prepend
$H1/bin when needed, keep the env -i and "$SH" "$installer" invocation in one
place, and preserve rc capture there for callers like this PATH advisory check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1d0bb65d-2103-4124-b0ed-07e77e38f07d

📥 Commits

Reviewing files that changed from the base of the PR and between 455dd97 and e4c5d90.

📒 Files selected for processing (5)
  • .github/workflows/ci-shell-installer.yml
  • .github/workflows/release.yml
  • justfile
  • scripts/install.sh
  • scripts/install_test.sh
🚧 Files skipped from review as they are similar to previous changes (2)
  • justfile
  • scripts/install.sh

@twitchyliquid64
twitchyliquid64 enabled auto-merge (squash) July 6, 2026 22:51

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
.github/workflows/release.yml (1)

520-530: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Correctly ordered before the channel update; consider env: indirection for the interpolated output.

zizmor flags the direct ${{ steps.release_info.outputs.short_sha }} expansion inside the run: block as a template-injection pattern. The value here is a deterministic hex git rev-parse --short output, so it isn't attacker-controllable — but routing GitHub Action outputs through an env: var before use in shell avoids the general anti-pattern (and matches the same opportunity at line 518, already pre-existing).

♻️ Optional hardening
             - name: Stage the installer script into the versioned folder
               run: |
+                  SHORT_SHA="$SHORT_SHA"
                   gcloud storage cp \
                     --cache-control="public, max-age=31536000, immutable" \
                     scripts/install.sh \
-                    "gs://minimal-one/versions/${{ steps.release_info.outputs.short_sha }}/install.sh"
+                    "gs://minimal-one/versions/${SHORT_SHA}/install.sh"
+              env:
+                  SHORT_SHA: ${{ steps.release_info.outputs.short_sha }}
🤖 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 520 - 530, The installer staging
step uses a direct GitHub Actions expression inside the shell command, which
zizmor flags as a template-injection pattern. Update the release workflow job
that stages the installer script by moving the
`steps.release_info.outputs.short_sha` value into an `env:` variable and
reference that variable inside the `run:` block, keeping the `gcloud storage cp`
step and its ordering unchanged. Use the existing `release_info` output and the
installer staging step as the anchors when making the change.

Source: Linters/SAST tools

scripts/install_test.sh (1)

179-196: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicated run() body for the PATH-suppression case.

The advise_on block re-implements run()'s env -i invocation just to prepend $H1/bin to PATH. Consider adding an optional PATH-prefix parameter to run() to avoid the duplication.

Also applies to: 312-320

🤖 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 `@scripts/install_test.sh` around lines 179 - 196, The PATH-suppression case
duplicates the same env -i launcher logic inside run(), so update run() to
accept an optional PATH-prefix argument and build PATH from it instead of
re-implementing the invocation in the advise_on path. Keep the existing setup in
run() (env -i, HOME, MINIMAL_* variables, STUB_UNAME_* and "$SH" "$installer")
and only parameterize the PATH value so both callers use the same function body.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In @.github/workflows/release.yml:
- Around line 520-530: The installer staging step uses a direct GitHub Actions
expression inside the shell command, which zizmor flags as a template-injection
pattern. Update the release workflow job that stages the installer script by
moving the `steps.release_info.outputs.short_sha` value into an `env:` variable
and reference that variable inside the `run:` block, keeping the `gcloud storage
cp` step and its ordering unchanged. Use the existing `release_info` output and
the installer staging step as the anchors when making the change.

In `@scripts/install_test.sh`:
- Around line 179-196: The PATH-suppression case duplicates the same env -i
launcher logic inside run(), so update run() to accept an optional PATH-prefix
argument and build PATH from it instead of re-implementing the invocation in the
advise_on path. Keep the existing setup in run() (env -i, HOME, MINIMAL_*
variables, STUB_UNAME_* and "$SH" "$installer") and only parameterize the PATH
value so both callers use the same function body.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ecce8a61-6c61-4d09-9846-a81219eff59d

📥 Commits

Reviewing files that changed from the base of the PR and between e4c5d90 and e1a2cde.

📒 Files selected for processing (5)
  • .github/workflows/ci-shell-installer.yml
  • .github/workflows/release.yml
  • justfile
  • scripts/install.sh
  • scripts/install_test.sh
🚧 Files skipped from review as they are similar to previous changes (2)
  • justfile
  • scripts/install.sh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants