diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8af468a..ce4dbc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: run: shell: bash steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: go.mod - name: Install mercurial and subversion (Linux) @@ -54,12 +54,38 @@ jobs: run: go mod tidy && git diff --exit-code go.mod go.sum - name: Unit and end-to-end tests run: go test -count=1 ./... + install-scripts: + name: Installers against a fake release (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + - name: install.sh and install.ps1 + if: runner.os != 'Windows' + run: scripts/install-test.sh + - name: install.ps1 + if: runner.os == 'Windows' + shell: pwsh + run: scripts/install-test.ps1 + changelog-fragments: + name: Changelog fragments well-formed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - run: python3 scripts/changelog.py check changelog: name: Changelog fragment runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 - name: Require a fragment for code changes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d852c29..20db273 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,61 +1,185 @@ name: Release +# A maintainer tags vX.Y.Z on main (CONTRIBUTING.md § Releases). The tag publishes nothing +# until the repository gate is green on every platform the release is built for; then the +# archives are built, their checksum manifest signed with cosign (keyless, the identity being +# this workflow on the tag), and the GitHub release created with the CHANGELOG.md section +# of the version as its notes. The installers are then run against the published release. + on: push: tags: ["v*"] permissions: - contents: write + contents: read + +env: + SYSPI_REQUIRE_VCS: "1" jobs: - build: - name: Build ${{ matrix.goos }}/${{ matrix.goarch }} - runs-on: ubuntu-latest + gate: + name: Go gate (${{ matrix.os }}) strategy: + fail-fast: false matrix: - goos: [linux, darwin, windows] - goarch: [amd64, arm64] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + defaults: + run: + shell: bash steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: go.mod + - name: Install mercurial and subversion (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update -q && sudo apt-get install -y -q mercurial subversion + - name: Install mercurial and subversion (macOS) + if: runner.os == 'macOS' + run: brew install mercurial subversion + - name: Install mercurial and subversion (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install -y --no-progress hg sliksvn + Add-Content $env:GITHUB_PATH 'C:\Program Files\Mercurial' + Add-Content $env:GITHUB_PATH 'C:\Program Files\SlikSvn\bin' + - name: Versions + run: go version && git --version && hg --version | head -1 && svn --version --quiet - name: Build - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - CGO_ENABLED: "0" - run: | - ext="" - [ "$GOOS" = windows ] && ext=".exe" - name="syspi_${GITHUB_REF_NAME}_${GOOS}_${GOARCH}" - mkdir -p dist - go build -trimpath -ldflags "-s -w -X main.Version=${GITHUB_REF_NAME}" -o "dist/${name}${ext}" ./cmd/syspi - - uses: actions/upload-artifact@v4 - with: - name: syspi-${{ matrix.goos }}-${{ matrix.goarch }} - path: dist/* - if-no-files-found: error - release: - name: Checksums and GitHub release - needs: build + run: go build ./... + - name: Vet + run: go vet ./... + - name: gofmt + run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; } + - name: Tidy + run: go mod tidy && git diff --exit-code go.mod go.sum + - name: Unit and end-to-end tests + run: go test -count=1 ./... + + publish: + name: Build, sign and publish + needs: gate runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write # create the release and upload its assets + id-token: write # cosign keyless signing steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + + - name: Check the tag names a version with a changelog section + env: + TAG: ${{ github.ref_name }} + run: | + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "::error::tag $TAG is not vX.Y.Z"; exit 1; } + python3 scripts/changelog.py release-notes "${TAG#v}" > "$RUNNER_TEMP/notes.md" + echo "notes for $TAG:"; cat "$RUNNER_TEMP/notes.md" + + - name: Build the release archives + env: + TAG: ${{ github.ref_name }} + run: scripts/build-release-artifacts.sh "$TAG" dist + + - name: Check the checksum manifest + run: | + cd dist + repeated=$(awk '{ print $2 }' SHA256SUMS.txt | sort | uniq -d) + [[ -z "$repeated" ]] || { echo "::error::SHA256SUMS.txt lists more than once: $repeated"; exit 1; } + [[ $(wc -l < SHA256SUMS.txt) -eq 6 ]] || { echo "::error::SHA256SUMS.txt lists $(wc -l < SHA256SUMS.txt) assets, not 6"; exit 1; } + sha256sum --check --strict SHA256SUMS.txt + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: - path: dist - merge-multiple: true - - name: Check the tag equals the version it carries - run: | - case "$GITHUB_REF_NAME" in - v[0-9]*.[0-9]*.[0-9]*) ;; - *) echo "tag $GITHUB_REF_NAME is not v"; exit 1 ;; - esac - - name: SHA-256 checksums - working-directory: dist - run: sha256sum syspi_* > SHA256SUMS && cat SHA256SUMS - - name: Create the release + cosign-release: v3.0.3 + + - name: Sign the checksum manifest with cosign keyless + run: | + cd dist + cosign sign-blob SHA256SUMS.txt \ + --bundle SHA256SUMS.txt.bundle \ + --use-signing-config=false \ + --yes + + - name: Verify the signature against the identity install.sh checks + env: + WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + regexp=$(sed -n "s/^RELEASE_IDENTITY_REGEXP='\(.*\)'$/\1/p" install.sh) + [[ -n "$regexp" ]] || { echo "::error::RELEASE_IDENTITY_REGEXP not found in install.sh"; exit 1; } + cd dist + cosign verify-blob SHA256SUMS.txt \ + --bundle SHA256SUMS.txt.bundle \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity "https://github.com/${WORKFLOW_REF}" + cosign verify-blob SHA256SUMS.txt \ + --bundle SHA256SUMS.txt.bundle \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp "$regexp" + + - name: Create the GitHub release env: GH_TOKEN: ${{ github.token }} - run: gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --generate-notes --verify-tag + TAG: ${{ github.ref_name }} + run: | + cat >> "$RUNNER_TEMP/notes.md" <<'NOTES' + + --- + + Install with the scripts in the repository (`install.sh`, `install.ps1`; see the + README) or download an archive and check it against `SHA256SUMS.txt`, which is signed + keyless by this workflow (`SHA256SUMS.txt.bundle`): + + ```sh + cosign verify-blob SHA256SUMS.txt --bundle SHA256SUMS.txt.bundle \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity https://github.com/Open-MBEE/SysPI/.github/workflows/release.yml@refs/tags/TAG + sha256sum --check --ignore-missing SHA256SUMS.txt + ``` + NOTES + sed -i "s|@refs/tags/TAG|@refs/tags/$TAG|" "$RUNNER_TEMP/notes.md" + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file "$RUNNER_TEMP/notes.md" \ + --verify-tag \ + dist/syspi-* dist/SHA256SUMS.txt dist/SHA256SUMS.txt.bundle + gh release view "$TAG" --json url,assets --jq '.url, (.assets[].name)' + + validate-install: + name: Install from the release (${{ matrix.os }}) + needs: publish + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Install cosign + if: runner.os != 'Windows' + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: v3.0.3 + - name: install.sh + if: runner.os != 'Windows' + env: + TAG: ${{ github.ref_name }} + run: | + sh install.sh --version "$TAG" --prefix "$RUNNER_TEMP/syspi" --verify-signature + "$RUNNER_TEMP/syspi/bin/syspi" version | grep -Fx "syspi $TAG" + - name: install.ps1 + if: runner.os == 'Windows' + shell: pwsh + env: + TAG: ${{ github.ref_name }} + run: | + .\install.ps1 -Version $env:TAG -InstallDir "$env:RUNNER_TEMP\syspi" -NoPath + $reported = & "$env:RUNNER_TEMP\syspi\syspi.exe" version + if ($reported -ne "syspi $env:TAG") { throw "reports '$reported'" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d09341f..326356a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,10 +38,20 @@ The repository follows git-flow: ## Releases -A maintainer tags `vX.Y.Z` on `main`; the release workflow builds Linux, macOS and Windows -binaries for amd64 and arm64 with a `SHA256SUMS` file and attaches them to the GitHub release. -The `.project.json` convention for packages applies to SysPI's own releases: the tag equals the -version. +1. On a `release/x.y.z` branch from `develop`, run `python3 scripts/changelog.py release x.y.z`: + it folds `changes/unreleased/` into a dated `## x.y.z` section of `CHANGELOG.md` and deletes + the fragments. Open the pull request against `develop` and merge it. +2. Merge `develop` into `main` and tag `vx.y.z` on `main`; the tag equals the version, as the + `.project.json` convention requires of the packages SysPI installs. +3. The release workflow (`.github/workflows/release.yml`) runs the gate on Linux, macOS and + Windows, builds `syspi--.tar.gz` (`.zip` on Windows) for amd64 and arm64 with + `scripts/build-release-artifacts.sh`, signs `SHA256SUMS.txt` with cosign (keyless; the + identity is the workflow on the tag), creates the GitHub release with the changelog section + as its notes, then installs it on all three systems with `install.sh` / `install.ps1`. + A tag without a `CHANGELOG.md` section publishes nothing. + +`scripts/install-test.sh` runs the installers against a fake release served locally; CI runs it +on every pull request. To rehearse a release locally: `scripts/build-release-artifacts.sh v0.0.0-rc dist`. ## Licence diff --git a/README.md b/README.md index fc2696f..172cfab 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,46 @@ hg commit, so two builds of the same tree are byte-identical. ## Install +Linux and macOS: + +```sh +curl -fsSL https://raw.githubusercontent.com/Open-MBEE/SysPI/main/install.sh | sh +``` + +Windows (PowerShell): + +```powershell +irm https://raw.githubusercontent.com/Open-MBEE/SysPI/main/install.ps1 | iex +``` + +Both install the latest release's `syspi` for this machine (`/usr/local/bin` or `~/.local/bin`; +`%LOCALAPPDATA%\Programs\SysPI` on Windows) after checking the archive against the release's +`SHA256SUMS.txt`, and run it once to confirm it reports the release. `install.sh --help` lists +the options (`--version v0.1.0`, `--prefix`, `--bin-dir`, `--verify-signature` for the cosign +check of the manifest, `--dry-run`); `install.ps1` takes the same as `-Version`, `-InstallDir`, +`-VerifySignature`, `-DryRun`, and the environment variables `SYSPI_VERSION`, `SYSPI_PREFIX` / +`SYSPI_INSTALL_DIR` and `SYSPI_DOWNLOAD_BASE` configure a piped run. The checksum guards against a +corrupt or partial download; it comes from the same place as the archive, so against a replaced +release only the signature check helps — pass `--verify-signature` / `-VerifySignature` (needs +[cosign](https://docs.sigstore.dev/cosign/system_config/installation/)) or verify by hand as below. + +Each release attaches `syspi--.tar.gz` (`.zip` on Windows) for Linux, macOS and +Windows on amd64 and arm64, `SHA256SUMS.txt`, and its keyless cosign signature +`SHA256SUMS.txt.bundle`, verifiable with + +```sh +cosign verify-blob SHA256SUMS.txt --bundle SHA256SUMS.txt.bundle \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity https://github.com/Open-MBEE/SysPI/.github/workflows/release.yml@refs/tags/v0.1.0 +``` + +From source, with Go 1.25 or later: + ```sh go install github.com/Open-MBEE/SysPI/cmd/syspi@latest ``` -Release binaries for Linux, macOS and Windows (amd64, arm64) with a `SHA256SUMS` file are -attached to each `v*` tag. Requires `git` (and `svn` / `hg` for packages hosted there) on `PATH`. +Requires `git` (and `svn` / `hg` for packages hosted there) on `PATH`. ## Interoperability diff --git a/changes/unreleased/ecma-427-identities.changed.md b/changes/unreleased/ecma-427-identities.changed.md index 36c720b..dacf0b5 100644 --- a/changes/unreleased/ecma-427-identities.changed.md +++ b/changes/unreleased/ecma-427-identities.changed.md @@ -1 +1 @@ -- Package identities are ECMA-427 Package-URLs: a repository package is `pkg:generic/?vcs_url=+:///[@][#]` with the Annex B.3.5 `vcs_url` qualifier percent-encoded (`` is the repository's last path segment, `` a tag naming a semantic version). `pkg:git//[@][#]` and the bare `+://…` notation are accepted as input and normalised to it; `syspi add` writes the canonical form into `.project.json`. `+file:///path` addresses a repository on this machine. A plain `http(s)` IRI is only dereferenced as a project interchange file (KerML 1.1 §10.3 Table 12). +- Package identities are ECMA-427 Package-URLs: a repository package is `pkg:generic/?vcs_url=+:///[@][#]` with the Annex B.3.5 `vcs_url` qualifier percent-encoded (`` is the repository's last path segment, `` a tag naming a semantic version). `pkg:git//[@][#]` and the bare `+://…` notation are accepted as input and normalised to it; `syspi add` writes the canonical form into `.project.json`. `+file:///path` addresses a repository on this machine. A plain `http(s)` IRI is only dereferenced as a project interchange file (KerML 1.1 §10.3 Table 12). Other spellings (`pkg:svn/…`, `pkg:hg/…`, clone URLs such as `https://host/path.git@v`, `git@host:path`, `file:///path@v`) are refused with a message naming the accepted ones. diff --git a/changes/unreleased/ecma-427-identities.removed.md b/changes/unreleased/ecma-427-identities.removed.md deleted file mode 100644 index ec72f60..0000000 --- a/changes/unreleased/ecma-427-identities.removed.md +++ /dev/null @@ -1,2 +0,0 @@ -- The invented `pkg:svn/…` and `pkg:hg/…` identities, plain clone URLs as repository identities (`https://host/path.git@v`, `ssh://…`, `git@host:path`, `file:///path@v`, `/path@v`, `pkg:git//`): each is refused with a message naming the accepted spellings. -- `pkg:sysand//` resolution, the static index client (discovery document, URL templates, `-index`/`-no-index`, `SYSPI_INDEX`, `SYSAND_INDEX`, `SYSAND_DEFAULT_INDEX`, `SYSAND_CRED_*`), `syspi index emit` and the reading of `.sysand/lib/`. Remote `.kpar` downloads keep their netrc credentials. diff --git a/changes/unreleased/release-line.added.md b/changes/unreleased/release-line.added.md new file mode 100644 index 0000000..13e3029 --- /dev/null +++ b/changes/unreleased/release-line.added.md @@ -0,0 +1 @@ +- Releases publish `syspi--.tar.gz` (`.zip` on Windows) for Linux, macOS and Windows on amd64 and arm64, each holding `syspi` and `LICENSE`, with `SHA256SUMS.txt` signed keyless by the release workflow (`SHA256SUMS.txt.bundle`); the tag publishes nothing until the repository gate is green on all three systems. `install.sh` (Linux, macOS) and `install.ps1` (Windows) install a release after checking the download against the manifest and the installed binary against the tag. diff --git a/changes/unreleased/syspi-phase-1.added.md b/changes/unreleased/syspi-phase-1.added.md index fbaf84c..50ce436 100644 --- a/changes/unreleased/syspi-phase-1.added.md +++ b/changes/unreleased/syspi-phase-1.added.md @@ -1 +1 @@ -- The `syspi` command: `init`, `add`, `install`, `sync`, `lock`, `bundle` and `index emit` for KerML / SysML v2 project-interchange packages identified as `pkg://[@][#]` (git, svn, hg), local directories, local and remote `.kpar` files, OMG standard-library IRIs and `pkg:sysand//` through a static index. Packages are verified before use, installed under `lib/-_` beside `.project.json` and recorded in a deterministic `.project.lock.json` with a VCS-neutral tree hash. +- The `syspi` command: `init`, `add`, `install`, `sync`, `lock`, `bundle` and `version` for KerML / SysML v2 project-interchange packages — any git, svn or hg repository with `.project.json` at a tag, a local directory, a local or remote `.kpar` file, or an OMG standard-library IRI. Packages are verified before use, installed under `lib/-_` beside `.project.json` and recorded in a deterministic `.project.lock.json` with a VCS-neutral tree hash. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..9426850 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,325 @@ +<# +.SYNOPSIS +Installs syspi, the SysML Package Installer, from a GitHub release. + +.DESCRIPTION +Windows PowerShell 5.1 or PowerShell 7, on Windows: + + irm https://raw.githubusercontent.com/Open-MBEE/SysPI/main/install.ps1 | iex + +With options, save it first (a piped script takes none; the environment +variables below configure it instead): + + irm https://raw.githubusercontent.com/Open-MBEE/SysPI/main/install.ps1 -OutFile install.ps1 + .\install.ps1 -Version v0.1.0 + +The release's archive (syspi-windows-amd64.zip; the tar.gz on Linux and macOS +under PowerShell 7) is downloaded with its SHA256SUMS.txt manifest, checked +against the manifest before anything is installed, and the installed binary is +run once to confirm it reports the release it came from. This script installs +for the current user and touches only the user PATH. + +Needs: PowerShell 5.1+ (Windows) or PowerShell 7 (elsewhere, with tar). + +.PARAMETER Version +Release tag (v0.1.0) or `latest` (default). [SYSPI_VERSION] + +.PARAMETER InstallDir +Where the binary goes; default $env:LOCALAPPDATA\Programs\SysPI on Windows, +~/.local/bin elsewhere. [SYSPI_INSTALL_DIR] + +.PARAMETER Os +Stage another platform's build instead of this machine's (windows, linux or +darwin); it is not run. + +.PARAMETER Arch +Architecture of the build (amd64 or arm64); default this machine's. + +.PARAMETER BaseUrl +Where the releases are, for a mirror; default +https://github.com/Open-MBEE/SysPI/releases. [SYSPI_DOWNLOAD_BASE] + +.PARAMETER NoPath +Leave the user PATH alone (Windows only; elsewhere PATH is never edited). + +.PARAMETER DryRun +Print what would be installed and from where, then exit. + +.LINK +https://github.com/Open-MBEE/SysPI#install +#> +# Progress is written to the host on purpose: it is for the person running the +# installer, not for a pipeline, and `irm | iex` has no caller to receive output. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] +[CmdletBinding()] +param( + [string]$Version = $(if ($env:SYSPI_VERSION) { $env:SYSPI_VERSION } else { 'latest' }), + [string]$InstallDir = $env:SYSPI_INSTALL_DIR, + [ValidateSet('', 'windows', 'linux', 'darwin')] + [string]$Os = '', + [ValidateSet('', 'amd64', 'arm64')] + [string]$Arch = '', + [string]$BaseUrl = $(if ($env:SYSPI_DOWNLOAD_BASE) { $env:SYSPI_DOWNLOAD_BASE } else { 'https://github.com/Open-MBEE/SysPI/releases' }), + [switch]$NoPath, + [switch]$VerifySignature, + [switch]$DryRun +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' +# Invoke-WebRequest on Windows PowerShell 5.1 is slow while it draws progress. +$ProgressPreference = 'SilentlyContinue' + +$RepoUrl = 'https://github.com/Open-MBEE/SysPI' +# The identity the checksum manifest is signed with: the release workflow, run on the tag. +$ReleaseOidcIssuer = 'https://token.actions.githubusercontent.com' +$WantSignature = [bool]$VerifySignature +$ReleaseIdentityRegexp = '^https://github\.com/Open-MBEE/SysPI/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' +$GoInstallHint = "'go install github.com/Open-MBEE/SysPI/cmd/syspi@latest' builds one for this machine" + +# Run as a file the script exits with the message; piped into Invoke-Expression it +# throws instead, since exit there would close the caller's session. +$RunAsFile = [bool]$PSCommandPath +function Fail([string]$Message) { + if (-not $RunAsFile) { throw "error: $Message" } + [Console]::Error.WriteLine("error: $Message") + exit 1 +} + +# --- which version ---------------------------------------------------------- + +switch -Regex ($Version) { + '^latest$' { break } + '^v\d+\.\d+\.\d+' { break } + '^\d+\.\d+\.\d+' { $Version = "v$Version"; break } + default { Fail "-Version must be a release tag like v0.1.0 or 'latest', not '$Version'" } +} + +$BaseUrl = $BaseUrl.TrimEnd('/') + +# --- the platform ----------------------------------------------------------- + +# $IsWindows exists only from PowerShell 6; Windows PowerShell is always on Windows. +function Test-PlatformFlag([string]$Name) { [bool](Get-Variable -Name $Name -ValueOnly -ErrorAction SilentlyContinue) } +$HostIsWindows = ($PSVersionTable.PSEdition -eq 'Desktop') -or ($PSVersionTable.PSVersion.Major -lt 6) -or (Test-PlatformFlag IsWindows) +$HostOs = if ($HostIsWindows) { 'windows' } elseif (Test-PlatformFlag IsMacOS) { 'darwin' } elseif (Test-PlatformFlag IsLinux) { 'linux' } else { '' } + +$HostArch = '' +try { + $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +} catch { + $osArch = $env:PROCESSOR_ARCHITECTURE +} +switch -Regex ($osArch) { + '^(X64|AMD64)$' { $HostArch = 'amd64' } + '^(Arm64|ARM64)$' { $HostArch = 'arm64' } +} + +if (-not $Os) { + $Os = $HostOs + if (-not $Os) { Fail "unsupported operating system: releases are built for Windows, Linux and macOS; see $RepoUrl#install for building from source" } +} +if (-not $Arch) { + $Arch = $HostArch + if (-not $Arch) { Fail "unsupported architecture '$osArch': releases are built for amd64 and arm64; $GoInstallHint" } +} +$Platform = "$Os-$Arch" +$IsHostPlatform = ($Os -eq $HostOs) -and ($Arch -eq $HostArch) +$Exe = if ($Os -eq 'windows') { '.exe' } else { '' } +$Archive = if ($Os -eq 'windows') { "syspi-$Platform.zip" } else { "syspi-$Platform.tar.gz" } +$Manifest = 'SHA256SUMS.txt' + +# --- where it goes ---------------------------------------------------------- + +if (-not $InstallDir) { + if ($HostIsWindows) { + $InstallDir = Join-Path $env:LOCALAPPDATA 'Programs\SysPI' + } else { + $InstallDir = Join-Path $HOME '.local/bin' + } +} +$Destination = Join-Path $InstallDir "syspi$Exe" + +# --- which release ---------------------------------------------------------- + +if ($BaseUrl -like 'https://*' -or $BaseUrl -like 'http://*') { + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } catch { + $null = $_ # the runtime already speaks TLS 1.2 or later + } +} + +function Resolve-Latest { + # The redirect GitHub serves for /releases/latest names the tag before anything + # is downloaded; a mirror without it still works through latest/download/. + try { + $request = [System.Net.WebRequest]::Create("$BaseUrl/latest") + $request.Method = 'HEAD' + $request.AllowAutoRedirect = $false + $response = $request.GetResponse() + try { + $location = $response.Headers['Location'] + } finally { + $response.Close() + } + if ($location -match '/tag/([^/]+)$') { return $Matches[1] } + } catch { + $null = $_ # no redirect to read + } + return $null +} + +$Tag = $Version +if ($Version -eq 'latest') { + $Tag = Resolve-Latest + $AssetDir = if ($Tag) { "$BaseUrl/download/$Tag" } else { "$BaseUrl/latest/download" } +} else { + $AssetDir = "$BaseUrl/download/$Version" +} +function Get-AssetUrl([string]$Asset) { "$AssetDir/$Asset" } +$ReleaseName = if ($Tag) { $Tag } else { 'the latest release' } + +# --- download and verify ---------------------------------------------------- + +$Work = Join-Path ([System.IO.Path]::GetTempPath()) ("syspi-install-" + [System.IO.Path]::GetRandomFileName()) + +function Get-Asset([string]$Asset) { + $url = Get-AssetUrl $Asset + $destination = Join-Path $Work $Asset + try { + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $destination + } catch { + if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Force } + Fail "could not download $url ($($_.Exception.Message)); is $ReleaseName a published release with a $Platform build? See $BaseUrl" + } +} + +# Fetches the manifest and, with -VerifySignature, checks its cosign bundle before +# anything is read from it. +function Get-Manifest { + Get-Asset $Manifest + if (-not $WantSignature) { return } + if (-not (Get-Command cosign -ErrorAction SilentlyContinue)) { Fail 'cosign is needed for -VerifySignature; see https://docs.sigstore.dev/cosign/system_config/installation/' } + Get-Asset "$Manifest.bundle" + & cosign verify-blob (Join-Path $Work $Manifest) --bundle (Join-Path $Work "$Manifest.bundle") ` + --certificate-oidc-issuer $ReleaseOidcIssuer --certificate-identity-regexp $ReleaseIdentityRegexp 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "$Manifest is not signed by the SysPI release workflow" } + Write-Host " signature of $Manifest verified" +} + +function Get-ManifestDigest([string]$Asset) { + # The asset's distinct digests in the manifest, none when it is not listed; + # a line repeated verbatim counts once. + $digests = @() + foreach ($line in Get-Content -LiteralPath (Join-Path $Work $Manifest)) { + $fields = $line.Trim() -split '\s+', 2 + if ($fields.Count -eq 2 -and $fields[1].TrimStart('*') -eq $Asset) { $digests += $fields[0].ToLowerInvariant() } + } + return @($digests | Select-Object -Unique) +} + +function Test-Checksum([string]$Asset) { + $expected = @(Get-ManifestDigest $Asset) + if ($expected.Count -eq 0) { Fail "$Manifest of $ReleaseName does not list $Asset; the release has no $Platform build" } + if ($expected.Count -gt 1) { Fail "$Manifest of $ReleaseName lists $Asset with more than one digest ($($expected -join ' ')); the manifest is corrupt or tampered with" } + $expected = $expected[0] + $actual = (Get-FileHash -LiteralPath (Join-Path $Work $Asset) -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Fail "$Asset does not match $Manifest (expected $expected, got $actual); the download is corrupt or tampered with" } + Write-Host " $Asset verified" +} + +Write-Host "Installing syspi ($ReleaseName) for $Platform" +Write-Host " binary: $Destination" +Write-Host " from: $(Get-AssetUrl $Archive)" +if ($DryRun) { + Write-Host 'Dry run: nothing installed.' + return +} + +New-Item -ItemType Directory -Path $Work -Force | Out-Null +try { + Write-Host 'Downloading...' + Get-Manifest + Get-Asset $Archive + Test-Checksum $Archive + + # --- install ------------------------------------------------------------ + + $Extracted = Join-Path $Work 'unpacked' + New-Item -ItemType Directory -Path $Extracted -Force | Out-Null + if ($Archive -like '*.zip') { + Expand-Archive -LiteralPath (Join-Path $Work $Archive) -DestinationPath $Extracted -Force + } else { + if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { Fail "tar is needed to extract $Archive" } + & tar -xzf (Join-Path $Work $Archive) -C $Extracted + if ($LASTEXITCODE -ne 0) { Fail "tar could not extract $Archive" } + } + $Source = Join-Path $Extracted "syspi$Exe" + if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { Fail "$Archive has no syspi$Exe" } + + try { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + } catch { + Fail "cannot create $InstallDir ($($_.Exception.Message)); pass -InstallDir for a writable location" + } + # Copy beside the destination first, so an interrupted copy never leaves a + # half-written binary under the final name. + $Staged = "$Destination.tmp.$PID" + Copy-Item -LiteralPath $Source -Destination $Staged -Force + if (-not $HostIsWindows) { + & chmod 0755 $Staged + if ($LASTEXITCODE -ne 0) { Fail "chmod could not mark $Staged executable" } + } + Move-Item -LiteralPath $Staged -Destination $Destination -Force +} finally { + Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue +} + +# --- check ------------------------------------------------------------------ + +if ($IsHostPlatform) { + # Windows PowerShell turns a native command's stderr into a terminating + # error under 'Stop', so the preference is relaxed for the run itself. + $previous = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + # Every line is read before the first is kept: a pipeline Select-Object + # stops early kills the binary before its exit status is recorded. + $lines = @(& $Destination version 2>&1 | ForEach-Object { "$_" }) + $exit = $LASTEXITCODE + $reported = ($lines | Select-Object -First 1) -join '' + } finally { + $ErrorActionPreference = $previous + } + if ($exit -ne 0) { Fail "$Destination does not run: $reported" } + if ($Tag -and " $reported " -notlike "* $Tag *") { + Fail "$Destination reports '$reported', not $Tag" + } + Write-Host " $reported" +} else { + Write-Host " staged for $Platform, not run on this $HostOs-$HostArch machine" +} + +Write-Host "Installed: $Destination" + +# --- PATH ------------------------------------------------------------------- + +$Separator = [System.IO.Path]::PathSeparator +$OnPath = @(($env:PATH -split [regex]::Escape($Separator)) | ForEach-Object { $_.TrimEnd('\', '/') }) -contains $InstallDir.TrimEnd('\', '/') +if ($HostIsWindows -and -not $NoPath) { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $userEntries = @(if ($userPath) { $userPath -split ';' } else { @() }) | ForEach-Object { $_.TrimEnd('\') } + if ($userEntries -notcontains $InstallDir.TrimEnd('\')) { + $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + Write-Host "Added $InstallDir to the user PATH; new terminals will see it." + } + if (-not $OnPath) { $env:PATH = "$InstallDir$Separator$env:PATH" } +} elseif (-not $OnPath) { + Write-Warning "$InstallDir is not on PATH; add it to your profile." +} +$Found = Get-Command "syspi$Exe" -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($Found -and $Found.Source -ne $Destination) { + Write-Warning "'syspi' on PATH is $($Found.Source), which comes before $Destination" +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3b35a69 --- /dev/null +++ b/install.sh @@ -0,0 +1,382 @@ +#!/bin/sh +# Installs syspi, the SysML Package Installer, from a GitHub release. +# +# Linux and macOS (and Git Bash or MSYS2 on Windows; install.ps1 is the native +# Windows installer): +# +# curl -fsSL https://raw.githubusercontent.com/Open-MBEE/SysPI/main/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/Open-MBEE/SysPI/main/install.sh | sh -s -- --version v0.1.0 +# +# The release's archive (syspi--.tar.gz, .zip on Windows) is downloaded +# with its SHA256SUMS.txt manifest, checked against the manifest before anything is +# installed, and the installed binary is run once to confirm it reports the release +# it came from. Downloads go through curl or wget, which never set macOS's +# quarantine attribute, so Gatekeeper does not object. +# +# Run with --help for the options. +# +# Needs: curl or wget; sha256sum, shasum or openssl; and tar (unzip or a +# zip-capable tar for the Windows archive). +set -eu + +REPO_URL="https://github.com/Open-MBEE/SysPI" +DEFAULT_BASE_URL="$REPO_URL/releases" + +# The identity the checksum manifest is signed with: the release workflow, run on the tag. +RELEASE_OIDC_ISSUER="https://token.actions.githubusercontent.com" +RELEASE_IDENTITY_REGEXP='^https://github\.com/Open-MBEE/SysPI/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + +version="${SYSPI_VERSION:-latest}" +prefix="${SYSPI_PREFIX:-}" +bin_dir="${SYSPI_BIN_DIR:-}" +base_url="${SYSPI_DOWNLOAD_BASE:-$DEFAULT_BASE_URL}" +target_os="" +target_arch="" +verify_signature=false +dry_run=false + +usage() { + # A literal, not read back from $0: piped through `sh` there is no file to read. + cat <<'USAGE' +usage: install.sh [--version ] [--prefix |--bin-dir ] + [--os --arch ] [--base-url ] [--verify-signature] [--dry-run] + +Installs syspi, the SysML Package Installer, from a GitHub release, checking the +download against the release's SHA256SUMS.txt. + +Options (each has an environment variable, so a piped `sh` can be configured): + + --version release tag (v0.1.0) or `latest` (default) [SYSPI_VERSION] + --prefix install under /bin; default /usr/local when + writable, else ~/.local [SYSPI_PREFIX] + --bin-dir install the binary into (overrides --prefix) + [SYSPI_BIN_DIR] + --os , --arch + stage another platform's build instead of this machine's + (linux|darwin|windows, amd64|arm64); it is not run + --base-url where the releases are, for a mirror; default + https://github.com/Open-MBEE/SysPI/releases [SYSPI_DOWNLOAD_BASE] + --verify-signature also verify SHA256SUMS.txt's cosign bundle against the + identity that signs the releases (needs cosign on PATH) + --dry-run print what would be installed and from where, then exit + -h, --help this text +USAGE +} + +info() { printf '%s\n' "$*"; } +warn() { printf 'warning: %s\n' "$*" >&2; } +fail() { + printf 'install.sh: %s\n' "$*" >&2 + exit 1 +} + +need_value() { #