Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 190 additions & 0 deletions .github/actions/setup-sfetch/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
---
# setup-sfetch — verified sfetch bootstrap for GitHub Actions
#
# Public contract (additive-only inputs after first release):
# sfetch-version (required) exact tag in this action SHA's supported range
# goneat-version (optional) exact tag; no floating default
# install-dir (optional) binary install directory
#
# Trust model:
# Consumers pin this action by commit SHA (not a moving tag). That SHA is the
# TCB for the verification engine — same model as actions/checkout.
# GitHub checks out the whole action repository for a nested composite action.
# The engine is the single canonical script at
# <action-repo>/scripts/bootstrap-sfetch-verified.sh (resolved from
# GITHUB_ACTION_PATH/../../../scripts/...). Never from GITHUB_WORKSPACE
# (consumer repo). Trust anchor is embedded in the engine; never fetched from
# the release being authenticated.
#
# Dual-route (selected by sfetch-version; machine field route= on stdout):
# >= v0.4.11 → install-sfetch.sh.minisig
# < v0.4.11 → signed SHA256SUMS + installer hash
#
# Fail-closed: no continue-on-error, no || true soft skips, no @latest tools,
# no Go toolchain requirement, no ambient minisign preference, no workspace
# engine fallback. One shared engine — this action is a thin wrapper.
#
# Supported sfetch-version range is declared by the engine revision shipped with
# this action SHA (see scripts/bootstrap-sfetch-verified.sh constants).
name: "Setup sfetch"
description: "Install a pinned, minisign-verified sfetch (optional goneat) without pipe-to-bash"
author: "3leaps"

inputs:
sfetch-version:
description: >-
Exact sfetch release tag (vMAJOR.MINOR.PATCH). Required. Never "latest". Must fall within the supported range of this action commit SHA.
required: true
goneat-version:
description: >-
Optional exact goneat release tag to install via verified sfetch after sfetch is installed. Empty means skip goneat. Never "latest".
required: false
default: ""
install-dir:
description: >-
Directory for installed binaries. Default: $HOME/.local/bin (created).
required: false
default: ""

outputs:
sfetch-bin:
description: "Absolute path to the installed sfetch binary"
value: ${{ steps.install.outputs.sfetch-bin }}
goneat-bin:
description: "Absolute path to goneat when goneat-version was set"
value: ${{ steps.install.outputs.goneat-bin }}
route:
description: "Verification route taken (minisig or sha256sums)"
value: ${{ steps.install.outputs.route }}

runs:
using: composite
steps:
- name: Verified sfetch install
id: install
shell: bash
env:
INPUT_SFETCH_VERSION: ${{ inputs.sfetch-version }}
INPUT_GONEAT_VERSION: ${{ inputs.goneat-version }}
INPUT_INSTALL_DIR: ${{ inputs.install-dir }}
run: |
set -euo pipefail

# Resolve single canonical engine inside the action repository checkout.
# Nested composite actions live at .github/actions/<name>/; the package
# root is three levels up. Never consult GITHUB_WORKSPACE (consumer).
PACKAGE_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)"
ENGINE="${PACKAGE_ROOT}/scripts/bootstrap-sfetch-verified.sh"
if [ ! -f "${ENGINE}" ] || [ ! -r "${ENGINE}" ]; then
echo "error: action-repo engine missing or unreadable: ${ENGINE}" >&2
exit 1
fi
if [ -d "${ENGINE}" ]; then
echo "error: engine path is a directory: ${ENGINE}" >&2
exit 1
fi
ENGINE="$(cd "$(dirname "${ENGINE}")" && pwd)/$(basename "${ENGINE}")"
case "${ENGINE}" in
"${PACKAGE_ROOT}"/*) ;;
*)
echo "error: engine resolved outside action repository checkout: ${ENGINE}" >&2
exit 1
;;
esac
# If workspace is a different tree, refuse to have resolved into it.
if [ -n "${GITHUB_WORKSPACE:-}" ]; then
WS_REAL="$(cd "${GITHUB_WORKSPACE}" && pwd)"
if [ "${PACKAGE_ROOT}" != "${WS_REAL}" ]; then
case "${ENGINE}" in
"${WS_REAL}"/*)
echo "error: engine resolved into consumer GITHUB_WORKSPACE" >&2
exit 1
;;
esac
fi
fi
chmod +x "${ENGINE}"

VERSION="${INPUT_SFETCH_VERSION:-}"
if [ -z "${VERSION}" ]; then
echo "error: sfetch-version is required" >&2
exit 1
fi
case "${VERSION}" in
latest|LATEST|main|master|HEAD|"")
echo "error: sfetch-version must be an exact tag (refusing ${VERSION:-empty})" >&2
exit 1
;;
esac

DIR="${INPUT_INSTALL_DIR:-}"
if [ -z "${DIR}" ]; then
DIR="${HOME}/.local/bin"
fi
mkdir -p "${DIR}"

ARGS=(--version "${VERSION}" --dir "${DIR}" --yes)
if [ -n "${INPUT_GONEAT_VERSION:-}" ]; then
case "${INPUT_GONEAT_VERSION}" in
latest|LATEST)
echo "error: goneat-version must be an exact tag (refusing ${INPUT_GONEAT_VERSION})" >&2
exit 1
;;
esac
ARGS+=(--goneat-version "${INPUT_GONEAT_VERSION}")
fi

# Capture machine stdout separately from human stderr logs.
# Engine is authoritative for version/route assertions before success.
LOG="$(mktemp)"
set +e
OUT="$("${ENGINE}" "${ARGS[@]}" 2>"${LOG}")"
RC=$?
set -e
cat "${LOG}" >&2
if [ "${RC}" -ne 0 ]; then
echo "error: verified bootstrap failed (exit ${RC})" >&2
rm -f "${LOG}"
exit "${RC}"
fi
rm -f "${LOG}"

# Machine-readable fields only: exactly one ^route= line on stdout.
# awk count — no || true soft suppression.
ROUTE_COUNT="$(printf '%s\n' "${OUT}" | awk 'BEGIN{c=0} /^route=/{c++} END{print c}')"
if [ "${ROUTE_COUNT}" -ne 1 ]; then
echo "error: expected exactly one stdout route= field, got ${ROUTE_COUNT}" >&2
exit 1
fi
ROUTE="$(printf '%s\n' "${OUT}" | awk -F= '/^route=/{print $2; exit}')"
case "${ROUTE}" in
minisig|sha256sums) ;;
*)
echo "error: invalid route value: ${ROUTE}" >&2
exit 1
;;
esac

# Prefix strip (not field-split) so install paths containing '=' survive.
SFETCH_BIN="$(printf '%s\n' "${OUT}" | sed -n 's/^sfetch-bin=//p' | head -n1)"
GONEAT_BIN="$(printf '%s\n' "${OUT}" | sed -n 's/^goneat-bin=//p' | head -n1)"

if [ -z "${SFETCH_BIN}" ] || [ ! -f "${SFETCH_BIN}" ]; then
echo "error: sfetch binary path missing after bootstrap" >&2
exit 1
fi
if [ -n "${INPUT_GONEAT_VERSION:-}" ]; then
if [ -z "${GONEAT_BIN}" ] || [ ! -f "${GONEAT_BIN}" ]; then
echo "error: goneat was requested but binary path missing after bootstrap" >&2
exit 1
fi
fi

echo "${DIR}" >> "${GITHUB_PATH}"
{
echo "sfetch-bin=${SFETCH_BIN}"
echo "goneat-bin=${GONEAT_BIN}"
echo "route=${ROUTE}"
} >> "${GITHUB_OUTPUT}"

echo "setup-sfetch complete: version=${VERSION} route=${ROUTE} sfetch-bin=${SFETCH_BIN}"
100 changes: 89 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,83 @@ jobs:
echo "Install smoke pin: $TAG (make print-sfetch-version)"
bash scripts/install-sfetch.sh --dry-run --tag "$TAG" --require-minisign

- name: Live verified bootstrap (N-1 SHA256SUMS route)
run: |
set -euo pipefail
TAG=$(make -s print-sfetch-version)
DEST="$RUNNER_TEMP/sfetch-bootstrap-live"
mkdir -p "$DEST"
./scripts/bootstrap-sfetch-verified.sh --version "$TAG" --dir "$DEST" --yes
"$DEST/sfetch" --version

# Dual-route consumer matrix: action thin-wraps the shared engine.
# v0.4.10 exercises sha256sums (backward pin). minisig route is unit-tested
# until v0.4.11 is published; after publish, add sfetch-version: v0.4.11 here.
setup-sfetch-matrix:
name: setup-sfetch (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
- os: macos-latest
- os: windows-latest
# Named org runner when available
- os: windows-latest-arm64-s
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5

- name: Setup sfetch via composite action (v0.4.10 sha256sums route)
uses: ./.github/actions/setup-sfetch
with:
sfetch-version: v0.4.10

- name: Assert sfetch on PATH
shell: bash
run: |
set -euo pipefail
command -v sfetch
# sfetch --version writes to stderr (not stdout)
sfetch --version 2>&1 | tee /tmp/sfetch-ver.txt
grep -E '0\.4\.10' /tmp/sfetch-ver.txt

- name: Fail-closed optional tool (goneat not requested)
shell: bash
run: |
set -euo pipefail
# goneat must not be soft-installed when not requested
if command -v goneat >/dev/null 2>&1; then
echo "note: ambient goneat present on runner (not installed by action)"
else
echo "goneat absent as expected when goneat-version omitted"
fi

- name: Fail-closed when requested goneat is unavailable
shell: bash
run: |
set -euo pipefail
# Request a non-existent goneat tag; engine must exit non-zero (no soft skip).
if ./scripts/bootstrap-sfetch-verified.sh \
--version v0.4.10 \
--dir "$RUNNER_TEMP/goneat-fail" \
--goneat-version v0.0.0 \
--yes; then
echo "error: requested unavailable goneat must fail closed" >&2
exit 1
fi
echo "requested-goneat failure path OK"

- name: Reject floating sfetch-version
shell: bash
run: |
set -euo pipefail
# Exercise engine reject path directly (action would also fail)
if ./scripts/bootstrap-sfetch-verified.sh --version latest --dir "$RUNNER_TEMP/bad" --yes; then
echo "error: latest must be rejected" >&2
exit 1
fi

container-probe:
name: Install probe (container)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -120,10 +197,13 @@ jobs:
with:
go-version: '1.26.5'

- name: Install minisign
shell: pwsh
# Pinned official minisign 0.12 archive (no Chocolatey/winget).
- name: Install pinned minisign 0.12
shell: bash
run: |
choco install minisign -y --no-progress
set -euo pipefail
./scripts/acquire-minisign-pinned.sh --dir "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Build sfetch
shell: pwsh
Expand Down Expand Up @@ -151,15 +231,13 @@ jobs:
with:
go-version: '1.26.5'

- name: Install minisign
shell: pwsh
# Pinned official minisign 0.12 archive (no Chocolatey/winget).
- name: Install pinned minisign 0.12
shell: bash
run: |
if (Get-Command choco -ErrorAction SilentlyContinue) {
choco install minisign -y --no-progress
} else {
winget install -e --id FrankDenis.Minisign --silent `
--accept-source-agreements --accept-package-agreements
}
set -euo pipefail
./scripts/acquire-minisign-pinned.sh --dir "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Build sfetch
shell: pwsh
Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,21 @@ jobs:
# The build matrix below appends per-platform archives to the same release.
# Maintainer signs + adds SHA256SUMS/notes afterward via scripts/
# (see README "Manual signing workflow").
- name: Create release and upload install script
# Draft until maintainer signs install-sfetch.sh + manifests and uploads
# via make release-upload. A published release without installer .minisig is
# non-consumable for verified bootstrap; draft keeps it off "latest".
- name: Create draft release and upload install script
uses: softprops/action-gh-release@v3
with:
name: sfetch ${{ github.ref_name }}
body: |
## sfetch ${{ github.ref_name }}
Auto-generated release.
draft: false
Draft release — incomplete until maintainer signs and publishes.

Do not bootstrap from this release until `install-sfetch.sh.minisig`
and signed checksum manifests are uploaded, then the release is
published (`gh release edit TAG --draft=false`).
draft: true
prerelease: false
files: scripts/install-sfetch.sh

Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.11] - 2026-07-31

### Added

- **Detached `install-sfetch.sh.minisig`** on releases. Minisign signs the installer plus checksum manifests; PGP remains manifests-only. The installer signature is **required** by `make release-verify-signatures` and `upload-release-assets` (missing/tampered/wrong-key ⇒ non-zero).
- **`scripts/bootstrap-sfetch-verified.sh`** dual-route verified bootstrap: `.minisig` for ≥ v0.4.11, signed `SHA256SUMS` for earlier pins. Refuses `latest`/floating refs; embeds the trust anchor; pins minisign 0.12 provenance (including official Windows archive hashes).
- **Composite action** `.github/actions/setup-sfetch` thin-wrapping the shared engine (pin by commit SHA).
- Release signature and bootstrap regression harnesses wired into `make precommit`.

### Changed

- Tag **release workflow creates a draft** until maintainer sign/upload/publish so pre-signature assets never become `latest`.
- **`make bootstrap`** uses the verified engine against the N-1 pin (no pipe-to-bash).
- Security and CI docs: expanded signing set, dual-route consumer guidance, trust-anchor rotation notes, `--require-minisign` CLI vs installer default asymmetry.

## [0.4.10] - 2026-07-30

### Fixed
Expand Down
Loading