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
2 changes: 2 additions & 0 deletions .github/workflows/go-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ jobs:
run: go test -count=1 ./...
- name: Run race tests
run: go test -race -count=1 ./...
- name: Test release packaging
run: ./scripts/test-package-release.sh

cross-build:
name: Build ${{ matrix.goos }} ${{ matrix.goarch }}
Expand Down
202 changes: 202 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
---
name: Release

on:
push:
tags: ["v*.*.*"]

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

jobs:
validate:
name: Validate tag and tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out tagged commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
fetch-depth: 0
fetch-tags: true
persist-credentials: false

- name: Validate strict annotated semantic tag
run: |
tag="${GITHUB_REF_NAME}"
if [[ ! "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::Tag '${tag}' is not a strict semantic version (expected vX.Y.Z)."
exit 1
fi

tag_ref="refs/tags/${tag}"
tag_type="$(git cat-file -t "$tag_ref" 2>/dev/null || true)"
if [[ "$tag_type" != "tag" ]]; then
echo "::error::Tag '${tag}' must be an annotated tag."
exit 1
fi

tag_commit="$(git rev-list -n 1 "$tag_ref")"
head_commit="$(git rev-parse HEAD)"
if [[ "$tag_commit" != "${GITHUB_SHA}" || "$head_commit" != "${GITHUB_SHA}" ]]; then
echo "::error::Tag '${tag}' (${tag_commit}) and HEAD (${head_commit}) must both resolve to event commit '${GITHUB_SHA}'."
exit 1
fi

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"

- name: Verify module files
run: |
go mod tidy -diff
go mod verify

- name: Verify formatting and whitespace
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
printf '%s\n' '::error::These files are not gofmt-clean:' "$unformatted"
exit 1
fi
git diff --check

- name: Verify release scripts
run: |
bash -n scripts/package-release.sh
bash -n scripts/test-package-release.sh

- name: Run vet
run: go vet ./...

- name: Run unit tests
run: go test -count=1 ./...

- name: Run race tests
run: go test -race -count=1 ./...

codeql:
name: CodeQL SAST Analysis
needs: validate
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
packages: read
security-events: write
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
build-mode: autobuild
languages: go

- name: Analyze Go SAST
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: /language:go

package:
name: Package release distributions
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"

- name: Build and package distributions (Windows requires sh on PATH)
run: |
./scripts/package-release.sh "${GITHUB_REF_NAME}" dist

- name: Upload package artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-artifacts
path: dist/*
if-no-files-found: error
retention-days: 1

attest:
name: Attest build provenance
needs: [validate, codeql, package]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-artifacts
path: dist

- name: Attest archives and checksum manifest
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
dist/*.tar.gz
dist/*.zip
dist/SHA256SUMS

publish:
name: Publish GitHub release
needs: [validate, codeql, package, attest]
if: github.repository == 'z-shell/zi-setup'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-artifacts
path: dist

- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release ${tag} already exists; uploading only missing assets."
existing_assets="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name')"
for asset in dist/*; do
name="${asset##*/}"
if grep -Fxq "$name" <<<"$existing_assets"; then
echo "Asset ${name} already exists; leaving it unchanged."
else
gh release upload "$tag" "$asset" --repo "$GITHUB_REPOSITORY"
fi
done
else
gh release create "$tag" dist/* \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--title "zi-setup ${tag}" \
--notes "Windows distributions require a POSIX sh on PATH, such as Git Bash or MSYS2, to execute the setup engine." \
--generate-notes
fi
35 changes: 17 additions & 18 deletions cmd/zi-setup/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,19 @@ import (
var version = "dev"

type options struct {
enginePath string
shellPath string
plain bool
headless bool
profile string
apply bool
yes bool
theme string
noColor bool
ascii bool
showVersion bool
inputs engine.Inputs
enginePath string
engineEvents bool
shellPath string
plain bool
headless bool
profile string
apply bool
yes bool
theme string
noColor bool
ascii bool
showVersion bool
inputs engine.Inputs
}

func main() {
Expand All @@ -43,17 +44,17 @@ func run(arguments []string) int {
return 2
}
if options.showVersion {
fmt.Println("zi-setup " + version)
fmt.Printf("zi-setup %s\nengine %s\n", version, engine.BundledEngineRevision)
return 0
}
client := engine.Client{EnginePath: options.enginePath, ShellPath: options.shellPath}
client := engine.Client{EnginePath: options.enginePath, ShellPath: options.shellPath, Events: options.engineEvents}
workspace, err := client.NewWorkspace(options.inputs)
if err != nil {
fmt.Fprintln(os.Stderr, presentation.SafeText(err.Error()))
return 2
}
defer workspace.Close()
session := workflow.New(workspace)
session := workflow.New(workspace, workflow.Options{Ref: options.inputs.Ref, SkipZshrc: options.inputs.SkipZshrc})
ctx := context.Background()
linear := useLinear(options, term.IsTerminal(int(os.Stdin.Fd())), term.IsTerminal(int(os.Stdout.Fd())))
if linear {
Expand Down Expand Up @@ -87,6 +88,7 @@ func parseFlags(arguments []string) (options, error) {
flags := flag.NewFlagSet("zi-setup", flag.ContinueOnError)
flags.SetOutput(os.Stderr)
flags.StringVar(&result.enginePath, "engine", os.Getenv("ZI_SETUP_ENGINE"), "path to public/sh/setup.sh")
flags.BoolVar(&result.engineEvents, "engine-events", false, "enable zi-setup-event-v1 for an external engine")
flags.StringVar(&result.shellPath, "shell", "sh", "POSIX shell used to invoke the engine")
flags.BoolVar(&result.plain, "plain", false, "use linear interactive output")
flags.BoolVar(&result.headless, "headless", false, "run without terminal control; requires --profile")
Expand Down Expand Up @@ -116,9 +118,6 @@ func parseFlags(arguments []string) (options, error) {
if result.showVersion {
return result, nil
}
if result.enginePath == "" {
return options{}, fmt.Errorf("--engine or ZI_SETUP_ENGINE is required for the local pilot")
}
switch result.profile {
case "", "loader", "annex":
default:
Expand Down
11 changes: 11 additions & 0 deletions cmd/zi-setup/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ package main

import "testing"

func TestParseFlagsUsesBundledEngineByDefault(t *testing.T) {
t.Setenv("ZI_SETUP_ENGINE", "")
got, err := parseFlags(nil)
if err != nil {
t.Fatal(err)
}
if got.enginePath != "" {
t.Fatalf("engine path = %q, want bundled engine", got.enginePath)
}
}

func TestUseLinearHonorsCommandLineIntent(t *testing.T) {
t.Parallel()
tests := []struct {
Expand Down
33 changes: 28 additions & 5 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,35 @@ Zi Setup is a terminal client for the versioned guided-setup engine in [`z-shell

This pilot supports `loader` and `annex`. A discovered `zunit` profile is shown only as preserved compatibility content.

## Local pilot
## Install

Versioned releases provide archives for Linux, macOS, and Windows on amd64 and arm64. Each release includes `SHA256SUMS` and GitHub artifact attestations. Windows execution requires a POSIX `sh` on `PATH`, such as Git Bash or MSYS2.

Download the archive for your platform from [GitHub Releases](https://github.com/z-shell/zi-setup/releases), verify it against `SHA256SUMS`, then place `zi-setup` on your `PATH`.

The binary includes a checksum-verified snapshot of the authoritative setup engine, so the normal invocation is:

```sh
zi-setup
```

Use `--version` to print both the client version and bundled `z-shell/src` revision.

## Build from source

Build the client:

```sh
go build -o bin/zi-setup ./cmd/zi-setup
```

Run it against a local checkout of the engine:
Run with the bundled engine:

```sh
bin/zi-setup
```

Override it with a local engine checkout when developing the engine contract:

```sh
bin/zi-setup --engine /path/to/src/public/sh/setup.sh
Expand All @@ -22,14 +42,17 @@ Use `--plain` for a linear keyboard interaction. For a disposable test home, pas

```sh
bin/zi-setup --plain \
--engine /path/to/src/public/sh/setup.sh \
--home /tmp/zi-setup-home \
--config-home /tmp/zi-setup-config/zi \
--zshrc /tmp/zi-setup-home/.zshrc
```

The setup engine remains the sole owner of discovery, planning, generated Zsh, precondition checks, file changes, checkout operations, and receipts.

The terminal UI offers only engine-backed choices: the `loader` or `annex` profile, the Zi Git ref, and whether setup should update `.zshrc`. The discovered `zunit` profile remains visible only as preserved compatibility content. Plain and headless modes expose the same choices as flags.

An external engine defaults to phase-level progress for backward compatibility. Pass `--engine-events` only when that engine implements `zi-setup-event-v1`.

## Verification

```sh
Expand All @@ -40,6 +63,6 @@ go vet ./...

See [architecture.md](architecture.md) for the pilot boundary and test strategy.

## Current limits
## Engine provenance

The pilot requires an explicit local `setup.sh` engine path. Release packaging, a verified engine-bundle bootstrap, streaming apply events, and additional capability choices remain separate delivery work.
The embedded files are copied byte-for-byte from a documented `z-shell/src` commit. Their SHA-256 values are compiled into the client and verified before extraction. `scripts/sync-engine.sh /path/to/src FULL_COMMIT_SHA` reads every asset from that exact commit, even when the source checkout has local changes. Maintainers then update the pinned revision and reported hashes in `internal/engine/bundle.go` and run the full checks.
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ The client uses only these versioned contracts:
- `zi-setup-describe-v1`
- `zi-setup-plan-v1`
- `zi-setup-result-v1`
- `zi-setup-event-v1`

Human-readable engine output is captured for an optional sanitized details view. It never controls a decision.

Release binaries embed an exact `z-shell/src` asset snapshot. The client verifies pinned SHA-256 values before extracting that snapshot into its private temporary workspace. An explicit `--engine` path remains available for development and compatibility testing. The embedded copy does not move setup behavior into Go; `setup.sh` remains the authority.

## Packages

- `internal/contract` reads and validates fixed artifact paths.
Expand All @@ -26,11 +29,14 @@ Review stores the exact `plan.id`. Both checkout and files phases pass it to the

Going backward discards the plan and creates a new one. No plan artifact is edited in place.

When supported, apply receives an event directory. The engine atomically publishes numbered event subdirectories while an operation runs. The client validates each directory as `zi-setup-event-v1` and may present its phase, operation, status, and detail. Missing events use the existing phase-level presentation. Process completion remains authoritative: cancellation can interrupt event publication itself, so the client tolerates a missing terminal event when its context was cancelled. Human output is never interpreted as progress.

## Safety

- Every engine argument is a distinct process argument.
- Artifact versions, IDs, order files, and restricted tokens are validated.
- Display text has terminal control bytes escaped before rendering.
- Temporary roots use private OS-created directories.
- Embedded engine assets are pinned to a source commit and verified before execution.
- Plain and TUI modes use the same workflow object and engine arguments.
- Tests use fake engines or disposable homes. Development never targets the maintainer's real shell configuration.
Loading
Loading