From 105c816ce368e6a4af52b0241b952d6398c5e58d Mon Sep 17 00:00:00 2001 From: itsfuad Date: Mon, 31 Aug 2026 00:19:38 +0600 Subject: [PATCH 1/6] Fix checkout-free release publication --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8c5dee..2321648 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -359,6 +359,7 @@ jobs: - name: Create or update draft release env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: | if gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft > "$RUNNER_TEMP/is-draft" 2>/dev/null; then test "$(cat "$RUNNER_TEMP/is-draft")" = true From 2703c7d1d76a7dafd68cdb712a1e3a2f16f917da Mon Sep 17 00:00:00 2001 From: itsfuad Date: Mon, 31 Aug 2026 01:38:05 +0600 Subject: [PATCH 2/6] Simplify release architecture and add one-command installation Bundle native runtime into compiler pack and require exactly one compiler and one toolchain per host in release manifest schema 2. Replace 24-job component release with six host-local pipelines that each fetch their immutable toolchain once, build compiler and runtime, package one host pack, and verify fresh installation before upload. Collapse sign, checksum, and draft publication into one protected finalization job and remove SBOM and provenance attestation. Add POSIX and PowerShell bootstrap installers that detect the platform, verify the native installer against SHA256SUMS, run it, and persist user PATH idempotently. Remove completed lock-path migration from the toolchain planner without changing fingerprints. --- .github/workflows/ci.yml | 6 +- .github/workflows/release-compiler.yml | 85 --------- .github/workflows/release-host.yml | 137 ++++++++++++++ .github/workflows/release-runtime.yml | 89 --------- .github/workflows/release-verify.yml | 100 ---------- .github/workflows/release.yml | 245 ++++--------------------- README.md | 38 ++-- cmd/distpack/main.go | 2 +- docs/distribution.md | 53 +++--- internal/installer/install_test.go | 8 +- pkg/distribution/release.go | 15 +- pkg/distribution/release_test.go | 23 ++- scripts/detect-changes.sh | 2 +- scripts/install.ps1 | 51 +++++ scripts/install.sh | 69 +++++++ scripts/plan-toolchains.sh | 15 +- scripts/toolchain_sources_test.go | 40 ---- 17 files changed, 373 insertions(+), 605 deletions(-) delete mode 100644 .github/workflows/release-compiler.yml create mode 100644 .github/workflows/release-host.yml delete mode 100644 .github/workflows/release-runtime.yml delete mode 100644 .github/workflows/release-verify.yml create mode 100644 scripts/install.ps1 create mode 100644 scripts/install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 628af7f..0f011bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,10 @@ jobs: go-version-file: go.mod cache: true - run: CCACHE_DISABLE=1 go test ./pkg/distribution ./internal/installer ./cmd/distpack ./cmd/distunpack ./cmd/release-index ./cmd/sign-release ./cmd/toolchain-lock + - name: Check bootstrap scripts + run: | + bash -n scripts/install.sh + pwsh -NoProfile -Command '$e=$null; [System.Management.Automation.Language.Parser]::ParseFile("scripts/install.ps1",[ref]$null,[ref]$e) | Out-Null; if ($e.Count -gt 0) { $e; exit 1 }' toolchain_config: name: Toolchain configuration @@ -187,7 +191,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Check repository scripts referenced by workflows - run: bash -n scripts/build.sh scripts/detect-changes.sh scripts/fetch-toolchain.sh scripts/plan-toolchains.sh scripts/toolchain-fingerprint.sh scripts/update-toolchain-lock.sh scripts/update-toolchain-sources.sh scripts/toolchains/common.sh scripts/toolchains/build-linux.sh scripts/toolchains/build-darwin.sh scripts/toolchains/build-windows.sh + run: bash -n scripts/build.sh scripts/detect-changes.sh scripts/fetch-toolchain.sh scripts/install.sh scripts/plan-toolchains.sh scripts/toolchain-fingerprint.sh scripts/update-toolchain-lock.sh scripts/update-toolchain-sources.sh scripts/toolchains/common.sh scripts/toolchains/build-linux.sh scripts/toolchains/build-darwin.sh scripts/toolchains/build-windows.sh ci-success: name: CI success diff --git a/.github/workflows/release-compiler.yml b/.github/workflows/release-compiler.yml deleted file mode 100644 index d585f52..0000000 --- a/.github/workflows/release-compiler.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Release compiler component - -on: - workflow_call: - inputs: - runner: - required: true - type: string - os: - required: true - type: string - arch: - required: true - type: string - version: - required: true - type: string - public-key: - required: true - type: string - -permissions: - contents: read - -jobs: - build: - name: Build ${{ inputs.os }} ${{ inputs.arch }} compiler - runs-on: ${{ inputs.runner }} - timeout-minutes: 45 - defaults: - run: - shell: bash - env: - CCACHE_DISABLE: "1" - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - cache: true - - name: Build compiler without target runtime - run: go run ./scripts/bundle.go -runtime=false - - name: Verify exact Peeper version - run: | - suffix="" - [ "${{ inputs.os }}" = windows ] && suffix=.exe - test "$(build/bin/peeper$suffix --version)" = "v${{ inputs.version }}" - - name: Stage compiler component and bootstrap installer - env: - RELEASE_PUBLIC_KEY: ${{ inputs.public-key }} - run: | - suffix="" - [ "${{ inputs.os }}" = windows ] && suffix=.exe - test -n "$RELEASE_PUBLIC_KEY" - mkdir -p stage/compiler/bin stage/compiler/libs dist - cp "build/bin/peeper$suffix" stage/compiler/bin/ - cp -R build/libs/. stage/compiler/libs/ - cp LICENSE stage/compiler/ - manifest_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${{ inputs.version }}/release-manifest.json" - go build -trimpath \ - -ldflags="-s -w -X=main.releaseManifestURL=$manifest_url -X=main.releasePublicKeyHex=$RELEASE_PUBLIC_KEY" \ - -o "dist/peeper-installer-${{ inputs.os }}-${{ inputs.arch }}$suffix" \ - ./cmd/peeper-installer - - name: Package compiler component - run: | - format=tar.gz - [ "${{ inputs.os }}" = windows ] && format=zip - id="compiler-${{ inputs.os }}-${{ inputs.arch }}-v${{ inputs.version }}" - archive="dist/$id.$format" - go run ./cmd/distpack \ - -source stage/compiler \ - -output "$archive" \ - -format "$format" \ - -kind compiler \ - -id "$id" \ - -version "${{ inputs.version }}" \ - -os "${{ inputs.os }}" \ - -arch "${{ inputs.arch }}" \ - > "$archive.json" - - uses: actions/upload-artifact@v7 - with: - name: compiler-${{ inputs.os }}-${{ inputs.arch }} - path: dist - if-no-files-found: error - compression-level: 0 diff --git a/.github/workflows/release-host.yml b/.github/workflows/release-host.yml new file mode 100644 index 0000000..0d3ceca --- /dev/null +++ b/.github/workflows/release-host.yml @@ -0,0 +1,137 @@ +name: Release host component + +on: + workflow_call: + inputs: + runner: + required: true + type: string + os: + required: true + type: string + arch: + required: true + type: string + version: + required: true + type: string + public-key: + required: true + type: string + minimum-macos: + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + build: + name: Build and verify ${{ inputs.os }} ${{ inputs.arch }} + runs-on: ${{ inputs.runner }} + timeout-minutes: 75 + defaults: + run: + shell: bash + env: + CCACHE_DISABLE: "1" + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + - name: Build compiler without host runtime + run: go run ./scripts/bundle.go -runtime=false + - name: Verify exact Peeper version + run: | + suffix="" + [ "${{ inputs.os }}" = windows ] && suffix=.exe + test "$(build/bin/peeper$suffix --version)" = "v${{ inputs.version }}" + - name: Fetch verified immutable toolchain + run: bash scripts/fetch-toolchain.sh "${{ inputs.os }}" "${{ inputs.arch }}" installation + - name: Build native runtime + run: | + case "${{ inputs.os }}/${{ inputs.arch }}" in + linux/amd64) triple=x86_64-unknown-linux-musl ;; + linux/arm64) triple=aarch64-unknown-linux-musl ;; + darwin/amd64) triple=x86_64-apple-darwin ;; + darwin/arm64) triple=aarch64-apple-darwin ;; + windows/amd64) triple=x86_64-w64-windows-gnu ;; + windows/arm64) triple=aarch64-w64-windows-gnu ;; + *) exit 2 ;; + esac + suffix="" + [ "${{ inputs.os }}" = windows ] && suffix=.exe + clang="$GITHUB_WORKSPACE/installation/toolchains/native/bin/clang$suffix" + archiver="$GITHUB_WORKSPACE/installation/toolchains/native/bin/llvm-ar$suffix" + runtime_dir="stage/compiler/targets/$triple/lib" + mkdir -p "$runtime_dir" + runtime_args=(-std=c11 -O2 -target "$triple") + if [ "${{ inputs.os }}" = linux ]; then + runtime_args+=(--sysroot "$GITHUB_WORKSPACE/installation/toolchains/native/sysroot") + elif [ "${{ inputs.os }}" = darwin ]; then + runtime_args+=(--sysroot "$(xcrun --sdk macosx --show-sdk-path)" "-mmacosx-version-min=${{ inputs.minimum-macos }}") + fi + "$clang" "${runtime_args[@]}" -c runtime/peeper_rt.c -o "$RUNNER_TEMP/peeper_rt.o" + "$archiver" rcs "$runtime_dir/libpeeper_rt_v1.a" "$RUNNER_TEMP/peeper_rt.o" + - name: Stage compiler component and bootstrap installer + env: + RELEASE_PUBLIC_KEY: ${{ inputs.public-key }} + run: | + suffix="" + [ "${{ inputs.os }}" = windows ] && suffix=.exe + test -n "$RELEASE_PUBLIC_KEY" + mkdir -p stage/compiler/bin stage/compiler/libs dist + cp "build/bin/peeper$suffix" stage/compiler/bin/ + cp -R build/libs/. stage/compiler/libs/ + cp LICENSE stage/compiler/ + manifest_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${{ inputs.version }}/release-manifest.json" + go build -trimpath \ + -ldflags="-s -w -X=main.releaseManifestURL=$manifest_url -X=main.releasePublicKeyHex=$RELEASE_PUBLIC_KEY" \ + -o "dist/peeper-installer-${{ inputs.os }}-${{ inputs.arch }}$suffix" \ + ./cmd/peeper-installer + - name: Package compiler component + run: | + format=tar.gz + [ "${{ inputs.os }}" = windows ] && format=zip + id="compiler-${{ inputs.os }}-${{ inputs.arch }}-v${{ inputs.version }}" + archive="dist/$id.$format" + go run ./cmd/distpack \ + -source stage/compiler \ + -output "$archive" \ + -format "$format" \ + -kind compiler \ + -id "$id" \ + -version "${{ inputs.version }}" \ + -os "${{ inputs.os }}" \ + -arch "${{ inputs.arch }}" \ + > "$archive.json" + - name: Extract and verify final component + run: | + shopt -s nullglob + records=(dist/*.tar.gz.json dist/*.zip.json) + test "${#records[@]}" -eq 1 + record="${records[0]}" + archive="${record%.json}" + go run ./cmd/distunpack \ + -archive "$archive" \ + -format "$(jq -er .format "$record")" \ + -destination installation \ + -kind "$(jq -er .metadata.kind "$record")" \ + -id "$(jq -er .metadata.id "$record")" \ + -version "$(jq -er .metadata.version "$record")" \ + -os "${{ inputs.os }}" \ + -arch "${{ inputs.arch }}" + suffix="" + [ "${{ inputs.os }}" = windows ] && suffix=.exe + compiler="$GITHUB_WORKSPACE/installation/bin/peeper$suffix" + "$compiler" doctor --json + PEEPER_BIN="$compiler" go test -count=1 ./x_test + - uses: actions/upload-artifact@v7 + with: + name: release-host-${{ inputs.os }}-${{ inputs.arch }} + path: dist + if-no-files-found: error + compression-level: 0 diff --git a/.github/workflows/release-runtime.yml b/.github/workflows/release-runtime.yml deleted file mode 100644 index 0038165..0000000 --- a/.github/workflows/release-runtime.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Release target runtime component - -on: - workflow_call: - inputs: - runner: - required: true - type: string - os: - required: true - type: string - arch: - required: true - type: string - version: - required: true - type: string - minimum-macos: - required: false - type: string - default: "" - -permissions: - contents: read - -jobs: - build: - name: Build ${{ inputs.os }} ${{ inputs.arch }} runtime - runs-on: ${{ inputs.runner }} - timeout-minutes: 60 - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - cache: true - - name: Fetch verified immutable toolchain - run: bash scripts/fetch-toolchain.sh "${{ inputs.os }}" "${{ inputs.arch }}" toolchain-install - - name: Build target runtime - run: | - case "${{ inputs.os }}/${{ inputs.arch }}" in - linux/amd64) triple=x86_64-unknown-linux-musl ;; - linux/arm64) triple=aarch64-unknown-linux-musl ;; - darwin/amd64) triple=x86_64-apple-darwin ;; - darwin/arm64) triple=aarch64-apple-darwin ;; - windows/amd64) triple=x86_64-w64-windows-gnu ;; - windows/arm64) triple=aarch64-w64-windows-gnu ;; - *) exit 2 ;; - esac - suffix="" - [ "${{ inputs.os }}" = windows ] && suffix=.exe - clang="$GITHUB_WORKSPACE/toolchain-install/toolchains/native/bin/clang$suffix" - archiver="$GITHUB_WORKSPACE/toolchain-install/toolchains/native/bin/llvm-ar$suffix" - runtime_dir="stage/targets/$triple/lib" - mkdir -p "$runtime_dir" - runtime_args=(-std=c11 -O2 -target "$triple") - if [ "${{ inputs.os }}" = linux ]; then - runtime_args+=(--sysroot "$GITHUB_WORKSPACE/toolchain-install/toolchains/native/sysroot") - elif [ "${{ inputs.os }}" = darwin ]; then - runtime_args+=(--sysroot "$(xcrun --sdk macosx --show-sdk-path)" "-mmacosx-version-min=${{ inputs.minimum-macos }}") - fi - "$clang" "${runtime_args[@]}" -c runtime/peeper_rt.c -o "$RUNNER_TEMP/peeper_rt.o" - "$archiver" rcs "$runtime_dir/libpeeper_rt_v1.a" "$RUNNER_TEMP/peeper_rt.o" - - name: Package target component - run: | - format=tar.gz - [ "${{ inputs.os }}" = windows ] && format=zip - id="target-${{ inputs.os }}-${{ inputs.arch }}-v${{ inputs.version }}" - archive="dist/$id.$format" - mkdir -p dist - go run ./cmd/distpack \ - -source stage \ - -output "$archive" \ - -format "$format" \ - -kind target \ - -id "$id" \ - -version "${{ inputs.version }}" \ - -os "${{ inputs.os }}" \ - -arch "${{ inputs.arch }}" \ - > "$archive.json" - - uses: actions/upload-artifact@v7 - with: - name: target-${{ inputs.os }}-${{ inputs.arch }} - path: dist - if-no-files-found: error - compression-level: 0 diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml deleted file mode 100644 index b5cac35..0000000 --- a/.github/workflows/release-verify.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Verify Peeper component installation - -on: - workflow_call: - inputs: - runner: - required: true - type: string - os: - required: true - type: string - arch: - required: true - type: string - -permissions: - contents: read - -jobs: - verify: - name: Verify ${{ inputs.os }} ${{ inputs.arch }} installation - runs-on: ${{ inputs.runner }} - timeout-minutes: 60 - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - cache: true - - uses: actions/download-artifact@v8 - with: - name: compiler-${{ inputs.os }}-${{ inputs.arch }} - path: components/compiler - - uses: actions/download-artifact@v8 - with: - name: target-${{ inputs.os }}-${{ inputs.arch }} - path: components/target - - name: Compose fresh installation from immutable components - run: | - installation="$GITHUB_WORKSPACE/installation" - bash scripts/fetch-toolchain.sh "${{ inputs.os }}" "${{ inputs.arch }}" "$installation" - shopt -s nullglob - for kind in compiler target; do - records=("components/$kind"/*.tar.gz.json "components/$kind"/*.zip.json) - test "${#records[@]}" -eq 1 - record="${records[0]}" - archive="${record%.json}" - go run ./cmd/distunpack \ - -archive "$archive" \ - -format "$(jq -er .format "$record")" \ - -destination "$installation" \ - -kind "$(jq -er .metadata.kind "$record")" \ - -id "$(jq -er .metadata.id "$record")" \ - -version "$(jq -er .metadata.version "$record")" \ - -os "${{ inputs.os }}" \ - -arch "${{ inputs.arch }}" - done - - name: Diagnose fresh installation and run source fixtures - run: | - suffix="" - [ "${{ inputs.os }}" = windows ] && suffix=.exe - compiler="$GITHUB_WORKSPACE/installation/bin/peeper$suffix" - "$compiler" doctor --json - PEEPER_BIN="$compiler" go test -count=1 ./x_test - - name: Prepare SBOM output - run: mkdir -p dist - - name: Generate SPDX SBOM from fresh installation - if: inputs.os != 'windows' || inputs.arch != 'arm64' - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 - with: - path: installation - format: spdx-json - output-file: dist/peeper-${{ inputs.os }}-${{ inputs.arch }}.spdx.json - upload-artifact: false - upload-release-assets: false - - name: Generate Windows arm64 SPDX SBOM from fresh installation - if: inputs.os == 'windows' && inputs.arch == 'arm64' - env: - SYFT_VERSION: 1.42.3 - run: | - archive="syft_${SYFT_VERSION}_windows_arm64.zip" - release="https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}" - curl --fail --location --retry 3 "$release/$archive" -o "$RUNNER_TEMP/$archive" - curl --fail --location --retry 3 "$release/syft_${SYFT_VERSION}_checksums.txt" -o "$RUNNER_TEMP/syft-checksums.txt" - awk -v archive="$archive" '$2 == archive { print; found=1 } END { exit !found }' \ - "$RUNNER_TEMP/syft-checksums.txt" > "$RUNNER_TEMP/syft-checksum.txt" - (cd "$RUNNER_TEMP" && sha256sum -c syft-checksum.txt) - mkdir -p "$RUNNER_TEMP/syft" - 7z x -y -o"$RUNNER_TEMP/syft" "$RUNNER_TEMP/$archive" - "$RUNNER_TEMP/syft/syft.exe" scan dir:installation \ - -o "spdx-json=dist/peeper-${{ inputs.os }}-${{ inputs.arch }}.spdx.json" - - uses: actions/upload-artifact@v7 - with: - name: verify-${{ inputs.os }}-${{ inputs.arch }} - path: dist - if-no-files-found: error - compression-level: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2321648..e1bae30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,10 +35,10 @@ jobs: done echo "value=$version" >> "$GITHUB_OUTPUT" - compiler_linux_amd64: - name: Compiler Linux amd64 + host_linux_amd64: + name: Host Linux amd64 needs: preflight - uses: ./.github/workflows/release-compiler.yml + uses: ./.github/workflows/release-host.yml with: runner: ubuntu-24.04 os: linux @@ -46,10 +46,10 @@ jobs: version: ${{ needs.preflight.outputs.version }} public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} - compiler_linux_arm64: - name: Compiler Linux arm64 + host_linux_arm64: + name: Host Linux arm64 needs: preflight - uses: ./.github/workflows/release-compiler.yml + uses: ./.github/workflows/release-host.yml with: runner: ubuntu-24.04-arm os: linux @@ -57,32 +57,34 @@ jobs: version: ${{ needs.preflight.outputs.version }} public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} - compiler_darwin_amd64: - name: Compiler macOS amd64 + host_darwin_amd64: + name: Host macOS amd64 needs: preflight - uses: ./.github/workflows/release-compiler.yml + uses: ./.github/workflows/release-host.yml with: runner: macos-15-intel os: darwin arch: amd64 version: ${{ needs.preflight.outputs.version }} public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} + minimum-macos: "13.0" - compiler_darwin_arm64: - name: Compiler macOS arm64 + host_darwin_arm64: + name: Host macOS arm64 needs: preflight - uses: ./.github/workflows/release-compiler.yml + uses: ./.github/workflows/release-host.yml with: runner: macos-15 os: darwin arch: arm64 version: ${{ needs.preflight.outputs.version }} public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} + minimum-macos: "13.0" - compiler_windows_amd64: - name: Compiler Windows amd64 + host_windows_amd64: + name: Host Windows amd64 needs: preflight - uses: ./.github/workflows/release-compiler.yml + uses: ./.github/workflows/release-host.yml with: runner: windows-2025 os: windows @@ -90,10 +92,10 @@ jobs: version: ${{ needs.preflight.outputs.version }} public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} - compiler_windows_arm64: - name: Compiler Windows arm64 + host_windows_arm64: + name: Host Windows arm64 needs: preflight - uses: ./.github/workflows/release-compiler.yml + uses: ./.github/workflows/release-host.yml with: runner: windows-11-arm os: windows @@ -101,132 +103,16 @@ jobs: version: ${{ needs.preflight.outputs.version }} public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} - runtime_linux_amd64: - name: Runtime Linux amd64 - needs: preflight - uses: ./.github/workflows/release-runtime.yml - with: - runner: ubuntu-24.04 - os: linux - arch: amd64 - version: ${{ needs.preflight.outputs.version }} - - runtime_linux_arm64: - name: Runtime Linux arm64 - needs: preflight - uses: ./.github/workflows/release-runtime.yml - with: - runner: ubuntu-24.04-arm - os: linux - arch: arm64 - version: ${{ needs.preflight.outputs.version }} - - runtime_darwin_amd64: - name: Runtime macOS amd64 - needs: preflight - uses: ./.github/workflows/release-runtime.yml - with: - runner: macos-15-intel - os: darwin - arch: amd64 - version: ${{ needs.preflight.outputs.version }} - minimum-macos: "13.0" - - runtime_darwin_arm64: - name: Runtime macOS arm64 - needs: preflight - uses: ./.github/workflows/release-runtime.yml - with: - runner: macos-15 - os: darwin - arch: arm64 - version: ${{ needs.preflight.outputs.version }} - minimum-macos: "13.0" - - runtime_windows_amd64: - name: Runtime Windows amd64 - needs: preflight - uses: ./.github/workflows/release-runtime.yml - with: - runner: windows-2025 - os: windows - arch: amd64 - version: ${{ needs.preflight.outputs.version }} - - runtime_windows_arm64: - name: Runtime Windows arm64 - needs: preflight - uses: ./.github/workflows/release-runtime.yml - with: - runner: windows-11-arm - os: windows - arch: arm64 - version: ${{ needs.preflight.outputs.version }} - - verify_linux_amd64: - name: Verify Linux amd64 - needs: [preflight, compiler_linux_amd64, runtime_linux_amd64] - uses: ./.github/workflows/release-verify.yml - with: - runner: ubuntu-24.04 - os: linux - arch: amd64 - - verify_linux_arm64: - name: Verify Linux arm64 - needs: [preflight, compiler_linux_arm64, runtime_linux_arm64] - uses: ./.github/workflows/release-verify.yml - with: - runner: ubuntu-24.04-arm - os: linux - arch: arm64 - - verify_darwin_amd64: - name: Verify macOS amd64 - needs: [preflight, compiler_darwin_amd64, runtime_darwin_amd64] - uses: ./.github/workflows/release-verify.yml - with: - runner: macos-15-intel - os: darwin - arch: amd64 - - verify_darwin_arm64: - name: Verify macOS arm64 - needs: [preflight, compiler_darwin_arm64, runtime_darwin_arm64] - uses: ./.github/workflows/release-verify.yml - with: - runner: macos-15 - os: darwin - arch: arm64 - - verify_windows_amd64: - name: Verify Windows amd64 - needs: [preflight, compiler_windows_amd64, runtime_windows_amd64] - uses: ./.github/workflows/release-verify.yml - with: - runner: windows-2025 - os: windows - arch: amd64 - - verify_windows_arm64: - name: Verify Windows arm64 - needs: [preflight, compiler_windows_arm64, runtime_windows_arm64] - uses: ./.github/workflows/release-verify.yml - with: - runner: windows-11-arm - os: windows - arch: arm64 - assemble_release: name: Assemble release needs: - preflight - - verify_linux_amd64 - - verify_linux_arm64 - - verify_darwin_amd64 - - verify_darwin_arm64 - - verify_windows_amd64 - - verify_windows_arm64 + - host_linux_amd64 + - host_linux_arm64 + - host_darwin_amd64 + - host_darwin_arm64 + - host_windows_amd64 + - host_windows_arm64 runs-on: ubuntu-24.04 permissions: contents: read @@ -238,17 +124,7 @@ jobs: cache: true - uses: actions/download-artifact@v8 with: - pattern: compiler-* - path: dist - merge-multiple: true - - uses: actions/download-artifact@v8 - with: - pattern: target-* - path: dist - merge-multiple: true - - uses: actions/download-artifact@v8 - with: - pattern: verify-* + pattern: release-host-* path: dist merge-multiple: true - name: Build unsigned release manifest @@ -264,8 +140,9 @@ jobs: dist/*.tar.gz.json dist/*.zip.json \ > release-assets/release-manifest.json find dist -maxdepth 1 -type f \ - \( -name '*.tar.gz' -o -name '*.zip' -o -name 'peeper-installer-*' -o -name '*.spdx.json' \) \ + \( -name '*.tar.gz' -o -name '*.zip' -o -name 'peeper-installer-*' \) \ -exec cp {} release-assets/ \; + cp scripts/install.sh scripts/install.ps1 release-assets/ - uses: actions/upload-artifact@v7 with: name: release-assembled @@ -273,13 +150,13 @@ jobs: if-no-files-found: error compression-level: 0 - sign_release: - name: Sign release manifest + finalize_release: + name: Finalize release needs: assemble_release runs-on: ubuntu-24.04 environment: release permissions: - contents: read + contents: write steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v7 @@ -294,68 +171,12 @@ jobs: env: PEEPER_RELEASE_PRIVATE_KEY: ${{ secrets.PEEPER_RELEASE_PRIVATE_KEY }} run: go run ./cmd/sign-release release-assets/release-manifest.json > release-assets/release-manifest.json.sig - - uses: actions/upload-artifact@v7 - with: - name: release-signed - path: release-assets - if-no-files-found: error - compression-level: 0 - - checksums: - name: Generate release checksums - needs: sign_release - runs-on: ubuntu-24.04 - permissions: - contents: read - steps: - - uses: actions/download-artifact@v8 - with: - name: release-signed - path: release-assets - name: Generate SHA256SUMS run: | ( cd release-assets find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%f\n' | sort | xargs sha256sum > SHA256SUMS ) - - uses: actions/upload-artifact@v7 - with: - name: release-checksummed - path: release-assets - if-no-files-found: error - compression-level: 0 - - attest: - name: Attest release provenance - needs: checksums - runs-on: ubuntu-24.04 - permissions: - actions: read - artifact-metadata: write - attestations: write - contents: read - id-token: write - steps: - - uses: actions/download-artifact@v8 - with: - name: release-checksummed - path: release-assets - - uses: actions/attest@v4 - with: - subject-path: release-assets/* - - publish_draft: - name: Publish draft release - needs: attest - runs-on: ubuntu-24.04 - environment: release - permissions: - contents: write - steps: - - uses: actions/download-artifact@v8 - with: - name: release-checksummed - path: release-assets - name: Create or update draft release env: GH_TOKEN: ${{ github.token }} @@ -364,6 +185,6 @@ jobs: if gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft > "$RUNNER_TEMP/is-draft" 2>/dev/null; then test "$(cat "$RUNNER_TEMP/is-draft")" = true else - gh release create "$GITHUB_REF_NAME" --draft --verify-tag --title "Peeper $GITHUB_REF_NAME" --notes "Open source release candidate signed with a self-managed Ed25519 key. Verify installers, checksums, SBOMs, and GitHub attestations." + gh release create "$GITHUB_REF_NAME" --draft --verify-tag --title "Peeper $GITHUB_REF_NAME" --notes "Open source release candidate signed with a self-managed Ed25519 key. Verify installers and checksums." fi gh release upload "$GITHUB_REF_NAME" release-assets/* --clobber diff --git a/README.md b/README.md index eca21f4..eea1b0b 100644 --- a/README.md +++ b/README.md @@ -21,20 +21,30 @@ unfinished language and runtime work. ## Binary installation -Signed release candidates target Linux, macOS, and Windows on `amd64` and -`arm64`. Download matching `peeper-installer--` asset from a -published release, verify it against `SHA256SUMS`, then run it. Windows assets -use `.exe`. - -Installer verifies signed release manifest, downloads compiler, native target, -and managed LLVM packs, validates every size and SHA-256 digest, then activates -complete installation atomically. It prints directory to add to `PATH`; it does -not modify shell or registry configuration. - -Installed distributions include Clang, linker, target runtime, and standard -libraries. macOS cannot redistribute Apple SDK, so Command Line Tools and -working `xcrun --sdk macosx --show-sdk-path` remain required. Run -`peeper doctor` after installation. +Linux and macOS: + +``` +curl --proto '=https' --tlsv1.2 -fsSL https://github.com/PeeperLanguage/compiler/releases/latest/download/install.sh | sh +``` + +Windows PowerShell: + +``` +irm https://github.com/PeeperLanguage/compiler/releases/latest/download/install.ps1 | iex +``` + +Bootstrap scripts detect `amd64` or `arm64` automatically, download the +matching native installer from the latest published release, verify it against +`SHA256SUMS`, and run it. macOS running under Rosetta installs the native +`arm64` build. macOS requires Command Line Tools and working +`xcrun --sdk macosx --show-sdk-path`. + +Installer verifies signed release manifest, downloads compiler and managed +LLVM packs, validates every size and SHA-256 digest, then activates complete +installation atomically. Bootstrap scripts persist the Peeper binary directory +in the user PATH idempotently; restart your terminal afterward, because a piped +shell cannot modify its parent environment. Run `peeper doctor` after +installation. See [`docs/distribution.md`](docs/distribution.md) for support policy, release security, and maintainer setup. diff --git a/cmd/distpack/main.go b/cmd/distpack/main.go index 9f43d46..daba534 100644 --- a/cmd/distpack/main.go +++ b/cmd/distpack/main.go @@ -13,7 +13,7 @@ func main() { source := flag.String("source", "", "staged pack root") output := flag.String("output", "", "archive output path") format := flag.String("format", "", "archive format: tar.gz or zip") - kind := flag.String("kind", "", "pack kind: compiler, target, or toolchain") + kind := flag.String("kind", "", "pack kind: compiler or toolchain") id := flag.String("id", "", "immutable pack identifier") version := flag.String("version", "", "pack version") targetOS := flag.String("os", "", "host operating system") diff --git a/docs/distribution.md b/docs/distribution.md index 6bcba5b..c7b4b78 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -20,22 +20,24 @@ toolchain and standard-library dependencies are included in release packs. ## Release contents -Each host install combines exactly three independently verified packs: +Each host install combines exactly two independently verified packs: -- compiler: native `peeper` executable, bundled Peeper libraries, and license; -- target: versioned `peeper_rt_v1` runtime archive for host target triple; +- compiler: native `peeper` executable, bundled Peeper libraries, versioned + `peeper_rt_v1` runtime archive for host target triple, and license; - toolchain: managed Clang/linker, required sysroot, licenses, and `toolchains/native/profile.json`. -Native bootstrap is separate release asset. Signed release manifest binds each -pack ID, version, URL, archive format, byte length, and SHA-256 digest to one -host install set. Installer verifies Ed25519 signature before parsing manifest, -downloads only HTTPS assets, rejects unsafe archive paths and links, stages on -destination filesystem, validates managed profile, then atomically activates or -rolls back. +Bootstrap install scripts are separate release assets. Signed release manifest +binds each pack ID, version, URL, archive format, byte length, and SHA-256 +digest to one host install set. Installer verifies Ed25519 signature before +parsing manifest, downloads only HTTPS assets, rejects unsafe archive paths and +links, stages on destination filesystem, validates managed profile, then +atomically activates or rolls back. Installer intentionally leaves `PATH` changes to user. Default install root is `~/.peeper` on Unix-like systems and `%LOCALAPPDATA%\Peeper` on Windows. +Bootstrap install scripts persist the binary directory in the user PATH +idempotently after installation. ## CI and release gates @@ -49,16 +51,18 @@ Tag workflow performs these additional gates: 1. Validate tag, version, public key, finished toolchain lock, and focused distribution/toolchain tests in cheap Linux preflight. -2. Fan out to six compiler jobs and six runtime jobs in parallel. Runtime jobs - download, size-check, SHA-256-check, and securely extract published - toolchain components; they do not build LLVM, musl, or llvm-mingw. -3. Each target-specific verify job composes fresh compiler, target, and - toolchain packs, runs doctor and source fixtures, and generates its SPDX SBOM. -4. Require all six verify jobs, then assemble, sign manifest, generate - `SHA256SUMS`, attest provenance, and upload draft release. - -Build jobs receive no signing secrets. Only protected `release` signing job -receives Ed25519 private key. +2. Fan out to six host jobs in parallel. Each host job builds the compiler, + fetches and verifies its published immutable toolchain once, builds the + native runtime, packages one compiler pack, then extracts the pack and + toolchain into a fresh root and runs `peeper doctor` and source fixtures. + Host jobs do not build LLVM, musl, or llvm-mingw. +3. Require all six host jobs, then assemble the unsigned manifest and copy + host packs, native installers, and bootstrap scripts into release assets. +4. One protected finalization job signs the manifest, generates `SHA256SUMS`, + and creates or updates the draft release. + +Build and assembly jobs receive no signing secrets. Only protected `release` +finalization job receives Ed25519 private key. ## Toolchain production and bootstrap @@ -116,14 +120,13 @@ copy. 2. Run full local validation and merge clean review. 3. Create and push signed tag `v`. 4. Approve protected release environment. -5. Inspect draft assets: six bootstraps, 12 Peeper compiler/target packs, six - SBOMs, signed manifest, checksums, and provenance attestation. Toolchains +5. Inspect draft assets: six host packs, six native installers, two bootstrap + scripts, signed manifest, manifest signature, and `SHA256SUMS`. Toolchains remain referenced immutable component assets, not duplicate release assets. -6. Verify checksums and `gh attestation verify` on downloaded assets. +6. Verify checksums on downloaded assets. 7. Install on clean host for each supported pair; run `peeper doctor`, then compile and run source project with network unavailable. 8. Publish draft only after all checks pass. -Failed platform, signing, SBOM, manifest, or attestation gate -prevents draft creation. Existing published release is never overwritten by -workflow rerun. +Failed platform, signing, manifest, or checksum gate prevents draft creation. +Existing published release is never overwritten by workflow rerun. diff --git a/internal/installer/install_test.go b/internal/installer/install_test.go index 60069ff..1c5e81c 100644 --- a/internal/installer/install_test.go +++ b/internal/installer/install_test.go @@ -118,8 +118,6 @@ func newReleaseFixture(t *testing.T) *releaseFixture { fixture.writePack(t, "/compiler.tar.gz", distribution.Metadata{Kind: distribution.PackKindCompiler, ID: "compiler-host", Version: "0.2.0", OS: host.OS, Arch: host.Arch}, func(root string) { writeFixtureFile(t, filepath.Join(root, "bin", "peeper"+target.ExecutableExt(host.OS)), "compiler", 0o755) writeFixtureFile(t, filepath.Join(root, "libs", "core", "src", "global.peep"), "core", 0o644) - }), - fixture.writePack(t, "/target.tar.gz", distribution.Metadata{Kind: distribution.PackKindTarget, ID: "target-host", Version: "0.2.0", OS: host.OS, Arch: host.Arch}, func(root string) { writeFixtureFile(t, filepath.Join(root, "targets", host.LLVMTriple, "lib", "libpeeper_rt_v1.a"), "runtime", 0o644) }), fixture.writePack(t, "/toolchain.tar.gz", distribution.Metadata{Kind: distribution.PackKindToolchain, ID: "toolchain-host", Version: "23.1.0", OS: host.OS, Arch: host.Arch}, func(root string) { @@ -146,15 +144,15 @@ func newReleaseFixture(t *testing.T) *releaseFixture { }), } for i := range components { - data := fixture.responses[[]string{"/compiler.tar.gz", "/target.tar.gz", "/toolchain.tar.gz"}[i]] - components[i].URL = fixture.server.URL + []string{"/compiler.tar.gz", "/target.tar.gz", "/toolchain.tar.gz"}[i] + data := fixture.responses[[]string{"/compiler.tar.gz", "/toolchain.tar.gz"}[i]] + components[i].URL = fixture.server.URL + []string{"/compiler.tar.gz", "/toolchain.tar.gz"}[i] components[i].Size = int64(len(data)) components[i].SHA256 = digest(data) components[i].Format = distribution.FormatTarGz } fixture.manifest = distribution.ReleaseManifest{ SchemaVersion: distribution.ReleaseManifestVersion, Version: "0.2.0", Components: components, - InstallSets: []distribution.InstallSet{{OS: host.OS, Arch: host.Arch, Components: []string{"compiler-host", "target-host", "toolchain-host"}}}, + InstallSets: []distribution.InstallSet{{OS: host.OS, Arch: host.Arch, Components: []string{"compiler-host", "toolchain-host"}}}, } fixture.signManifest(t) return fixture diff --git a/pkg/distribution/release.go b/pkg/distribution/release.go index 7a550c5..cdc2cc5 100644 --- a/pkg/distribution/release.go +++ b/pkg/distribution/release.go @@ -15,9 +15,8 @@ import ( ) const ( - ReleaseManifestVersion = 1 + ReleaseManifestVersion = 2 PackKindCompiler = "compiler" - PackKindTarget = "target" PackKindToolchain = "toolchain" ) @@ -126,8 +125,8 @@ func BuildReleaseManifest(version, baseURL string, artifacts []ReleaseArtifact, manifest.InstallSets = make([]InstallSet, 0, len(supportedReleaseHosts)) for _, host := range supportedReleaseHosts { componentIDs := componentsByHost[host] - if len(componentIDs) != 3 { - return ReleaseManifest{}, fmt.Errorf("release requires exactly three components for %s/%s", host.os, host.arch) + if len(componentIDs) != 2 { + return ReleaseManifest{}, fmt.Errorf("release requires exactly two components for %s/%s", host.os, host.arch) } manifest.InstallSets = append(manifest.InstallSets, InstallSet{OS: host.os, Arch: host.arch, Components: componentIDs}) } @@ -201,7 +200,7 @@ func validateReleaseManifest(manifest ReleaseManifest) (map[releaseHost][]Releas if _, exists := installSets[host]; exists { return nil, fmt.Errorf("release manifest repeats install set for %s/%s", host.os, host.arch) } - byKind := make(map[string]ReleaseComponent, 3) + byKind := make(map[string]ReleaseComponent, 2) for _, componentID := range installSet.Components { component, ok := components[componentID] if !ok { @@ -219,8 +218,8 @@ func validateReleaseManifest(manifest ReleaseManifest) (map[releaseHost][]Releas byKind[component.Kind] = component referenced[componentID] = true } - ordered := make([]ReleaseComponent, 0, 3) - for _, kind := range []string{PackKindCompiler, PackKindTarget, PackKindToolchain} { + ordered := make([]ReleaseComponent, 0, 2) + for _, kind := range []string{PackKindCompiler, PackKindToolchain} { component, ok := byKind[kind] if !ok { return nil, fmt.Errorf("release set requires exactly one %s component", kind) @@ -247,7 +246,7 @@ func validateReleaseComponent(component ReleaseComponent) error { return fmt.Errorf("release component %q has no %s", component.ID, field[0]) } } - if component.Kind != PackKindCompiler && component.Kind != PackKindTarget && component.Kind != PackKindToolchain { + if component.Kind != PackKindCompiler && component.Kind != PackKindToolchain { return fmt.Errorf("release component %q has unsupported kind %q", component.ID, component.Kind) } parsedURL, err := url.Parse(component.URL) diff --git a/pkg/distribution/release_test.go b/pkg/distribution/release_test.go index 720a30b..cf6eca1 100644 --- a/pkg/distribution/release_test.go +++ b/pkg/distribution/release_test.go @@ -25,10 +25,10 @@ func TestVerifyReleaseManifestSelectsCompleteHostSet(t *testing.T) { if err != nil { t.Fatalf("VerifyReleaseManifest() error = %v", err) } - if verified.Version != "0.2.0" || len(components) != 3 { + if verified.Version != "0.2.0" || len(components) != 2 { t.Fatalf("verified release = %#v, components = %#v", verified, components) } - for i, kind := range []string{PackKindCompiler, PackKindTarget, PackKindToolchain} { + for i, kind := range []string{PackKindCompiler, PackKindToolchain} { if components[i].Kind != kind { t.Fatalf("component %d kind = %q", i, components[i].Kind) } @@ -40,7 +40,7 @@ func TestVerifyReleaseManifestRejectsInvalidSignatureBeforeJSON(t *testing.T) { if err != nil { t.Fatal(err) } - _, _, err = VerifyReleaseManifest([]byte(`{"schema_version":1}`), []byte(base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize))), publicKey, "linux", "amd64") + _, _, err = VerifyReleaseManifest([]byte(`{"schema_version":2}`), []byte(base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize))), publicKey, "linux", "amd64") if err == nil || !strings.Contains(err.Error(), "signature") { t.Fatalf("VerifyReleaseManifest() error = %v", err) } @@ -60,10 +60,10 @@ func TestVerifyReleaseManifestRejectsIncompleteOrUnsupportedSet(t *testing.T) { }{ {name: "unsupported", mutate: func(*ReleaseManifest) {}, hostOS: "darwin", hostArch: "arm64", want: "no release set"}, {name: "missing kind", mutate: func(manifest *ReleaseManifest) { - manifest.InstallSets[0].Components = manifest.InstallSets[0].Components[:2] + manifest.InstallSets[0].Components = manifest.InstallSets[0].Components[:1] }, hostOS: "linux", hostArch: "amd64", want: "requires exactly one"}, - {name: "duplicate kind", mutate: func(manifest *ReleaseManifest) { manifest.Components[2].Kind = PackKindTarget }, hostOS: "linux", hostArch: "amd64", want: "requires exactly one"}, - {name: "component mismatch", mutate: func(manifest *ReleaseManifest) { manifest.Components[2].Arch = "arm64" }, hostOS: "linux", hostArch: "amd64", want: "does not match release set"}, + {name: "duplicate kind", mutate: func(manifest *ReleaseManifest) { manifest.Components[1].Kind = PackKindCompiler }, hostOS: "linux", hostArch: "amd64", want: "requires exactly one"}, + {name: "component mismatch", mutate: func(manifest *ReleaseManifest) { manifest.Components[1].Arch = "arm64" }, hostOS: "linux", hostArch: "amd64", want: "does not match release set"}, } { t.Run(test.name, func(t *testing.T) { manifest := testReleaseManifest() @@ -86,7 +86,7 @@ func TestVerifyReleaseManifestRejectsUnknownFields(t *testing.T) { if err != nil { t.Fatal(err) } - data := []byte(`{"schema_version":1,"version":"0.2.0","components":[],"install_sets":[],"surprise":true}`) + data := []byte(`{"schema_version":2,"version":"0.2.0","components":[],"install_sets":[],"surprise":true}`) signature := []byte(base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, data))) _, _, err = VerifyReleaseManifest(data, signature, publicKey, "linux", "amd64") if err == nil || !strings.Contains(err.Error(), "unknown field") { @@ -102,7 +102,7 @@ func TestBuildReleaseManifestCreatesDeterministicCompleteHostSets(t *testing.T) if err != nil { t.Fatalf("BuildReleaseManifest() error = %v", err) } - if len(manifest.Components) != 18 || len(manifest.InstallSets) != 6 { + if len(manifest.Components) != 12 || len(manifest.InstallSets) != 6 { t.Fatalf("manifest has %d components and %d install sets", len(manifest.Components), len(manifest.InstallSets)) } if manifest.InstallSets[0].OS != "darwin" || manifest.InstallSets[0].Arch != "amd64" { @@ -172,22 +172,21 @@ func testReleaseManifest() ReleaseManifest { Version: "0.2.0", Components: []ReleaseComponent{ {ID: "compiler-linux-amd64", Kind: PackKindCompiler, Version: "0.2.0", OS: "linux", Arch: "amd64", URL: "https://example.com/compiler.tar.gz", Size: 10, SHA256: digest, Format: FormatTarGz}, - {ID: "target-linux-amd64", Kind: PackKindTarget, Version: "0.2.0", OS: "linux", Arch: "amd64", URL: "https://example.com/target.tar.gz", Size: 20, SHA256: digest, Format: FormatTarGz}, {ID: "toolchain-linux-amd64", Kind: PackKindToolchain, Version: "23.1.0", OS: "linux", Arch: "amd64", URL: "https://example.com/toolchain.tar.gz", Size: 30, SHA256: digest, Format: FormatTarGz}, }, - InstallSets: []InstallSet{{OS: "linux", Arch: "amd64", Components: []string{"compiler-linux-amd64", "target-linux-amd64", "toolchain-linux-amd64"}}}, + InstallSets: []InstallSet{{OS: "linux", Arch: "amd64", Components: []string{"compiler-linux-amd64", "toolchain-linux-amd64"}}}, } } func completeReleaseArtifacts() []ReleaseArtifact { digest := strings.Repeat("b", 64) - artifacts := make([]ReleaseArtifact, 0, 18) + artifacts := make([]ReleaseArtifact, 0, 12) for _, host := range [][2]string{{"linux", "amd64"}, {"linux", "arm64"}, {"darwin", "amd64"}, {"darwin", "arm64"}, {"windows", "amd64"}, {"windows", "arm64"}} { format := FormatTarGz if host[0] == "windows" { format = FormatZip } - for _, kind := range []string{PackKindCompiler, PackKindTarget, PackKindToolchain} { + for _, kind := range []string{PackKindCompiler, PackKindToolchain} { id := kind + "-" + host[0] + "-" + host[1] artifacts = append(artifacts, ReleaseArtifact{ FileName: id + format.Extension(), diff --git a/scripts/detect-changes.sh b/scripts/detect-changes.sh index 3c61ff6..50595e4 100644 --- a/scripts/detect-changes.sh +++ b/scripts/detect-changes.sh @@ -39,7 +39,7 @@ for file in "${files[@]}"; do runtime/*|internal/backend/*|internal/codegen/*) runtime=true; non_compiler=true ;; esac case "$file" in - pkg/distribution/*|cmd/distpack/*|cmd/distunpack/*|cmd/release-index/*|cmd/sign-release/*|cmd/toolchain-lock/*|internal/installer/*) distribution=true; non_compiler=true ;; + pkg/distribution/*|cmd/distpack/*|cmd/distunpack/*|cmd/release-index/*|cmd/sign-release/*|cmd/toolchain-lock/*|internal/installer/*|scripts/install.sh|scripts/install.ps1) distribution=true; non_compiler=true ;; esac case "$file" in toolchains/*|scripts/fetch-toolchain.sh|scripts/plan-toolchains.sh|scripts/toolchain-fingerprint.sh|scripts/update-toolchain-lock.sh|scripts/update-toolchain-sources.sh|scripts/toolchain*_test.go|scripts/toolchains/*|internal/toolchain/*) toolchain=true; non_compiler=true ;; diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..b417855 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,51 @@ +# Peeper bootstrap installer: downloads the native installer for the detected +# platform, verifies it against the published SHA256SUMS, runs it, and +# persists the binary directory in the user PATH. +$ErrorActionPreference = "Stop" + +$repository = "PeeperLanguage/compiler" +$baseUrl = "https://github.com/$repository/releases/latest/download" + +$arch = switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { "amd64" } + "ARM64" { "arm64" } + default { throw "peeper install: unsupported architecture: $env:PROCESSOR_ARCHITECTURE" } +} +$installer = "peeper-installer-windows-$arch.exe" + +$work = Join-Path ([System.IO.Path]::GetTempPath()) "peeper-install-$([Guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $work | Out-Null +try { + Invoke-WebRequest "$baseUrl/$installer" -OutFile "$work/$installer" + Invoke-WebRequest "$baseUrl/SHA256SUMS" -OutFile "$work/SHA256SUMS" + + $expected = $null + foreach ($line in Get-Content "$work/SHA256SUMS") { + if ($line -match "^([0-9a-f]{64})\s+\*?$installer$") { $expected = $Matches[1] } + } + if (-not $expected) { throw "peeper install: checksum for $installer not found in SHA256SUMS" } + $actual = (Get-FileHash "$work/$installer" -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { throw "peeper install: checksum mismatch for $installer" } + + $output = & "$work/$installer" 2>&1 + $output | ForEach-Object { Write-Host $_ } + + $binDir = $null + foreach ($line in $output) { + if ($line -match '^Add (.+) to PATH\.$') { $binDir = $Matches[1] } + } + if (-not $binDir) { throw "peeper install: could not determine binary directory" } + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $entries = @() + if ($userPath) { $entries = $userPath -split ';' | Where-Object { $_ } } + if ($entries -notcontains $binDir) { + [Environment]::SetEnvironmentVariable("Path", (($entries + $binDir) -join ';'), "User") + Write-Host "Added $binDir to user PATH. Restart your terminal to apply." + } + if (($env:Path -split ';') -notcontains $binDir) { + $env:Path = "$binDir;$env:Path" + } +} finally { + Remove-Item -Recurse -Force $work +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..1ce7e9f --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Peeper bootstrap installer: downloads the native installer for the detected +# platform, verifies it against the published SHA256SUMS, runs it, and +# persists the binary directory in the user PATH. +set -eu + +repository="PeeperLanguage/compiler" +base_url="https://github.com/${repository}/releases/latest/download" + +case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) echo "peeper install: unsupported operating system: $(uname -s)" >&2; exit 1 ;; +esac + +machine=$(uname -m) +arch="" +case "$machine" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; +esac +# Under Rosetta, uname reports x86_64 while the hardware is arm64; prefer the native build. +if [ "$os" = darwin ] && [ "$arch" = amd64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then + arch=arm64 +fi +[ -n "$arch" ] || { echo "peeper install: unsupported architecture: $machine" >&2; exit 1; } + +installer="peeper-installer-${os}-${arch}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +curl --proto '=https' --tlsv1.2 -fsSL "$base_url/$installer" -o "$work/$installer" +curl --proto '=https' --tlsv1.2 -fsSL "$base_url/SHA256SUMS" -o "$work/SHA256SUMS" + +expected=$(grep " ${installer}\$" "$work/SHA256SUMS" | cut -d' ' -f1 || true) +case "$expected" in + [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; + *) echo "peeper install: checksum for $installer not found in SHA256SUMS" >&2; exit 1 ;; +esac + +if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$expected" "$work/$installer" | sha256sum -c - >/dev/null +else + printf '%s %s\n' "$expected" "$work/$installer" | shasum -a 256 -c - >/dev/null +fi + +chmod +x "$work/$installer" +output="$("$work/$installer")" +printf '%s\n' "$output" + +bin_dir=$(printf '%s\n' "$output" | sed -n 's/^Add \(.*\) to PATH\.$/\1/p') +[ -n "$bin_dir" ] || { echo "peeper install: could not determine binary directory" >&2; exit 1; } + +persist_path() { + file=$1 + line=$2 + mkdir -p "$(dirname "$file")" + if [ ! -f "$file" ] || ! grep -Fq "$bin_dir" "$file"; then + printf '\n%s\n' "$line" >> "$file" + echo "Added $bin_dir to $file. Restart your terminal to apply." + fi +} + +case "${SHELL##*/}" in + zsh) persist_path "$HOME/.zshrc" "export PATH=\"$bin_dir:\$PATH\" # peeper" ;; + bash) persist_path "$HOME/.bashrc" "export PATH=\"$bin_dir:\$PATH\" # peeper" ;; + fish) persist_path "$HOME/.config/fish/conf.d/peeper.fish" "fish_add_path \"$bin_dir\"" ;; + *) persist_path "$HOME/.profile" "export PATH=\"$bin_dir:\$PATH\" # peeper" ;; +esac diff --git a/scripts/plan-toolchains.sh b/scripts/plan-toolchains.sh index 2107a86..0ce093a 100755 --- a/scripts/plan-toolchains.sh +++ b/scripts/plan-toolchains.sh @@ -54,26 +54,17 @@ else ;; scripts/toolchains/common.sh) old_common="$(git show "$base:$file")" - normalized_old="$(sed 's#pkg/distribution/toolchain-sources\.lock\.json#toolchains/toolchain-sources.lock.json#g' <<< "$old_common")" - normalized_new="$(sed 's#pkg/distribution/toolchain-sources\.lock\.json#toolchains/toolchain-sources.lock.json#g' "$file")" - [ "$normalized_old" = "$normalized_new" ] || select_all + [ "$old_common" = "$(cat "$file")" ] || select_all ;; scripts/toolchains/build-linux.sh) select_family linux ;; scripts/toolchains/build-darwin.sh) select_family darwin ;; scripts/toolchains/build-windows.sh) select_family windows ;; toolchains/toolchain-sources.lock.json) - old_lock_path="$file" - if ! git cat-file -e "$base:$old_lock_path" 2>/dev/null; then - old_lock_path=pkg/distribution/toolchain-sources.lock.json - fi - if ! git cat-file -e "$base:$old_lock_path" 2>/dev/null; then - old_lock_path=distribution/toolchain-sources.lock.json - fi - if ! git cat-file -e "$base:$old_lock_path" 2>/dev/null; then + if ! git cat-file -e "$base:$file" 2>/dev/null; then select_all continue fi - old_lock="$(git show "$base:$old_lock_path")" + old_lock="$(git show "$base:$file")" for id in llvm-linux-amd64 llvm-linux-arm64 musl-source llvm-source llvm-mingw-windows-amd64 llvm-mingw-windows-arm64; do old_entry="$(jq -cS --arg id "$id" '.assets[] | select(.id == $id)' <<< "$old_lock")" new_entry="$(jq -cS --arg id "$id" '.assets[] | select(.id == $id)' "$file")" diff --git a/scripts/toolchain_sources_test.go b/scripts/toolchain_sources_test.go index 255c2fc..05f00cf 100644 --- a/scripts/toolchain_sources_test.go +++ b/scripts/toolchain_sources_test.go @@ -84,46 +84,6 @@ func TestUpdateToolchainSourcesRejectsIncompleteRelease(t *testing.T) { } } -func TestPlanToolchainsDoesNotRebuildForLockRelocation(t *testing.T) { - repository := t.TempDir() - writeTestFile(t, filepath.Join(repository, "scripts", "toolchains", "common.sh"), "toolchain_sources_lock=\"${TOOLCHAIN_SOURCES_LOCK:-pkg/distribution/toolchain-sources.lock.json}\"\n") - writeTestFile(t, filepath.Join(repository, ".github", "workflows", "build-toolchains.yml"), "source-lock: pkg/distribution/toolchain-sources.lock.json\n") - writeTestFile(t, filepath.Join(repository, "pkg", "distribution", "toolchain-sources.lock.json"), "{\"assets\":[]}\n") - runGit(t, repository, "init") - runGit(t, repository, "config", "user.name", "Test") - runGit(t, repository, "config", "user.email", "test@example.com") - runGit(t, repository, "config", "commit.gpgsign", "false") - runGit(t, repository, "add", ".") - runGit(t, repository, "commit", "-m", "baseline") - - plan, err := os.ReadFile("plan-toolchains.sh") - if err != nil { - t.Fatal(err) - } - writeTestFile(t, filepath.Join(repository, "scripts", "plan-toolchains.sh"), string(plan)) - writeTestFile(t, filepath.Join(repository, "scripts", "toolchains", "common.sh"), "toolchain_sources_lock=\"${TOOLCHAIN_SOURCES_LOCK:-toolchains/toolchain-sources.lock.json}\"\n") - writeTestFile(t, filepath.Join(repository, ".github", "workflows", "build-toolchains.yml"), "source-lock: toolchains/toolchain-sources.lock.json\n") - writeTestFile(t, filepath.Join(repository, "toolchains", "toolchain-sources.lock.json"), "{\"assets\":[]}\n") - if err := os.Remove(filepath.Join(repository, "pkg", "distribution", "toolchain-sources.lock.json")); err != nil { - t.Fatal(err) - } - runGit(t, repository, "add", ".") - runGit(t, repository, "commit", "-m", "move lock") - - command := exec.Command("bash", "scripts/plan-toolchains.sh") - command.Dir = repository - command.Env = append(os.Environ(), "GITHUB_OUTPUT=") - output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("plan toolchains: %v\n%s", err, output) - } - for _, target := range []string{"linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64", "windows_amd64", "windows_arm64"} { - if !strings.Contains(string(output), target+"=false") { - t.Fatalf("plan output missing %s=false:\n%s", target, output) - } - } -} - func TestUpdateToolchainLockReadsSeparateArtifactDirectories(t *testing.T) { temporary := t.TempDir() lock := filepath.Join(temporary, "toolchains.lock.json") From 8fa70354f7b68a20774290953ddeb0da3af76d8c Mon Sep 17 00:00:00 2001 From: itsfuad Date: Mon, 31 Aug 2026 02:37:11 +0600 Subject: [PATCH 3/6] Add installer download progress and parallel component downloads Component downloads now run concurrently so total install time tracks the largest pack instead of the sum, with one aggregate throttled progress line on stderr. Progress counts bytes on the write side of the copy so streamed data is reported accurately. README drops compiler pipeline and repository layout sections and the stale no-binary-release note. --- README.md | 36 +--------- cmd/peeper-installer/main.go | 2 +- internal/installer/install.go | 106 +++++++++++++++++++++++++++-- internal/installer/install_test.go | 19 ++++++ 4 files changed, 123 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index eea1b0b..ccf2f0d 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,7 @@ repository contains the compiler, bundled library, package tooling, language server, and executable source fixtures. > Peeper is pre-release software. Language, package, and compiler interfaces may -> change without compatibility guarantees. No stable binary release is currently -> published. +> change without compatibility guarantees. ## Current capabilities @@ -107,39 +106,6 @@ fn main() { Run `peeper -help` for the complete command list and current aliases. -## Compiler pipeline - -```text -source - -> tokens - -> AST - -> name and base-type semantics - -> CFG - -> flow typing and optional narrowing - -> definite initialization - -> ownership - -> project-wide usage analysis - -> HIR - -> MIR - -> LLVM IR - -> native linker -``` - -Mandatory semantic checks finish before HIR optimization may remove source -control flow. [`COMPILER_GUIDELINES.md`](COMPILER_GUIDELINES.md) explains these -phase and representation boundaries. - -## Repository layout - -| Path | Contents | -| --- | --- | -| `cmd/` | Compiler CLI entrypoint and commands. | -| `internal/` | Frontend, semantics, IR, LSP, pipeline, and backend packages. | -| `pkg/` | Reusable manifest, registry, and utility packages. | -| `_builtin_library/` | Compiler-bundled Peeper library sources. | -| `x_test/` | Positive, negative, and runtime source fixtures. | -| `scripts/` | Bundling and repository automation. | - ## Development ```bash diff --git a/cmd/peeper-installer/main.go b/cmd/peeper-installer/main.go index ea96115..752da3a 100644 --- a/cmd/peeper-installer/main.go +++ b/cmd/peeper-installer/main.go @@ -48,7 +48,7 @@ func main() { defer stop() result, err := installer.Install(ctx, installer.Config{ Client: &http.Client{Timeout: 30 * time.Minute}, ManifestURL: *manifestURL, SignatureURL: *manifestURL + ".sig", - PublicKey: publicKey, HostOS: runtime.GOOS, HostArch: runtime.GOARCH, InstallRoot: *installRoot, + PublicKey: publicKey, HostOS: runtime.GOOS, HostArch: runtime.GOARCH, InstallRoot: *installRoot, Progress: os.Stderr, }) if err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/internal/installer/install.go b/internal/installer/install.go index 056b0e9..ffd9656 100644 --- a/internal/installer/install.go +++ b/internal/installer/install.go @@ -11,6 +11,8 @@ import ( "net/url" "os" "path/filepath" + "sync" + "time" "compiler/internal/target" "compiler/internal/toolchain" @@ -31,6 +33,8 @@ type Config struct { HostOS string HostArch string InstallRoot string + // Progress receives human-readable download progress. Nil disables it. + Progress io.Writer } type Result struct { @@ -83,13 +87,39 @@ func Install(ctx context.Context, config Config) (Result, error) { if err := os.Mkdir(payload, 0o755); err != nil { return Result{}, fmt.Errorf("create installation staging root: %w", err) } + totalSize := int64(0) for _, component := range components { - archivePath, err := downloadComponent(ctx, client, transaction, component) + totalSize += component.Size + } + progress := newProgressWriter(config.Progress, "components", totalSize) + archives := make([]string, len(components)) + downloadErrs := make([]error, len(components)) + downloadCtx, cancelDownloads := context.WithCancel(ctx) + defer cancelDownloads() + var downloads sync.WaitGroup + for i, component := range components { + downloads.Add(1) + go func(i int, component distribution.ReleaseComponent) { + defer downloads.Done() + archivePath, err := downloadComponent(downloadCtx, client, transaction, component, progress) + archives[i], downloadErrs[i] = archivePath, err + if err != nil { + cancelDownloads() + } + }(i, component) + } + downloads.Wait() + for _, err := range downloadErrs { if err != nil { return Result{}, err } + } + if progress != nil { + progress.finish() + } + for i, component := range components { expected := distribution.Metadata{Kind: component.Kind, ID: component.ID, Version: component.Version, OS: component.OS, Arch: component.Arch} - if _, err := distribution.ExtractPack(archivePath, component.Format, payload, expected); err != nil { + if _, err := distribution.ExtractPack(archives[i], component.Format, payload, expected); err != nil { return Result{}, fmt.Errorf("extract component %q: %w", component.ID, err) } } @@ -157,7 +187,7 @@ func downloadBytes(ctx context.Context, client *http.Client, requestURL string, return data, nil } -func downloadComponent(ctx context.Context, client *http.Client, transaction string, component distribution.ReleaseComponent) (string, error) { +func downloadComponent(ctx context.Context, client *http.Client, transaction string, component distribution.ReleaseComponent, progress *progressWriter) (string, error) { if err := validateHTTPSURL(component.URL); err != nil { return "", fmt.Errorf("component %q URL: %w", component.ID, err) } @@ -186,7 +216,11 @@ func downloadComponent(ctx context.Context, client *http.Client, transaction str } archivePath := archive.Name() hash := sha256.New() - written, copyErr := io.Copy(io.MultiWriter(archive, hash), io.LimitReader(response.Body, component.Size+1)) + destination := io.Writer(io.MultiWriter(archive, hash)) + if progress != nil { + destination = progress.writer(destination) + } + written, copyErr := io.Copy(destination, io.LimitReader(response.Body, component.Size+1)) closeErr := archive.Close() if copyErr != nil { return "", fmt.Errorf("download component %q: %w", component.ID, copyErr) @@ -203,6 +237,70 @@ func downloadComponent(ctx context.Context, client *http.Client, transaction str return archivePath, nil } +// progressWriter reports streamed download progress as one in-place terminal +// line, throttled so terminals and CI logs are not flooded. It is safe for +// concurrent component downloads, which share one aggregate report. +type progressWriter struct { + destination io.Writer + label string + total int64 + mutex sync.Mutex + written int64 + lastReport time.Time +} + +func newProgressWriter(destination io.Writer, label string, total int64) *progressWriter { + if destination == nil { + return nil + } + return &progressWriter{destination: destination, label: label, total: total, lastReport: time.Now()} +} + +func (p *progressWriter) writer(destination io.Writer) io.Writer { + return writerFunc(func(chunk []byte) (int, error) { + if _, err := p.Write(chunk); err != nil { + return 0, err + } + return destination.Write(chunk) + }) +} + +type writerFunc func([]byte) (int, error) + +func (f writerFunc) Write(chunk []byte) (int, error) { return f(chunk) } + +func (p *progressWriter) Write(chunk []byte) (int, error) { + p.mutex.Lock() + p.written += int64(len(chunk)) + report := time.Since(p.lastReport) >= 200*time.Millisecond + if report { + p.lastReport = time.Now() + } + written := p.written + p.mutex.Unlock() + if report { + p.render(written) + } + return len(chunk), nil +} + +func (p *progressWriter) finish() { + p.mutex.Lock() + written := p.written + p.mutex.Unlock() + p.render(written) + fmt.Fprintln(p.destination) +} + +func (p *progressWriter) render(written int64) { + if p.total > 0 { + fmt.Fprintf(p.destination, "\rDownloading %s: %.1f/%.1f MB (%d%%) ", + p.label, float64(written)/(1<<20), float64(p.total)/(1<<20), written*100/p.total) + return + } + fmt.Fprintf(p.destination, "\rDownloading %s: %.1f MB ", p.label, float64(written)/(1<<20)) +} + func validateHTTPSURL(value string) error { parsed, err := url.Parse(value) if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { diff --git a/internal/installer/install_test.go b/internal/installer/install_test.go index 1c5e81c..41d4a84 100644 --- a/internal/installer/install_test.go +++ b/internal/installer/install_test.go @@ -1,6 +1,7 @@ package installer import ( + "bytes" "crypto/ed25519" "crypto/rand" "crypto/sha256" @@ -12,6 +13,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "compiler/internal/target" @@ -45,6 +47,23 @@ func TestInstallDownloadsVerifiesAndActivatesRelease(t *testing.T) { } } +func TestInstallReportsComponentProgress(t *testing.T) { + fixture := newReleaseFixture(t) + defer fixture.server.Close() + installRoot := filepath.Join(t.TempDir(), "peeper") + var progress bytes.Buffer + _, err := Install(t.Context(), Config{ + Client: fixture.server.Client(), ManifestURL: fixture.server.URL + "/release-manifest.json", SignatureURL: fixture.server.URL + "/release-manifest.json.sig", + PublicKey: fixture.publicKey, HostOS: runtime.GOOS, HostArch: runtime.GOARCH, InstallRoot: installRoot, Progress: &progress, + }) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + if !strings.Contains(progress.String(), "Downloading components") || !strings.Contains(progress.String(), "(100%)") { + t.Fatalf("progress output missing complete aggregate report: %q", progress.String()) + } +} + func TestInstallHashFailurePreservesExistingInstall(t *testing.T) { fixture := newReleaseFixture(t) defer fixture.server.Close() From 77527cac9eafcd3308d18688ab9471ae7d1be108 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Mon, 31 Aug 2026 02:56:02 +0600 Subject: [PATCH 4/6] Show download progress for installer bootstrap download The installer binary download is large enough to need feedback; curl now uses a stderr progress bar and PowerShell keeps its default progress for that step only. The tiny SHA256SUMS fetch stays silent. --- scripts/install.ps1 | 2 ++ scripts/install.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b417855..8e8b0cb 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -16,7 +16,9 @@ $installer = "peeper-installer-windows-$arch.exe" $work = Join-Path ([System.IO.Path]::GetTempPath()) "peeper-install-$([Guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Path $work | Out-Null try { + $ProgressPreference = 'Continue' Invoke-WebRequest "$baseUrl/$installer" -OutFile "$work/$installer" + $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest "$baseUrl/SHA256SUMS" -OutFile "$work/SHA256SUMS" $expected = $null diff --git a/scripts/install.sh b/scripts/install.sh index 1ce7e9f..4cf9adb 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -29,7 +29,7 @@ installer="peeper-installer-${os}-${arch}" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT -curl --proto '=https' --tlsv1.2 -fsSL "$base_url/$installer" -o "$work/$installer" +curl --proto '=https' --tlsv1.2 -fL --progress-bar "$base_url/$installer" -o "$work/$installer" curl --proto '=https' --tlsv1.2 -fsSL "$base_url/SHA256SUMS" -o "$work/SHA256SUMS" expected=$(grep " ${installer}\$" "$work/SHA256SUMS" | cut -d' ' -f1 || true) From 7ccc40be9073f84ad5ba105f8d84e44b58ab677b Mon Sep 17 00:00:00 2001 From: itsfuad Date: Mon, 31 Aug 2026 03:18:39 +0600 Subject: [PATCH 5/6] Replace Go installer with pure-script installation Bootstrap scripts now perform the full installation: download the release manifest, verify it against SHA256SUMS, read pack URLs and SHA-256 digests for the detected host, download compiler and toolchain packs, verify every digest, extract into staging on the destination filesystem, and activate atomically. Removes cmd/peeper-installer and internal/installer, dropping six installer builds and assets per release; release assets drop to 11. Manifest signing stays for out-of-band audit. --- .github/workflows/ci.yml | 2 +- .github/workflows/release-host.yml | 13 +- .github/workflows/release.yml | 10 +- README.md | 19 +- cmd/peeper-installer/main.go | 72 ------ cmd/peeper-installer/main_test.go | 20 -- docs/distribution.md | 19 +- internal/installer/install.go | 374 ----------------------------- internal/installer/install_test.go | 214 ----------------- scripts/detect-changes.sh | 2 +- scripts/install.ps1 | 66 +++-- scripts/install.sh | 116 +++++++-- 12 files changed, 165 insertions(+), 762 deletions(-) delete mode 100644 cmd/peeper-installer/main.go delete mode 100644 cmd/peeper-installer/main_test.go delete mode 100644 internal/installer/install.go delete mode 100644 internal/installer/install_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f011bc..986383c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,7 +153,7 @@ jobs: with: go-version-file: go.mod cache: true - - run: CCACHE_DISABLE=1 go test ./pkg/distribution ./internal/installer ./cmd/distpack ./cmd/distunpack ./cmd/release-index ./cmd/sign-release ./cmd/toolchain-lock + - run: CCACHE_DISABLE=1 go test ./pkg/distribution ./cmd/distpack ./cmd/distunpack ./cmd/release-index ./cmd/sign-release ./cmd/toolchain-lock - name: Check bootstrap scripts run: | bash -n scripts/install.sh diff --git a/.github/workflows/release-host.yml b/.github/workflows/release-host.yml index 0d3ceca..f07486f 100644 --- a/.github/workflows/release-host.yml +++ b/.github/workflows/release-host.yml @@ -15,9 +15,6 @@ on: version: required: true type: string - public-key: - required: true - type: string minimum-macos: required: false type: string @@ -76,22 +73,14 @@ jobs: fi "$clang" "${runtime_args[@]}" -c runtime/peeper_rt.c -o "$RUNNER_TEMP/peeper_rt.o" "$archiver" rcs "$runtime_dir/libpeeper_rt_v1.a" "$RUNNER_TEMP/peeper_rt.o" - - name: Stage compiler component and bootstrap installer - env: - RELEASE_PUBLIC_KEY: ${{ inputs.public-key }} + - name: Stage compiler component run: | suffix="" [ "${{ inputs.os }}" = windows ] && suffix=.exe - test -n "$RELEASE_PUBLIC_KEY" mkdir -p stage/compiler/bin stage/compiler/libs dist cp "build/bin/peeper$suffix" stage/compiler/bin/ cp -R build/libs/. stage/compiler/libs/ cp LICENSE stage/compiler/ - manifest_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${{ inputs.version }}/release-manifest.json" - go build -trimpath \ - -ldflags="-s -w -X=main.releaseManifestURL=$manifest_url -X=main.releasePublicKeyHex=$RELEASE_PUBLIC_KEY" \ - -o "dist/peeper-installer-${{ inputs.os }}-${{ inputs.arch }}$suffix" \ - ./cmd/peeper-installer - name: Package compiler component run: | format=tar.gz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1bae30..2823ae9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: version="${RELEASE_TAG#v}" test "v$version" = "$RELEASE_TAG" [[ "$RELEASE_PUBLIC_KEY" =~ ^[0-9a-fA-F]{64}$ ]] - GOCACHE="$RUNNER_TEMP/go-cache" CCACHE_DISABLE=1 go test ./pkg/distribution ./internal/installer ./internal/toolchain + GOCACHE="$RUNNER_TEMP/go-cache" CCACHE_DISABLE=1 go test ./pkg/distribution ./internal/toolchain for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do go run ./cmd/toolchain-lock -lock toolchains/toolchains.lock.json -os "${target%/*}" -arch "${target#*/}" >/dev/null done @@ -44,7 +44,6 @@ jobs: os: linux arch: amd64 version: ${{ needs.preflight.outputs.version }} - public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} host_linux_arm64: name: Host Linux arm64 @@ -55,7 +54,6 @@ jobs: os: linux arch: arm64 version: ${{ needs.preflight.outputs.version }} - public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} host_darwin_amd64: name: Host macOS amd64 @@ -66,7 +64,6 @@ jobs: os: darwin arch: amd64 version: ${{ needs.preflight.outputs.version }} - public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} minimum-macos: "13.0" host_darwin_arm64: @@ -78,7 +75,6 @@ jobs: os: darwin arch: arm64 version: ${{ needs.preflight.outputs.version }} - public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} minimum-macos: "13.0" host_windows_amd64: @@ -90,7 +86,6 @@ jobs: os: windows arch: amd64 version: ${{ needs.preflight.outputs.version }} - public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} host_windows_arm64: name: Host Windows arm64 @@ -101,7 +96,6 @@ jobs: os: windows arch: arm64 version: ${{ needs.preflight.outputs.version }} - public-key: ${{ vars.PEEPER_RELEASE_PUBLIC_KEY }} assemble_release: name: Assemble release @@ -140,7 +134,7 @@ jobs: dist/*.tar.gz.json dist/*.zip.json \ > release-assets/release-manifest.json find dist -maxdepth 1 -type f \ - \( -name '*.tar.gz' -o -name '*.zip' -o -name 'peeper-installer-*' \) \ + \( -name '*.tar.gz' -o -name '*.zip' \) \ -exec cp {} release-assets/ \; cp scripts/install.sh scripts/install.ps1 release-assets/ - uses: actions/upload-artifact@v7 diff --git a/README.md b/README.md index ccf2f0d..90b3479 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,14 @@ irm https://github.com/PeeperLanguage/compiler/releases/latest/download/install. ``` Bootstrap scripts detect `amd64` or `arm64` automatically, download the -matching native installer from the latest published release, verify it against -`SHA256SUMS`, and run it. macOS running under Rosetta installs the native -`arm64` build. macOS requires Command Line Tools and working -`xcrun --sdk macosx --show-sdk-path`. - -Installer verifies signed release manifest, downloads compiler and managed -LLVM packs, validates every size and SHA-256 digest, then activates complete -installation atomically. Bootstrap scripts persist the Peeper binary directory -in the user PATH idempotently; restart your terminal afterward, because a piped -shell cannot modify its parent environment. Run `peeper doctor` after -installation. +release manifest and the compiler and toolchain packs for the detected host, +verify every SHA-256 digest, and activate the installation atomically. macOS +running under Rosetta installs the native `arm64` build. macOS requires +Command Line Tools and working `xcrun --sdk macosx --show-sdk-path`. + +Bootstrap scripts persist the Peeper binary directory in the user PATH +idempotently; restart your terminal afterward, because a piped shell cannot +modify its parent environment. Run `peeper doctor` after installation. See [`docs/distribution.md`](docs/distribution.md) for support policy, release security, and maintainer setup. diff --git a/cmd/peeper-installer/main.go b/cmd/peeper-installer/main.go deleted file mode 100644 index 752da3a..0000000 --- a/cmd/peeper-installer/main.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "context" - "encoding/hex" - "flag" - "fmt" - "net/http" - "os" - "os/signal" - "path/filepath" - "runtime" - "syscall" - "time" - - "compiler/internal/installer" -) - -var ( - releaseManifestURL = "" - releasePublicKeyHex = "" -) - -func main() { - defaultRoot, err := defaultInstallRoot() - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - installRoot := flag.String("install-dir", defaultRoot, "installation directory") - manifestURL := flag.String("manifest-url", releaseManifestURL, "signed release manifest URL") - publicKeyHex := flag.String("public-key", releasePublicKeyHex, "Ed25519 release public key in hexadecimal") - flag.Parse() - if flag.NArg() != 0 { - fmt.Fprintln(os.Stderr, "installer accepts flags only") - os.Exit(2) - } - publicKey, err := hex.DecodeString(*publicKeyHex) - if err != nil || len(publicKey) == 0 { - fmt.Fprintln(os.Stderr, "installer has no valid release public key") - os.Exit(1) - } - if *manifestURL == "" { - fmt.Fprintln(os.Stderr, "installer has no release manifest URL") - os.Exit(1) - } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - result, err := installer.Install(ctx, installer.Config{ - Client: &http.Client{Timeout: 30 * time.Minute}, ManifestURL: *manifestURL, SignatureURL: *manifestURL + ".sig", - PublicKey: publicKey, HostOS: runtime.GOOS, HostArch: runtime.GOARCH, InstallRoot: *installRoot, Progress: os.Stderr, - }) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Printf("Installed Peeper %s in %s\n", result.Version, result.InstallRoot) - fmt.Printf("Add %s to PATH.\n", filepath.Dir(result.Executable)) -} - -func defaultInstallRoot() (string, error) { - if runtime.GOOS == "windows" { - if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { - return filepath.Join(localAppData, "Peeper"), nil - } - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve user home for installation: %w", err) - } - return filepath.Join(home, ".peeper"), nil -} diff --git a/cmd/peeper-installer/main_test.go b/cmd/peeper-installer/main_test.go deleted file mode 100644 index f1c84ad..0000000 --- a/cmd/peeper-installer/main_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "path/filepath" - "runtime" - "testing" -) - -func TestDefaultInstallRootIsUserScoped(t *testing.T) { - root, err := defaultInstallRoot() - if err != nil { - t.Fatalf("defaultInstallRoot() error = %v", err) - } - if !filepath.IsAbs(root) || filepath.Dir(root) == root { - t.Fatalf("defaultInstallRoot() = %q", root) - } - if runtime.GOOS != "windows" && filepath.Base(root) != ".peeper" { - t.Fatalf("defaultInstallRoot() = %q", root) - } -} diff --git a/docs/distribution.md b/docs/distribution.md index c7b4b78..1db739e 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -27,12 +27,13 @@ Each host install combines exactly two independently verified packs: - toolchain: managed Clang/linker, required sysroot, licenses, and `toolchains/native/profile.json`. -Bootstrap install scripts are separate release assets. Signed release manifest -binds each pack ID, version, URL, archive format, byte length, and SHA-256 -digest to one host install set. Installer verifies Ed25519 signature before -parsing manifest, downloads only HTTPS assets, rejects unsafe archive paths and -links, stages on destination filesystem, validates managed profile, then -atomically activates or rolls back. +Bootstrap install scripts are the only installers. Each script downloads the +release manifest, verifies it against `SHA256SUMS`, reads the compiler and +toolchain pack URLs and SHA-256 digests for the detected host, downloads both +packs over HTTPS only, verifies every digest, extracts into a staging +directory on the destination filesystem, then activates the complete +installation atomically. The manifest is also published with an Ed25519 +signature that auditors can verify out of band. Installer intentionally leaves `PATH` changes to user. Default install root is `~/.peeper` on Unix-like systems and `%LOCALAPPDATA%\Peeper` on Windows. @@ -57,7 +58,7 @@ Tag workflow performs these additional gates: toolchain into a fresh root and runs `peeper doctor` and source fixtures. Host jobs do not build LLVM, musl, or llvm-mingw. 3. Require all six host jobs, then assemble the unsigned manifest and copy - host packs, native installers, and bootstrap scripts into release assets. + host packs and bootstrap scripts into release assets. 4. One protected finalization job signs the manifest, generates `SHA256SUMS`, and creates or updates the draft release. @@ -120,8 +121,8 @@ copy. 2. Run full local validation and merge clean review. 3. Create and push signed tag `v`. 4. Approve protected release environment. -5. Inspect draft assets: six host packs, six native installers, two bootstrap - scripts, signed manifest, manifest signature, and `SHA256SUMS`. Toolchains +5. Inspect draft assets: six host packs, two bootstrap scripts, signed + manifest, manifest signature, and `SHA256SUMS`. Toolchains remain referenced immutable component assets, not duplicate release assets. 6. Verify checksums on downloaded assets. 7. Install on clean host for each supported pair; run `peeper doctor`, then diff --git a/internal/installer/install.go b/internal/installer/install.go deleted file mode 100644 index ffd9656..0000000 --- a/internal/installer/install.go +++ /dev/null @@ -1,374 +0,0 @@ -package installer - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "sync" - "time" - - "compiler/internal/target" - "compiler/internal/toolchain" - "compiler/pkg/distribution" -) - -const ( - maxReleaseManifestBytes = int64(2 << 20) - maxReleaseSignatureBytes = int64(4 << 10) - maxRedirects = 5 -) - -type Config struct { - Client *http.Client - ManifestURL string - SignatureURL string - PublicKey []byte - HostOS string - HostArch string - InstallRoot string - // Progress receives human-readable download progress. Nil disables it. - Progress io.Writer -} - -type Result struct { - Version string - InstallRoot string - Executable string -} - -func Install(ctx context.Context, config Config) (Result, error) { - if config.Client == nil { - return Result{}, fmt.Errorf("installer HTTP client is unavailable") - } - host, err := target.New(config.HostOS, config.HostArch) - if err != nil { - return Result{}, err - } - installRoot, err := filepath.Abs(config.InstallRoot) - if err != nil { - return Result{}, fmt.Errorf("resolve installation root: %w", err) - } - if filepath.Dir(installRoot) == installRoot { - return Result{}, fmt.Errorf("refusing filesystem root as installation root") - } - client, err := secureHTTPClient(config.Client) - if err != nil { - return Result{}, err - } - manifestData, err := downloadBytes(ctx, client, config.ManifestURL, maxReleaseManifestBytes, "release manifest") - if err != nil { - return Result{}, err - } - signatureData, err := downloadBytes(ctx, client, config.SignatureURL, maxReleaseSignatureBytes, "release signature") - if err != nil { - return Result{}, err - } - manifest, components, err := distribution.VerifyReleaseManifest(manifestData, signatureData, config.PublicKey, host.OS, host.Arch) - if err != nil { - return Result{}, err - } - parent := filepath.Dir(installRoot) - if err := os.MkdirAll(parent, 0o755); err != nil { - return Result{}, fmt.Errorf("create installation parent: %w", err) - } - transaction, err := os.MkdirTemp(parent, ".peeper-install-*") - if err != nil { - return Result{}, fmt.Errorf("create installation transaction: %w", err) - } - defer os.RemoveAll(transaction) - payload := filepath.Join(transaction, "payload") - if err := os.Mkdir(payload, 0o755); err != nil { - return Result{}, fmt.Errorf("create installation staging root: %w", err) - } - totalSize := int64(0) - for _, component := range components { - totalSize += component.Size - } - progress := newProgressWriter(config.Progress, "components", totalSize) - archives := make([]string, len(components)) - downloadErrs := make([]error, len(components)) - downloadCtx, cancelDownloads := context.WithCancel(ctx) - defer cancelDownloads() - var downloads sync.WaitGroup - for i, component := range components { - downloads.Add(1) - go func(i int, component distribution.ReleaseComponent) { - defer downloads.Done() - archivePath, err := downloadComponent(downloadCtx, client, transaction, component, progress) - archives[i], downloadErrs[i] = archivePath, err - if err != nil { - cancelDownloads() - } - }(i, component) - } - downloads.Wait() - for _, err := range downloadErrs { - if err != nil { - return Result{}, err - } - } - if progress != nil { - progress.finish() - } - for i, component := range components { - expected := distribution.Metadata{Kind: component.Kind, ID: component.ID, Version: component.Version, OS: component.OS, Arch: component.Arch} - if _, err := distribution.ExtractPack(archives[i], component.Format, payload, expected); err != nil { - return Result{}, fmt.Errorf("extract component %q: %w", component.ID, err) - } - } - if err := validateStagedInstallation(payload, host); err != nil { - return Result{}, err - } - if err := activateInstallation(payload, installRoot); err != nil { - return Result{}, err - } - executable := filepath.Join(installRoot, "bin", "peeper"+target.ExecutableExt(host.OS)) - return Result{Version: manifest.Version, InstallRoot: installRoot, Executable: executable}, nil -} - -func secureHTTPClient(source *http.Client) (*http.Client, error) { - if source == nil { - return nil, fmt.Errorf("installer HTTP client is unavailable") - } - client := *source - priorRedirectCheck := source.CheckRedirect - client.CheckRedirect = func(request *http.Request, via []*http.Request) error { - if len(via) >= maxRedirects { - return fmt.Errorf("installer download exceeded %d redirects", maxRedirects) - } - if request.URL.Scheme != "https" || request.URL.Host == "" || request.URL.User != nil { - return fmt.Errorf("installer redirect uses unsafe URL") - } - if priorRedirectCheck != nil { - return priorRedirectCheck(request, via) - } - return nil - } - return &client, nil -} - -func downloadBytes(ctx context.Context, client *http.Client, requestURL string, limit int64, label string) ([]byte, error) { - if err := validateHTTPSURL(requestURL); err != nil { - return nil, fmt.Errorf("%s URL: %w", label, err) - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) - if err != nil { - return nil, fmt.Errorf("create %s request: %w", label, err) - } - request.Header.Set("Accept-Encoding", "identity") - response, err := client.Do(request) - if err != nil { - return nil, fmt.Errorf("download %s: %w", label, err) - } - defer response.Body.Close() - if response.Request.URL.Scheme != "https" { - return nil, fmt.Errorf("download %s ended at unsafe URL", label) - } - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("download %s returned HTTP %d", label, response.StatusCode) - } - if response.ContentLength > limit { - return nil, fmt.Errorf("%s exceeds %d byte limit", label, limit) - } - data, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) - if err != nil { - return nil, fmt.Errorf("read %s: %w", label, err) - } - if int64(len(data)) > limit { - return nil, fmt.Errorf("%s exceeds %d byte limit", label, limit) - } - return data, nil -} - -func downloadComponent(ctx context.Context, client *http.Client, transaction string, component distribution.ReleaseComponent, progress *progressWriter) (string, error) { - if err := validateHTTPSURL(component.URL); err != nil { - return "", fmt.Errorf("component %q URL: %w", component.ID, err) - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, component.URL, nil) - if err != nil { - return "", fmt.Errorf("create component %q request: %w", component.ID, err) - } - request.Header.Set("Accept-Encoding", "identity") - response, err := client.Do(request) - if err != nil { - return "", fmt.Errorf("download component %q: %w", component.ID, err) - } - defer response.Body.Close() - if response.Request.URL.Scheme != "https" { - return "", fmt.Errorf("component %q download ended at unsafe URL", component.ID) - } - if response.StatusCode != http.StatusOK { - return "", fmt.Errorf("component %q download returned HTTP %d", component.ID, response.StatusCode) - } - if response.ContentLength >= 0 && response.ContentLength != component.Size { - return "", fmt.Errorf("component %q content length does not match manifest", component.ID) - } - archive, err := os.CreateTemp(transaction, "."+component.ID+"-*"+component.Format.Extension()) - if err != nil { - return "", fmt.Errorf("create component %q download: %w", component.ID, err) - } - archivePath := archive.Name() - hash := sha256.New() - destination := io.Writer(io.MultiWriter(archive, hash)) - if progress != nil { - destination = progress.writer(destination) - } - written, copyErr := io.Copy(destination, io.LimitReader(response.Body, component.Size+1)) - closeErr := archive.Close() - if copyErr != nil { - return "", fmt.Errorf("download component %q: %w", component.ID, copyErr) - } - if closeErr != nil { - return "", fmt.Errorf("close component %q download: %w", component.ID, closeErr) - } - if written != component.Size { - return "", fmt.Errorf("component %q size does not match manifest", component.ID) - } - if hex.EncodeToString(hash.Sum(nil)) != component.SHA256 { - return "", fmt.Errorf("component %q SHA-256 does not match manifest", component.ID) - } - return archivePath, nil -} - -// progressWriter reports streamed download progress as one in-place terminal -// line, throttled so terminals and CI logs are not flooded. It is safe for -// concurrent component downloads, which share one aggregate report. -type progressWriter struct { - destination io.Writer - label string - total int64 - mutex sync.Mutex - written int64 - lastReport time.Time -} - -func newProgressWriter(destination io.Writer, label string, total int64) *progressWriter { - if destination == nil { - return nil - } - return &progressWriter{destination: destination, label: label, total: total, lastReport: time.Now()} -} - -func (p *progressWriter) writer(destination io.Writer) io.Writer { - return writerFunc(func(chunk []byte) (int, error) { - if _, err := p.Write(chunk); err != nil { - return 0, err - } - return destination.Write(chunk) - }) -} - -type writerFunc func([]byte) (int, error) - -func (f writerFunc) Write(chunk []byte) (int, error) { return f(chunk) } - -func (p *progressWriter) Write(chunk []byte) (int, error) { - p.mutex.Lock() - p.written += int64(len(chunk)) - report := time.Since(p.lastReport) >= 200*time.Millisecond - if report { - p.lastReport = time.Now() - } - written := p.written - p.mutex.Unlock() - if report { - p.render(written) - } - return len(chunk), nil -} - -func (p *progressWriter) finish() { - p.mutex.Lock() - written := p.written - p.mutex.Unlock() - p.render(written) - fmt.Fprintln(p.destination) -} - -func (p *progressWriter) render(written int64) { - if p.total > 0 { - fmt.Fprintf(p.destination, "\rDownloading %s: %.1f/%.1f MB (%d%%) ", - p.label, float64(written)/(1<<20), float64(p.total)/(1<<20), written*100/p.total) - return - } - fmt.Fprintf(p.destination, "\rDownloading %s: %.1f MB ", p.label, float64(written)/(1<<20)) -} - -func validateHTTPSURL(value string) error { - parsed, err := url.Parse(value) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { - return fmt.Errorf("unsafe HTTPS URL") - } - return nil -} - -func validateStagedInstallation(root string, host target.Info) error { - executable := filepath.Join(root, "bin", "peeper"+target.ExecutableExt(host.OS)) - info, err := os.Stat(executable) - if err != nil || !info.Mode().IsRegular() { - return fmt.Errorf("staged compiler executable is missing") - } - if host.OS != "windows" && info.Mode().Perm()&0o111 == 0 { - return fmt.Errorf("staged compiler is not executable") - } - if info, err := os.Stat(filepath.Join(root, "libs", "core")); err != nil || !info.IsDir() { - return fmt.Errorf("staged core library is missing") - } - profile, err := toolchain.Load(filepath.Join(root, "toolchains", "native", "profile.json"), root, host) - if err != nil { - return fmt.Errorf("validate staged toolchain profile: %w", err) - } - if profile.RuntimeABI != toolchain.RuntimeABIVersion || profile.RuntimeArchive == "" { - return fmt.Errorf("staged toolchain profile has incompatible runtime ABI") - } - for _, required := range [][2]string{{"compiler", profile.ClangPath}, {"linker", profile.LinkerPath}, {"runtime", profile.RuntimeArchive}} { - if info, err := os.Stat(required[1]); err != nil || !info.Mode().IsRegular() { - return fmt.Errorf("staged %s is missing", required[0]) - } - } - if profile.Sysroot != "" { - if info, err := os.Stat(profile.Sysroot); err != nil || !info.IsDir() { - return fmt.Errorf("staged sysroot is missing") - } - } - return nil -} - -func activateInstallation(staged, destination string) error { - if _, err := os.Lstat(destination); os.IsNotExist(err) { - if err := os.Rename(staged, destination); err != nil { - return fmt.Errorf("activate installation: %w", err) - } - return nil - } else if err != nil { - return fmt.Errorf("inspect existing installation: %w", err) - } - backup, err := os.MkdirTemp(filepath.Dir(destination), ".peeper-backup-*") - if err != nil { - return fmt.Errorf("reserve installation backup: %w", err) - } - if err := os.Remove(backup); err != nil { - return fmt.Errorf("prepare installation backup: %w", err) - } - if err := os.Rename(destination, backup); err != nil { - return fmt.Errorf("backup existing installation: %w", err) - } - if err := os.Rename(staged, destination); err != nil { - activateErr := fmt.Errorf("activate installation: %w", err) - if rollbackErr := os.Rename(backup, destination); rollbackErr != nil { - return errors.Join(activateErr, fmt.Errorf("restore existing installation: %w", rollbackErr)) - } - return activateErr - } - if err := os.RemoveAll(backup); err != nil { - return fmt.Errorf("remove installation backup: %w", err) - } - return nil -} diff --git a/internal/installer/install_test.go b/internal/installer/install_test.go deleted file mode 100644 index 41d4a84..0000000 --- a/internal/installer/install_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package installer - -import ( - "bytes" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "compiler/internal/target" - "compiler/pkg/distribution" -) - -func TestInstallDownloadsVerifiesAndActivatesRelease(t *testing.T) { - fixture := newReleaseFixture(t) - defer fixture.server.Close() - installRoot := filepath.Join(t.TempDir(), "peeper") - - result, err := Install(t.Context(), Config{ - Client: fixture.server.Client(), ManifestURL: fixture.server.URL + "/release-manifest.json", SignatureURL: fixture.server.URL + "/release-manifest.json.sig", - PublicKey: fixture.publicKey, HostOS: runtime.GOOS, HostArch: runtime.GOARCH, InstallRoot: installRoot, - }) - if err != nil { - t.Fatalf("Install() error = %v", err) - } - if result.Version != "0.2.0" || result.InstallRoot != installRoot { - t.Fatalf("Install() result = %#v", result) - } - for _, relative := range []string{ - filepath.Join("bin", "peeper"+target.ExecutableExt(runtime.GOOS)), - filepath.Join("libs", "core", "src", "global.peep"), - filepath.Join("toolchains", "native", "profile.json"), - filepath.Join("targets", target.Host().LLVMTriple, "lib", "libpeeper_rt_v1.a"), - } { - if _, err := os.Stat(filepath.Join(installRoot, relative)); err != nil { - t.Fatalf("installed %s: %v", relative, err) - } - } -} - -func TestInstallReportsComponentProgress(t *testing.T) { - fixture := newReleaseFixture(t) - defer fixture.server.Close() - installRoot := filepath.Join(t.TempDir(), "peeper") - var progress bytes.Buffer - _, err := Install(t.Context(), Config{ - Client: fixture.server.Client(), ManifestURL: fixture.server.URL + "/release-manifest.json", SignatureURL: fixture.server.URL + "/release-manifest.json.sig", - PublicKey: fixture.publicKey, HostOS: runtime.GOOS, HostArch: runtime.GOARCH, InstallRoot: installRoot, Progress: &progress, - }) - if err != nil { - t.Fatalf("Install() error = %v", err) - } - if !strings.Contains(progress.String(), "Downloading components") || !strings.Contains(progress.String(), "(100%)") { - t.Fatalf("progress output missing complete aggregate report: %q", progress.String()) - } -} - -func TestInstallHashFailurePreservesExistingInstall(t *testing.T) { - fixture := newReleaseFixture(t) - defer fixture.server.Close() - fixture.manifest.Components[0].SHA256 = hex.EncodeToString(make([]byte, sha256.Size)) - fixture.signManifest(t) - installRoot := filepath.Join(t.TempDir(), "peeper") - if err := os.MkdirAll(installRoot, 0o755); err != nil { - t.Fatal(err) - } - marker := filepath.Join(installRoot, "existing") - if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil { - t.Fatal(err) - } - - _, err := Install(t.Context(), Config{ - Client: fixture.server.Client(), ManifestURL: fixture.server.URL + "/release-manifest.json", SignatureURL: fixture.server.URL + "/release-manifest.json.sig", - PublicKey: fixture.publicKey, HostOS: runtime.GOOS, HostArch: runtime.GOARCH, InstallRoot: installRoot, - }) - if err == nil { - t.Fatal("Install() accepted component hash mismatch") - } - data, readErr := os.ReadFile(marker) - if readErr != nil || string(data) != "keep" { - t.Fatalf("prior install changed: data=%q err=%v", data, readErr) - } -} - -func TestInstallRejectsTruncatedComponent(t *testing.T) { - fixture := newReleaseFixture(t) - defer fixture.server.Close() - fixture.responses["/compiler.tar.gz"] = fixture.responses["/compiler.tar.gz"][:10] - installRoot := filepath.Join(t.TempDir(), "peeper") - _, err := Install(t.Context(), Config{ - Client: fixture.server.Client(), ManifestURL: fixture.server.URL + "/release-manifest.json", SignatureURL: fixture.server.URL + "/release-manifest.json.sig", - PublicKey: fixture.publicKey, HostOS: runtime.GOOS, HostArch: runtime.GOARCH, InstallRoot: installRoot, - }) - if err == nil { - t.Fatal("Install() accepted truncated component") - } - if _, statErr := os.Stat(installRoot); !os.IsNotExist(statErr) { - t.Fatalf("failed install activated destination: %v", statErr) - } -} - -type releaseFixture struct { - server *httptest.Server - responses map[string][]byte - manifest distribution.ReleaseManifest - privateKey ed25519.PrivateKey - publicKey ed25519.PublicKey -} - -func newReleaseFixture(t *testing.T) *releaseFixture { - t.Helper() - fixture := &releaseFixture{responses: make(map[string][]byte)} - fixture.server = httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - data, ok := fixture.responses[request.URL.Path] - if !ok { - http.NotFound(response, request) - return - } - _, _ = response.Write(data) - })) - fixture.publicKey, fixture.privateKey, _ = ed25519.GenerateKey(rand.Reader) - host := target.Host() - digest := func(data []byte) string { - hash := sha256.Sum256(data) - return hex.EncodeToString(hash[:]) - } - components := []distribution.ReleaseComponent{ - fixture.writePack(t, "/compiler.tar.gz", distribution.Metadata{Kind: distribution.PackKindCompiler, ID: "compiler-host", Version: "0.2.0", OS: host.OS, Arch: host.Arch}, func(root string) { - writeFixtureFile(t, filepath.Join(root, "bin", "peeper"+target.ExecutableExt(host.OS)), "compiler", 0o755) - writeFixtureFile(t, filepath.Join(root, "libs", "core", "src", "global.peep"), "core", 0o644) - writeFixtureFile(t, filepath.Join(root, "targets", host.LLVMTriple, "lib", "libpeeper_rt_v1.a"), "runtime", 0o644) - }), - fixture.writePack(t, "/toolchain.tar.gz", distribution.Metadata{Kind: distribution.PackKindToolchain, ID: "toolchain-host", Version: "23.1.0", OS: host.OS, Arch: host.Arch}, func(root string) { - clang := filepath.Join("toolchains", "native", "bin", "clang"+target.ExecutableExt(host.OS)) - writeFixtureFile(t, filepath.Join(root, clang), "clang", 0o755) - debugFormat := "dwarf" - if host.OS == "windows" { - debugFormat = "codeview" - } - profile := map[string]any{ - "schema_version": 1, "profile_id": "native-host", "target_os": host.OS, "target_arch": host.Arch, "llvm_triple": host.LLVMTriple, - "clang_path": clang, "linker_path": clang, "runtime_archive": filepath.ToSlash(filepath.Join("targets", host.LLVMTriple, "lib", "libpeeper_rt_v1.a")), - "runtime_abi": "peeper_rt_v1", "link_mode": "system", "debug_format": debugFormat, - } - if host.OS == "darwin" { - profile["sdk_discovery"] = "xcrun" - profile["minimum_os"] = "14.0" - } - data, err := json.Marshal(profile) - if err != nil { - t.Fatal(err) - } - writeFixtureFile(t, filepath.Join(root, "toolchains", "native", "profile.json"), string(data), 0o644) - }), - } - for i := range components { - data := fixture.responses[[]string{"/compiler.tar.gz", "/toolchain.tar.gz"}[i]] - components[i].URL = fixture.server.URL + []string{"/compiler.tar.gz", "/toolchain.tar.gz"}[i] - components[i].Size = int64(len(data)) - components[i].SHA256 = digest(data) - components[i].Format = distribution.FormatTarGz - } - fixture.manifest = distribution.ReleaseManifest{ - SchemaVersion: distribution.ReleaseManifestVersion, Version: "0.2.0", Components: components, - InstallSets: []distribution.InstallSet{{OS: host.OS, Arch: host.Arch, Components: []string{"compiler-host", "toolchain-host"}}}, - } - fixture.signManifest(t) - return fixture -} - -func (fixture *releaseFixture) writePack(t *testing.T, requestPath string, metadata distribution.Metadata, populate func(string)) distribution.ReleaseComponent { - t.Helper() - root := t.TempDir() - populate(root) - archivePath := filepath.Join(t.TempDir(), filepath.Base(requestPath)) - if _, err := distribution.WritePack(root, archivePath, distribution.FormatTarGz, metadata); err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(archivePath) - if err != nil { - t.Fatal(err) - } - fixture.responses[requestPath] = data - return distribution.ReleaseComponent{ID: metadata.ID, Kind: metadata.Kind, Version: metadata.Version, OS: metadata.OS, Arch: metadata.Arch} -} - -func (fixture *releaseFixture) signManifest(t *testing.T) { - t.Helper() - data, err := json.Marshal(fixture.manifest) - if err != nil { - t.Fatal(err) - } - fixture.responses["/release-manifest.json"] = data - fixture.responses["/release-manifest.json.sig"] = []byte(base64.StdEncoding.EncodeToString(ed25519.Sign(fixture.privateKey, data))) -} - -func writeFixtureFile(t *testing.T, path, content string, mode os.FileMode) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), mode); err != nil { - t.Fatal(err) - } -} diff --git a/scripts/detect-changes.sh b/scripts/detect-changes.sh index 50595e4..b12909a 100644 --- a/scripts/detect-changes.sh +++ b/scripts/detect-changes.sh @@ -39,7 +39,7 @@ for file in "${files[@]}"; do runtime/*|internal/backend/*|internal/codegen/*) runtime=true; non_compiler=true ;; esac case "$file" in - pkg/distribution/*|cmd/distpack/*|cmd/distunpack/*|cmd/release-index/*|cmd/sign-release/*|cmd/toolchain-lock/*|internal/installer/*|scripts/install.sh|scripts/install.ps1) distribution=true; non_compiler=true ;; + pkg/distribution/*|cmd/distpack/*|cmd/distunpack/*|cmd/release-index/*|cmd/sign-release/*|cmd/toolchain-lock/*|scripts/install.sh|scripts/install.ps1) distribution=true; non_compiler=true ;; esac case "$file" in toolchains/*|scripts/fetch-toolchain.sh|scripts/plan-toolchains.sh|scripts/toolchain-fingerprint.sh|scripts/update-toolchain-lock.sh|scripts/update-toolchain-sources.sh|scripts/toolchain*_test.go|scripts/toolchains/*|internal/toolchain/*) toolchain=true; non_compiler=true ;; diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 8e8b0cb..f6317c7 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,6 +1,8 @@ -# Peeper bootstrap installer: downloads the native installer for the detected -# platform, verifies it against the published SHA256SUMS, runs it, and -# persists the binary directory in the user PATH. +# Peeper bootstrap installer: downloads the release manifest, verifies it +# against the published SHA256SUMS, downloads the compiler and toolchain packs +# for the detected platform, verifies every SHA-256, extracts into a staging +# directory, activates atomically, and persists the binary directory in the +# user PATH. $ErrorActionPreference = "Stop" $repository = "PeeperLanguage/compiler" @@ -11,33 +13,65 @@ $arch = switch ($env:PROCESSOR_ARCHITECTURE) { "ARM64" { "arm64" } default { throw "peeper install: unsupported architecture: $env:PROCESSOR_ARCHITECTURE" } } -$installer = "peeper-installer-windows-$arch.exe" $work = Join-Path ([System.IO.Path]::GetTempPath()) "peeper-install-$([Guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Path $work | Out-Null try { + Invoke-WebRequest "$baseUrl/SHA256SUMS" -OutFile "$work/SHA256SUMS" $ProgressPreference = 'Continue' - Invoke-WebRequest "$baseUrl/$installer" -OutFile "$work/$installer" + Invoke-WebRequest "$baseUrl/release-manifest.json" -OutFile "$work/release-manifest.json" $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest "$baseUrl/SHA256SUMS" -OutFile "$work/SHA256SUMS" $expected = $null foreach ($line in Get-Content "$work/SHA256SUMS") { - if ($line -match "^([0-9a-f]{64})\s+\*?$installer$") { $expected = $Matches[1] } + if ($line -match "^([0-9a-f]{64})\s+\*?release-manifest\.json$") { $expected = $Matches[1] } + } + if (-not $expected) { throw "peeper install: checksum for release-manifest.json not found in SHA256SUMS" } + $actual = (Get-FileHash "$work/release-manifest.json" -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { throw "peeper install: release manifest checksum mismatch" } + + $manifest = Get-Content "$work/release-manifest.json" -Raw | ConvertFrom-Json + $components = $manifest.components | Where-Object { $_.os -eq "windows" -and $_.arch -eq $arch } + $compiler = $components | Where-Object { $_.kind -eq "compiler" } + $toolchain = $components | Where-Object { $_.kind -eq "toolchain" } + if (-not $compiler -or -not $toolchain) { throw "peeper install: release manifest has no complete component set for windows/$arch" } + + function Download-Component($component, $output) { + if (-not $component.url.StartsWith("https://")) { throw "peeper install: component URL is not HTTPS: $($component.url)" } + $ProgressPreference = 'Continue' + Invoke-WebRequest $component.url -OutFile $output + $ProgressPreference = 'SilentlyContinue' + $actual = (Get-FileHash $output -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $component.sha256) { throw "peeper install: component checksum mismatch: $output" } } - if (-not $expected) { throw "peeper install: checksum for $installer not found in SHA256SUMS" } - $actual = (Get-FileHash "$work/$installer" -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $expected) { throw "peeper install: checksum mismatch for $installer" } - $output = & "$work/$installer" 2>&1 - $output | ForEach-Object { Write-Host $_ } + Download-Component $compiler "$work/compiler.zip" + Download-Component $toolchain "$work/toolchain.zip" + + $staging = Join-Path $work "staging" + Expand-Archive "$work/compiler.zip" -DestinationPath $staging + Expand-Archive "$work/toolchain.zip" -DestinationPath $staging + if (-not (Test-Path (Join-Path $staging "bin\peeper.exe"))) { throw "peeper install: staged installation has no peeper executable" } + if (-not (Test-Path (Join-Path $staging "toolchains\native\profile.json"))) { throw "peeper install: staged installation has no managed toolchain profile" } - $binDir = $null - foreach ($line in $output) { - if ($line -match '^Add (.+) to PATH\.$') { $binDir = $Matches[1] } + $installRoot = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Peeper" } else { Join-Path $HOME ".peeper" } + $backup = "$installRoot.old" + if (Test-Path $installRoot) { + if (Test-Path $backup) { Remove-Item -Recurse -Force $backup } + Move-Item $installRoot $backup } - if (-not $binDir) { throw "peeper install: could not determine binary directory" } + try { + Move-Item $staging $installRoot + if (Test-Path $backup) { Remove-Item -Recurse -Force $backup } + } catch { + if (Test-Path $backup) { Move-Item $backup $installRoot } + throw "peeper install: could not activate installation at $installRoot" + } + + Write-Host "Installed Peeper $($manifest.version) in $installRoot" + Write-Host "Add $(Join-Path $installRoot 'bin') to PATH." + $binDir = Join-Path $installRoot "bin" $userPath = [Environment]::GetEnvironmentVariable("Path", "User") $entries = @() if ($userPath) { $entries = $userPath -split ';' | Where-Object { $_ } } diff --git a/scripts/install.sh b/scripts/install.sh index 4cf9adb..0c1d482 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,16 +1,23 @@ #!/bin/sh -# Peeper bootstrap installer: downloads the native installer for the detected -# platform, verifies it against the published SHA256SUMS, runs it, and -# persists the binary directory in the user PATH. +# Peeper bootstrap installer: downloads the release manifest, verifies it +# against the published SHA256SUMS, downloads the compiler and toolchain packs +# for the detected platform, verifies every SHA-256, extracts into a staging +# directory, activates atomically, and persists the binary directory in the +# user PATH. set -eu repository="PeeperLanguage/compiler" base_url="https://github.com/${repository}/releases/latest/download" +fail() { + echo "peeper install: $1" >&2 + exit 1 +} + case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; - *) echo "peeper install: unsupported operating system: $(uname -s)" >&2; exit 1 ;; + *) fail "unsupported operating system: $(uname -s)" ;; esac machine=$(uname -m) @@ -23,47 +30,108 @@ esac if [ "$os" = darwin ] && [ "$arch" = amd64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then arch=arm64 fi -[ -n "$arch" ] || { echo "peeper install: unsupported architecture: $machine" >&2; exit 1; } +[ -n "$arch" ] || fail "unsupported architecture: $machine" -installer="peeper-installer-${os}-${arch}" -work="$(mktemp -d)" +# Staging lives in $HOME so activation is a same-filesystem rename. +work="$(mktemp -d "$HOME/.peeper-install-XXXXXX")" trap 'rm -rf "$work"' EXIT -curl --proto '=https' --tlsv1.2 -fL --progress-bar "$base_url/$installer" -o "$work/$installer" curl --proto '=https' --tlsv1.2 -fsSL "$base_url/SHA256SUMS" -o "$work/SHA256SUMS" +curl --proto '=https' --tlsv1.2 -fL --progress-bar "$base_url/release-manifest.json" -o "$work/release-manifest.json" -expected=$(grep " ${installer}\$" "$work/SHA256SUMS" | cut -d' ' -f1 || true) +expected=$(grep " release-manifest.json\$" "$work/SHA256SUMS" | cut -d' ' -f1 || true) case "$expected" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; - *) echo "peeper install: checksum for $installer not found in SHA256SUMS" >&2; exit 1 ;; + *) fail "checksum for release-manifest.json not found in SHA256SUMS" ;; esac - if command -v sha256sum >/dev/null 2>&1; then - printf '%s %s\n' "$expected" "$work/$installer" | sha256sum -c - >/dev/null + actual=$(sha256sum "$work/release-manifest.json" | cut -d' ' -f1) else - printf '%s %s\n' "$expected" "$work/$installer" | shasum -a 256 -c - >/dev/null + actual=$(shasum -a 256 "$work/release-manifest.json" | cut -d' ' -f1) fi +[ "$actual" = "$expected" ] || fail "release manifest checksum mismatch" + +# Extract one field from the component object matching kind/os/arch. The +# manifest is generated with a fixed field order, one field per line. +component_field() { + awk -v RS="}" \ + -v kind="\"kind\": \"$1\"" \ + -v os="\"os\": \"$2\"" \ + -v arch="\"arch\": \"$3\"" \ + -v field="\"$4\": " ' + index($0, kind) && index($0, os) && index($0, arch) { + if (match($0, field "\"[^\"]*\"")) { + print substr($0, RSTART + length(field), RLENGTH - length(field) - 1) + exit + } + } + ' "$work/release-manifest.json" +} + +compiler_url=$(component_field compiler "$os" "$arch" url) +compiler_sha=$(component_field compiler "$os" "$arch" sha256) +version=$(component_field compiler "$os" "$arch" version) +toolchain_url=$(component_field toolchain "$os" "$arch" url) +toolchain_sha=$(component_field toolchain "$os" "$arch" sha256) +[ -n "$compiler_url" ] && [ -n "$compiler_sha" ] && [ -n "$version" ] && [ -n "$toolchain_url" ] && [ -n "$toolchain_sha" ] \ + || fail "release manifest has no complete component set for $os/$arch" -chmod +x "$work/$installer" -output="$("$work/$installer")" -printf '%s\n' "$output" +download_component() { + url=$1 + sha=$2 + output=$3 + case "$url" in + https://*) ;; + *) fail "component URL is not HTTPS: $url" ;; + esac + curl --proto '=https' --tlsv1.2 -fL --progress-bar "$url" -o "$output" + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$output" | cut -d' ' -f1) + else + actual=$(shasum -a 256 "$output" | cut -d' ' -f1) + fi + [ "$actual" = "$sha" ] || fail "component checksum mismatch: $output" +} + +download_component "$compiler_url" "$compiler_sha" "$work/compiler.tar.gz" +download_component "$toolchain_url" "$toolchain_sha" "$work/toolchain.tar.gz" + +mkdir -p "$work/staging" +tar -xzf "$work/compiler.tar.gz" -C "$work/staging" +tar -xzf "$work/toolchain.tar.gz" -C "$work/staging" +test -x "$work/staging/bin/peeper" || fail "staged installation has no peeper executable" +test -f "$work/staging/toolchains/native/profile.json" || fail "staged installation has no managed toolchain profile" + +install_root="$HOME/.peeper" +if [ -e "$install_root" ]; then + rm -rf "$install_root.old" + mv "$install_root" "$install_root.old" +fi +if mv "$work/staging" "$install_root"; then + rm -rf "$install_root.old" +else + if [ -d "$install_root.old" ]; then + mv "$install_root.old" "$install_root" + fi + fail "could not activate installation at $install_root" +fi -bin_dir=$(printf '%s\n' "$output" | sed -n 's/^Add \(.*\) to PATH\.$/\1/p') -[ -n "$bin_dir" ] || { echo "peeper install: could not determine binary directory" >&2; exit 1; } +echo "Installed Peeper $version in $install_root" +echo "Add $install_root/bin to PATH." persist_path() { file=$1 line=$2 mkdir -p "$(dirname "$file")" - if [ ! -f "$file" ] || ! grep -Fq "$bin_dir" "$file"; then + if [ ! -f "$file" ] || ! grep -Fq "$install_root/bin" "$file"; then printf '\n%s\n' "$line" >> "$file" - echo "Added $bin_dir to $file. Restart your terminal to apply." + echo "Added $install_root/bin to $file. Restart your terminal to apply." fi } case "${SHELL##*/}" in - zsh) persist_path "$HOME/.zshrc" "export PATH=\"$bin_dir:\$PATH\" # peeper" ;; - bash) persist_path "$HOME/.bashrc" "export PATH=\"$bin_dir:\$PATH\" # peeper" ;; - fish) persist_path "$HOME/.config/fish/conf.d/peeper.fish" "fish_add_path \"$bin_dir\"" ;; - *) persist_path "$HOME/.profile" "export PATH=\"$bin_dir:\$PATH\" # peeper" ;; + zsh) persist_path "$HOME/.zshrc" "export PATH=\"$install_root/bin:\$PATH\" # peeper" ;; + bash) persist_path "$HOME/.bashrc" "export PATH=\"$install_root/bin:\$PATH\" # peeper" ;; + fish) persist_path "$HOME/.config/fish/conf.d/peeper.fish" "fish_add_path \"$install_root/bin\"" ;; + *) persist_path "$HOME/.profile" "export PATH=\"$install_root/bin:\$PATH\" # peeper" ;; esac From 73033e11c720aec8e4be23f6a7bb585d18646b6d Mon Sep 17 00:00:00 2001 From: itsfuad Date: Mon, 31 Aug 2026 03:40:02 +0600 Subject: [PATCH 6/6] Fix manifest field extraction and add artifact retention component_field now strips the field prefix and quotes robustly instead of fragile substring arithmetic that left a leading quote on extracted URLs. Release workflow artifacts get retention-days: 1 since they are only needed within the run, and draft release notes no longer mention installers. --- .github/workflows/release-host.yml | 1 + .github/workflows/release.yml | 1 + scripts/install.sh | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-host.yml b/.github/workflows/release-host.yml index f07486f..7054451 100644 --- a/.github/workflows/release-host.yml +++ b/.github/workflows/release-host.yml @@ -124,3 +124,4 @@ jobs: path: dist if-no-files-found: error compression-level: 0 + retention-days: 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2823ae9..e52a596 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,6 +143,7 @@ jobs: path: release-assets if-no-files-found: error compression-level: 0 + retention-days: 1 finalize_release: name: Finalize release diff --git a/scripts/install.sh b/scripts/install.sh index 0c1d482..fe862be 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -61,7 +61,9 @@ component_field() { -v field="\"$4\": " ' index($0, kind) && index($0, os) && index($0, arch) { if (match($0, field "\"[^\"]*\"")) { - print substr($0, RSTART + length(field), RLENGTH - length(field) - 1) + value = substr($0, RSTART + length(field), RLENGTH - length(field)) + gsub(/^"|"$/, "", value) + print value exit } }