diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 789cc2c..b40a9fe 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,7 +2,16 @@ name: CI on: push: + branches: ['**'] pull_request: + workflow_call: + inputs: + version: + required: true + type: string + commit: + required: true + type: string permissions: contents: read @@ -11,32 +20,57 @@ jobs: validate: name: Validate (${{ matrix.os }}) runs-on: ${{ matrix.os }} + env: + RELEASE_VERSION: ${{ inputs.version || 'v0.0.0' }} + BINARY: ${{ matrix.binary }} + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' strategy: fail-fast: false matrix: include: - os: ubuntu-latest + artifact: linux binary: dscpkg test_args: -race - os: windows-latest + artifact: windows binary: dscpkg.exe test_args: "" - os: macos-latest + artifact: macos binary: dscpkg test_args: -race steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.commit || github.sha }} + persist-credentials: false + + - name: Set package directories + shell: pwsh + run: | + "DIST_DIR=$(Join-Path $env:RUNNER_TEMP 'dscpkg-dist')" >> $env:GITHUB_ENV + "FIXTURE_DIR=$(Join-Path $env:RUNNER_TEMP 'dscpkg-upgrade-fixtures')" >> $env:GITHUB_ENV + "DSCPKG_LOG_DIRECTORY=$(Join-Path $env:RUNNER_TEMP 'dscpkg-package-logs')" >> $env:GITHUB_ENV - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod cache: true + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + if: runner.os == 'Windows' + with: + dotnet-version: '8.0.x' + - name: Check formatting if: runner.os == 'Linux' shell: bash - run: gofmt -w $(git ls-files '*.go') && git diff --exit-code -- '*.go' + run: | + git ls-files -z '*.go' | xargs -0 -r gofmt -w + git diff --exit-code -- '*.go' - name: Vet run: go vet ./... @@ -44,11 +78,95 @@ jobs: - name: Test run: go test ${{ matrix.test_args }} ./... + - name: Test release safeguards + shell: pwsh + run: | + ./packaging/test-version.ps1 + ./packaging/test-release.ps1 + + - name: Install Linux packaging and validation tools + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y rpm shellcheck + mkdir -p "$RUNNER_TEMP/package-tools" + GOBIN="$RUNNER_TEMP/package-tools" go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0 + GOBIN="$RUNNER_TEMP/package-tools" go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 + echo "$RUNNER_TEMP/package-tools" >> "$GITHUB_PATH" + + - name: Lint workflows and packaging scripts + if: runner.os == 'Linux' + shell: bash + run: | + actionlint + shellcheck packaging/linux/*.sh + - name: Build - run: go build -trimpath -o ${{ matrix.binary }} ./cmd/dscpkg + shell: pwsh + env: + CGO_ENABLED: '0' + run: go build -trimpath -o $env:BINARY ./cmd/dscpkg - - uses: actions/upload-artifact@v4 + - name: Build and inspect release assets + shell: pwsh + run: | + ./packaging/build.ps1 -Version $env:RELEASE_VERSION -BinaryPath $env:BINARY -OutputDirectory $env:DIST_DIR + ./packaging/verify.ps1 -Version $env:RELEASE_VERSION -BinaryPath $env:BINARY -Directory $env:DIST_DIR + + - name: Build isolated upgrade fixtures + if: runner.os != 'macOS' + shell: pwsh + run: ./packaging/test-fixtures.ps1 -OutputDirectory $env:FIXTURE_DIR + + - name: Test Windows installer lifecycle + if: runner.os == 'Windows' + shell: pwsh + run: | + ./packaging/windows/test-install.ps1 -Disposable -Version $env:RELEASE_VERSION ` + -Directory $env:DIST_DIR -BinaryPath $env:BINARY -FixtureDirectory $env:FIXTURE_DIR ` + -LogDirectory $env:DSCPKG_LOG_DIRECTORY + + - name: Test DEB and RPM installer lifecycles + if: runner.os == 'Linux' + shell: bash + run: | + mkdir -p "$DSCPKG_LOG_DIRECTORY" + sudo env GITHUB_ACTIONS="$GITHUB_ACTIONS" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" \ + bash packaging/linux/test-install.sh --disposable deb "$RELEASE_VERSION" \ + "$DIST_DIR" "$PWD/dscpkg" "$FIXTURE_DIR" "$DSCPKG_LOG_DIRECTORY" + docker run --rm -e GITHUB_ACTIONS -e RUNNER_ENVIRONMENT \ + -v "$PWD:/src:ro" -v "$DIST_DIR:/assets:ro" -v "$FIXTURE_DIR:/fixtures:ro" \ + -v "$DSCPKG_LOG_DIRECTORY:/logs" fedora:44 \ + bash -c 'dnf -y install diffutils && exec bash /src/packaging/linux/test-install.sh --disposable rpm "$@"' \ + bash "$RELEASE_VERSION" /assets /src/dscpkg /fixtures /logs + + - name: Recheck tested asset checksums + shell: pwsh + run: | + . ./packaging/common.ps1 + $platform = Get-BinaryPlatform $env:BINARY + Assert-Checksums $env:DIST_DIR @(Get-AssetNames $env:RELEASE_VERSION $platform) + + - name: Upload installer diagnostics + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dscpkg-install-logs-${{ matrix.artifact }} + path: ${{ env.DSCPKG_LOG_DIRECTORY }} + overwrite: true + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: dscpkg-${{ runner.os }}-${{ runner.arch }} path: ${{ matrix.binary }} - if-no-files-found: error \ No newline at end of file + if-no-files-found: error + overwrite: true + + - name: Upload verified release payloads + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dscpkg-release-${{ matrix.artifact }} + path: ${{ env.DIST_DIR }}/* + if-no-files-found: error + overwrite: true \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..3f71a99 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,76 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-latest + outputs: + commit: ${{ steps.source.outputs.commit }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Validate release tag and source + id: source + shell: pwsh + env: + VERSION: ${{ github.ref_name }} + SOURCE_SHA: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + $commit = ./packaging/release.ps1 -Operation Preflight -Version $env:VERSION ` + -Commit $env:SOURCE_SHA -Repository $env:GH_REPO + "commit=$commit" >> $env:GITHUB_OUTPUT + + build: + needs: preflight + uses: ./.github/workflows/ci.yaml + with: + version: ${{ github.ref_name }} + commit: ${{ needs.preflight.outputs.commit }} + + draft: + needs: [preflight, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.preflight.outputs.commit }} + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dscpkg-release-linux + path: dist/linux + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dscpkg-release-windows + path: dist/windows + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dscpkg-release-macos + path: dist/macos + - name: Verify tested assets and create an unpublished draft + shell: pwsh + env: + VERSION: ${{ github.ref_name }} + SOURCE_SHA: ${{ needs.preflight.outputs.commit }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + ./packaging/release.ps1 -Operation Draft -Version $env:VERSION ` + -Commit $env:SOURCE_SHA -Repository $env:GH_REPO -AssetDirectory ./dist diff --git a/README.md b/README.md index 7c907a1..1e02b74 100644 --- a/README.md +++ b/README.md @@ -208,3 +208,129 @@ The GitHub Actions workflow runs formatting, vetting, tests (with race detection Linux and macOS), and builds on Linux, Windows, and macOS. It uploads one executable artifact for each runner operating system. Go build caching is enabled through `actions/setup-go`. + +CI also builds and inspects release archives and native installers. Windows MSI, +Ubuntu DEB, and containerized Fedora RPM tests cover fresh installation, +reinstallation/repair, upgrade replacement using isolated fixtures, uninstall, +and preservation of downloaded resources and caches. Windows checks system PATH +registration/removal and rejects downgrades. Installer tests run only on +disposable GitHub-hosted runners, never on a developer workstation. + +## Release downloads and installation + +Releases include Linux AMD64 and Windows AMD64 archives, a macOS archive for the +native architecture of the macOS CI runner, Windows MSI, Linux DEB/RPM, and +`SHA256SUMS`. macOS filenames use Go's `darwin` platform name and the actual +`amd64` or `arm64` build architecture. Extract an archive and put its `bin` +directory on PATH, or use the appropriate native installer: + +```sh +sudo apt install ./dscpkg_1.2.3_amd64.deb +# On an RPM-based distribution: +sudo dnf install ./dscpkg-1.2.3-1.x86_64.rpm +``` + +On Windows, run `dscpkg-v1.2.3-windows-amd64.msi` as administrator, or use: + +```powershell +msiexec /i dscpkg-v1.2.3-windows-amd64.msi /qn /norestart +``` + +The MSI installs to `%ProgramFiles%\dscpkg` and adds that directory to the system +PATH; open a new terminal after installation. DEB/RPM install `/usr/bin/dscpkg`. +These are machine-wide CLI installations, not services. No DSC runtime is +bundled or required to install dscpkg. + +Use Installed apps on Windows, `sudo apt remove dscpkg`, or `sudo dnf remove +dscpkg` to uninstall. Upgrades replace the executable; uninstall removes the +executable and its installer-managed PATH entry, but never deletes users' +downloaded resource packages, registries, or caches. Linux follows the native +package manager's version ordering; MSI blocks downgrades. + +Packages are unsigned. Verify downloaded files against the release's +`SHA256SUMS` before installation. On Linux, use +`sha256sum --check --ignore-missing SHA256SUMS`; on macOS, compare +`shasum -a 256 `; on Windows, compare +`Get-FileHash -Algorithm SHA256 ` with its manifest entry. Checksums +detect corruption but do not replace trusted download provenance or signing. + +## Building release packages + +Packaging requires PowerShell 7.2 or later, Git, Go, and native platform tools. +Windows additionally requires the .NET 8 SDK/runtime (or a compatible installed +toolchain); the project pins WiX SDK 7.0.0. Review the +[WiX license and fee requirements](https://docs.firegiant.com/wix/) +before using that toolchain; its build-time EULA setting is recorded in the +WiX project. This does not change dscpkg's MIT license. + +Linux additionally requires Bash, tar, nFPM 2.47.0, `dpkg-deb`, `rpm`, and +`rpm2archive` (provided by Ubuntu's RPM tooling). Install nFPM as a separate build +tool, not a runtime module dependency: + +```sh +go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0 +# Ensure Go's bin directory is on PATH. Go may download nFPM's required toolchain. +``` + +On each native platform, build one executable with CGO disabled and pass that +same binary to the packager. The output directory must be empty. For example, +in PowerShell on Windows: + +```powershell +$env:CGO_ENABLED = '0' +go build -trimpath -o dscpkg.exe ./cmd/dscpkg +./packaging/build.ps1 -Version v1.2.3 -BinaryPath ./dscpkg.exe -OutputDirectory ./dist +./packaging/verify.ps1 -Version v1.2.3 -BinaryPath ./dscpkg.exe -Directory ./dist +``` + +On Linux or macOS, use `CGO_ENABLED=0 go build -trimpath -o dscpkg ./cmd/dscpkg`, +then invoke the same scripts with `pwsh -NoProfile -File`, `-BinaryPath ./dscpkg`, +and the other arguments above. Inspection extracts packages without installing +the product. Windows MSI binding/ICE validation requires a Windows environment +whose installer policy permits validation; do not disable validation to produce +a release. + +`packaging/test-version.ps1` tests tag/version boundaries. +`packaging/test-release.ps1` tests release safeguards using an isolated Git +repository and mocked GitHub API, without creating real tags or releases. +CI uses `v0.0.0` for non-release package checks. Separate `v1.0.0`/`v1.0.1` +fixtures exercise upgrades even before the first release; they are never shipped. + +## Creating a draft release + +After the workflow changes are merged, push a stable `vX.Y.Z` tag pointing to a +reviewed commit already in `main`: + +```sh +git fetch origin +git tag v1.2.3 +git push origin refs/tags/v1.2.3 +``` + +The [release workflow](.github/workflows/release.yaml) validates the tag and runs +reusable CI on its exact commit. Lightweight and annotated tags are supported. +Tags must have exactly three numeric components without leading zeroes; RC tags, +build metadata, and other prereleases are unsupported. MSI uses the direct +`X.Y.Z` version: major/minor must be at most 255, and patch at most 65535. These +limits apply to the entire release, not just its MSI. + +Every platform's archive and installer contain the same executable bytes. After +all native checks pass, the final job downloads those exact tested assets from +the same workflow run, verifies the expected file sets and SHA-256 checksums, +and rechecks the remote tag. It creates a **draft normal release**, with generated +release notes and a combined `SHA256SUMS`, not a published release or prerelease. +Review its assets, source commit, and workflow results in **Releases**, then +publish manually. The workflow never creates or moves a tag. + +An existing release or draft stops the run rather than being overwritten. +Authentication/API failures are errors, not evidence that a release is absent. +If creation partially fails, inspect the draft and uploaded assets; remove an +incomplete draft manually only after review before retrying the unchanged tag. +For source fixes, merge the fix and push a new version tag instead of moving one. + +Re-run failed jobs while the original artifacts remain available. A rebuilt +platform must pass its complete package checks again; never skip them to make +a draft. If artifacts have expired, re-run the whole workflow. Installer failure +diagnostics are available as `dscpkg-install-logs-` artifacts. There is +no automatic publication, signing/notarization, macOS PKG, package repository, +SBOM, or attestation in this flow. diff --git a/packaging/build.ps1 b/packaging/build.ps1 new file mode 100644 index 0000000..58d212d --- /dev/null +++ b/packaging/build.ps1 @@ -0,0 +1,45 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Version, + [Parameter(Mandatory)][string] $BinaryPath, + [Parameter(Mandatory)][string] $OutputDirectory +) + +. "$PSScriptRoot/common.ps1" +$null = & "$PSScriptRoot/version.ps1" $Version +$binary = (Resolve-Path -LiteralPath $BinaryPath).Path +$platform = Get-BinaryPlatform $binary +$names = @(Get-AssetNames $Version $platform) +$hostTarget = (Invoke-Checked go @('env', 'GOHOSTOS', 'GOHOSTARCH')) -join '-' +Assert-Equal $platform $hostTarget 'Native packaging target' +$output = [IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Path $output -Force | Out-Null +Assert-FileSet $output @() +$repo = Split-Path $PSScriptRoot -Parent +$stage = Join-Path ([IO.Path]::GetTempPath()) ("dscpkg-package-" + [guid]::NewGuid()) +$name = "dscpkg-$Version-$platform" +$package = Join-Path $stage $name +$executable = if ($IsWindows) { 'dscpkg.exe' } else { 'dscpkg' } + +try { + New-Item -ItemType Directory -Path (Join-Path $package 'bin') -Force | Out-Null + Copy-Item -LiteralPath $binary -Destination (Join-Path $package "bin/$executable") + foreach ($file in @('README.md', 'LICENSE')) { + Copy-Item -LiteralPath (Join-Path $repo $file) -Destination $package + } + if ($IsWindows) { + Compress-Archive -LiteralPath $package -DestinationPath (Join-Path $output "$name.zip") + & "$PSScriptRoot/windows/msi/build.ps1" -Version $Version -BinaryPath $binary -OutputDirectory $output + } else { + Invoke-Checked chmod @('755', (Join-Path $package "bin/$executable")) + Invoke-Checked tar @('-czf', (Join-Path $output "$name.tar.gz"), '-C', $stage, $name) + if ($IsLinux) { + Invoke-Checked bash @("$PSScriptRoot/linux/build.sh", $Version, $binary, $output) + } + } + Write-Checksums $output $names +} finally { + if (Test-Path -LiteralPath $stage) { + Remove-Item -LiteralPath $stage -Recurse -Force + } +} diff --git a/packaging/common.ps1 b/packaging/common.ps1 new file mode 100644 index 0000000..c15af32 --- /dev/null +++ b/packaging/common.ps1 @@ -0,0 +1,126 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-Checked { + param([string] $Command, [string[]] $Arguments) + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$Command failed with exit code $LASTEXITCODE." + } +} + +function Assert-Equal { + param($Actual, $Expected, [string] $Context) + if ($Actual -cne $Expected) { + throw "${Context}: expected '$Expected', got '$Actual'." + } +} + +function Get-SHA256 { + param([string] $Path) + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Get-AssetNames { + param([string] $Version, [string] $Platform) + $packageVersion = & "$PSScriptRoot/version.ps1" -Version $Version + switch -Regex -CaseSensitive ($Platform) { + '^linux-amd64$' { + "dscpkg-$Version-linux-amd64.tar.gz" + "dscpkg_${packageVersion}_amd64.deb" + "dscpkg-${packageVersion}-1.x86_64.rpm" + } + '^windows-amd64$' { + "dscpkg-$Version-windows-amd64.zip" + "dscpkg-$Version-windows-amd64.msi" + } + '^darwin-(amd64|arm64)$' { "dscpkg-$Version-$Platform.tar.gz" } + default { throw "Unsupported release platform: $Platform" } + } +} + +function Assert-FileSet { + param([string] $Directory, [string[]] $Expected) + $actual = @(Get-ChildItem -LiteralPath $Directory -Force | ForEach-Object { + if ($_.PSIsContainer) { throw "Unexpected directory: $($_.FullName)" } + $_.Name + }) + Assert-Equal (($actual | Sort-Object -CaseSensitive) -join "`n") ` + (($Expected | Sort-Object -CaseSensitive) -join "`n") "Files in $Directory" +} + +function Write-Checksums { + param([string] $Directory, [string[]] $Names) + Assert-FileSet $Directory $Names + $lines = foreach ($name in ($Names | Sort-Object -CaseSensitive)) { + "$(Get-SHA256 (Join-Path $Directory $name)) $name" + } + Set-Content -LiteralPath (Join-Path $Directory 'SHA256SUMS') ` + -Value (($lines -join "`n") + "`n") -Encoding utf8NoBOM -NoNewline +} + +function Assert-Checksums { + param([string] $Directory, [string[]] $Names) + Assert-FileSet $Directory @($Names + 'SHA256SUMS') + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($line in Get-Content -LiteralPath (Join-Path $Directory 'SHA256SUMS')) { + if ($line -cnotmatch '\A([0-9a-f]{64}) ([A-Za-z0-9_.-]+)\z') { + throw "Invalid checksum entry: $line" + } + $hash, $name = $Matches[1], $Matches[2] + if ($Names -cnotcontains $name -or -not $seen.Add($name)) { + throw "Unexpected or duplicate checksum entry: $name" + } + Assert-Equal (Get-SHA256 (Join-Path $Directory $name)) $hash "Checksum for $name" + } + Assert-Equal $seen.Count $Names.Count 'Checksum entry count' +} + +function Get-BinaryPlatform { + param([string] $BinaryPath) + $settings = (Invoke-Checked go @('version', '-m', $BinaryPath)) -join "`n" + if ($settings -notmatch '(?m)^\s+build\s+GOOS=(\w+)\s*$') { + throw "Missing Go target OS in $BinaryPath." + } + $targetOS = $Matches[1] + if ($settings -notmatch '(?m)^\s+build\s+GOARCH=(\w+)\s*$') { + throw "Missing Go target architecture in $BinaryPath." + } + "$targetOS-$($Matches[1])" +} + +function Read-MsiTable { + param([string] $Path, [string] $Query, [string[]] $Columns) + $installer = New-Object -ComObject WindowsInstaller.Installer + $database = $installer.OpenDatabase($Path, 0) + $view = $database.OpenView($Query) + try { + $view.Execute() | Out-Null + while ($record = $view.Fetch()) { + $row = [ordered]@{} + for ($i = 0; $i -lt $Columns.Count; $i++) { + $row[$Columns[$i]] = $record.StringData($i + 1) + } + [pscustomobject]$row + [Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) | Out-Null + } + } finally { + $view.Close() | Out-Null + foreach ($object in @($view, $database, $installer)) { + [Runtime.InteropServices.Marshal]::FinalReleaseComObject($object) | Out-Null + } + } +} + +function Invoke-Msi { + param( + [string] $Operation, [string] $Path, [string] $LogPath, + [string[]] $Properties = @(), [int[]] $ExpectedCodes = @(0, 3010) + ) + $arguments = @($Operation, "`"$Path`"", '/qn', '/norestart', '/L*v', "`"$LogPath`"") + $Properties + $process = Start-Process -FilePath "$env:SystemRoot/System32/msiexec.exe" ` + -ArgumentList $arguments -Wait -PassThru + if ($ExpectedCodes -notcontains $process.ExitCode) { + throw "msiexec $Operation failed with exit code $($process.ExitCode); see $LogPath." + } +} diff --git a/packaging/linux/build.sh b/packaging/linux/build.sh new file mode 100644 index 0000000..badd10e --- /dev/null +++ b/packaging/linux/build.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +trap 'printf "ERROR %s:%s: %s\n" "$0" "$LINENO" "$BASH_COMMAND" >&2' ERR + +[[ $# == 3 ]] || { echo "Usage: $0 VERSION BINARY OUTPUT_DIRECTORY" >&2; exit 1; } +root=$(cd "$(dirname "$0")/../.." && pwd) +export VERSION +VERSION=$(pwsh -NoProfile -File "$root/packaging/version.ps1" -Version "$1") +export BINARY_PATH +BINARY_PATH=$(realpath "$2") +output=$(realpath "$3") +cd "$root" +[[ $(go version -m "$BINARY_PATH") == *'GOOS=linux'* ]] +[[ $(go version -m "$BINARY_PATH") == *'GOARCH=amd64'* ]] +nfpm package --config packaging/linux/nfpm.yaml --packager deb \ + --target "$output/dscpkg_${VERSION}_amd64.deb" +nfpm package --config packaging/linux/nfpm.yaml --packager rpm \ + --target "$output/dscpkg-${VERSION}-1.x86_64.rpm" diff --git a/packaging/linux/inspect.sh b/packaging/linux/inspect.sh new file mode 100644 index 0000000..8c8402b --- /dev/null +++ b/packaging/linux/inspect.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +trap 'printf "ERROR %s:%s: %s\n" "$0" "$LINENO" "$BASH_COMMAND" >&2' ERR + +[[ $# == 3 ]] || { echo "Usage: $0 VERSION BINARY OUTPUT_DIRECTORY" >&2; exit 1; } +root=$(cd "$(dirname "$0")/../.." && pwd) +version=$(pwsh -NoProfile -File "$root/packaging/version.ps1" -Version "$1") +binary=$(realpath "$2") +output=$(realpath "$3") +deb="$output/dscpkg_${version}_amd64.deb" +rpm="$output/dscpkg-${version}-1.x86_64.rpm" +[[ $(dpkg-deb -f "$deb" Package) == dscpkg ]] +[[ $(dpkg-deb -f "$deb" Version) == "$version" ]] +[[ $(dpkg-deb -f "$deb" Architecture) == amd64 ]] +[[ -z $(dpkg-deb -f "$deb" Depends) ]] +[[ $(rpm -qp --qf '%{NAME} %{VERSION} %{RELEASE} %{ARCH}' "$rpm") == "dscpkg $version 1 x86_64" ]] +[[ -z $(rpm -qp --scripts "$rpm") ]] +rpm --checksig "$rpm" +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT +dpkg-deb --extract "$deb" "$stage/deb" +dpkg-deb --control "$deb" "$stage/control" +[[ $(find "$stage/control" -type f ! -name control ! -name md5sums | wc -l) == 0 ]] +mkdir "$stage/rpm" +rpm2archive -n < "$rpm" > "$stage/rpm.tar" +tar -xf "$stage/rpm.tar" -C "$stage/rpm" +for format in deb rpm; do + extracted="$stage/$format" + diff <(printf '%s\n' usr/bin/dscpkg usr/share/doc/dscpkg/README.md usr/share/licenses/dscpkg/LICENSE | sort) \ + <(find "$extracted" -type f -printf '%P\n' | sort) + [[ $(stat -c %a "$extracted/usr/bin/dscpkg") == 755 ]] + cmp "$binary" "$extracted/usr/bin/dscpkg" + cmp "$root/README.md" "$extracted/usr/share/doc/dscpkg/README.md" + cmp "$root/LICENSE" "$extracted/usr/share/licenses/dscpkg/LICENSE" + "$extracted/usr/bin/dscpkg" publish -h +done +dpkg-deb --contents "$deb" | awk '$2 != "root/root" { bad=1 } END { exit bad }' +rpm -qp --qf '[%{FILEUSERNAME} %{FILEGROUPNAME}\n]' "$rpm" | + awk '$0 != "root root" { bad=1 } END { exit bad }' +echo 'DEB/RPM metadata, permissions, and identical executable payloads verified.' diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml new file mode 100644 index 0000000..c6d822b --- /dev/null +++ b/packaging/linux/nfpm.yaml @@ -0,0 +1,28 @@ +name: dscpkg +arch: amd64 +platform: linux +version: ${VERSION} +maintainer: dscpkg contributors +description: CLI for acquiring DSC v3 resource packages from static repositories +homepage: https://github.com/Bpoe/dscpkg +license: MIT +contents: + - src: ${BINARY_PATH} + dst: /usr/bin/dscpkg + expand: true + file_info: + mode: 0755 + owner: root + group: root + - src: README.md + dst: /usr/share/doc/dscpkg/README.md + file_info: + mode: 0644 + owner: root + group: root + - src: LICENSE + dst: /usr/share/licenses/dscpkg/LICENSE + file_info: + mode: 0644 + owner: root + group: root diff --git a/packaging/linux/test-install.sh b/packaging/linux/test-install.sh new file mode 100644 index 0000000..568adda --- /dev/null +++ b/packaging/linux/test-install.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +trap 'printf "ERROR %s:%s: %s\n" "$0" "$LINENO" "$BASH_COMMAND" >&2' ERR + +if [[ $# != 7 || $1 != --disposable || ${GITHUB_ACTIONS:-} != true || + ${RUNNER_ENVIRONMENT:-} != github-hosted || $EUID != 0 ]]; then + echo "Usage (disposable GitHub-hosted runner/container only): $0 --disposable deb|rpm VERSION ASSETS BINARY FIXTURES LOGS" >&2 + exit 1 +fi +format=$2 +version=${3#v} +assets=$(realpath "$4") +binary=$(realpath "$5") +fixtures=$(realpath "$6") +mkdir -p "$7" +logs=$(realpath "$7") +exec > >(tee "$logs/$format-install.log") 2>&1 +case "$format" in + deb) + command -v dpkg + if dpkg-query -W dscpkg; then echo 'dscpkg is already installed.' >&2; exit 1; fi ;; + rpm) + command -v rpm + if rpm -q dscpkg; then echo 'dscpkg is already installed.' >&2; exit 1; fi ;; + *) echo "Unknown package format: $format" >&2; exit 1 ;; +esac +[[ ! -e /usr/bin/dscpkg ]] +cache="$HOME/.cache/dscpkg/packages" +[[ ! -e "$cache" ]] +mkdir -p "$cache/.dscpkg" "$cache/test-resource" +sentinels=("$cache/resources.json" "$cache/.dscpkg/metadata" "$cache/test-resource/resource") +for file in "${sentinels[@]}"; do printf 'preserve-me\n' > "$file"; done +installed=false + +package_path() { + if [[ $format == deb ]]; then printf '%s/dscpkg_%s_amd64.deb' "$1" "$2" + else printf '%s/dscpkg-%s-1.x86_64.rpm' "$1" "$2"; fi +} +install_package() { + if [[ $format == deb ]]; then dpkg -i "$1"; else rpm -U --replacepkgs "$1"; fi + installed=true +} +remove_package() { + if [[ $format == deb ]]; then dpkg --purge dscpkg; else rpm -e dscpkg; fi + installed=false +} +preserved() { + for file in "${sentinels[@]}"; do [[ $(cat "$file") == preserve-me ]]; done +} +verify_install() { + # Merged bin/sbin layouts may expose the installed file through another PATH alias. + [[ $(command -v dscpkg) -ef /usr/bin/dscpkg ]] + cmp "$1" /usr/bin/dscpkg + dscpkg publish -h + if [[ $format == deb ]]; then + # dpkg-query, not Bash, expands this field. + # shellcheck disable=SC2016 + [[ $(dpkg-query -W -f '${Version}' dscpkg) == "$2" ]] + else + [[ $(rpm -q --qf '%{VERSION}' dscpkg) == "$2" ]] + fi + preserved +} +cleanup() { if $installed; then remove_package; fi; } +trap cleanup EXIT + +candidate=$(package_path "$assets" "$version") +install_package "$candidate" +verify_install "$binary" "$version" +install_package "$candidate" +verify_install "$binary" "$version" +remove_package +[[ ! -e /usr/bin/dscpkg ]] +preserved + +if cmp -s "$fixtures/v1.0.0/dscpkg" "$fixtures/v1.0.1/dscpkg"; then + echo 'Upgrade fixture executables must differ.' >&2 + exit 1 +fi +install_package "$(package_path "$fixtures/v1.0.0/assets" 1.0.0)" +verify_install "$fixtures/v1.0.0/dscpkg" 1.0.0 +install_package "$(package_path "$fixtures/v1.0.1/assets" 1.0.1)" +verify_install "$fixtures/v1.0.1/dscpkg" 1.0.1 +remove_package +[[ ! -e /usr/bin/dscpkg ]] +preserved +echo "$format install, reinstall, upgrade, uninstall and data retention passed." diff --git a/packaging/release.ps1 b/packaging/release.ps1 new file mode 100644 index 0000000..949245f --- /dev/null +++ b/packaging/release.ps1 @@ -0,0 +1,106 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('Preflight', 'Draft')][string] $Operation, + [Parameter(Mandatory)][string] $Version, + [Parameter(Mandatory)][string] $Commit, + [Parameter(Mandatory)][string] $Repository, + [string] $AssetDirectory +) + +. "$PSScriptRoot/common.ps1" +$null = & "$PSScriptRoot/version.ps1" $Version +if ($Commit -cnotmatch '\A[0-9a-f]{40}\z') { throw 'Expected an exact Git commit/object SHA.' } +if ($Repository -cnotmatch '\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z') { throw 'Expected owner/repository.' } + +function Get-GitHubJSON { + param([string[]] $Arguments) + ((Invoke-Checked gh $Arguments) -join "`n") | ConvertFrom-Json -NoEnumerate +} + +function Assert-NoRelease { + $pages = Get-GitHubJSON @('api', '--paginate', '--slurp', "repos/$Repository/releases?per_page=100") + if ($pages -isnot [array]) { throw 'Expected a paginated release listing.' } + foreach ($page in $pages) { + if ($page -isnot [array]) { throw 'Expected a release-list page.' } + foreach ($release in $page) { + if ($release.tag_name -ceq $Version) { + throw "Release $Version already exists (including drafts); inspect it manually. It will not be overwritten." + } + } + } +} + +function Assert-RemoteTag { + param([string] $ExpectedCommit) + $reference = Get-GitHubJSON @('api', "repos/$Repository/git/ref/tags/$Version") + $object = $reference.object + for ($depth = 0; $depth -lt 16; $depth++) { + if ($object.sha -cnotmatch '\A[0-9a-f]{40}\z') { throw 'Invalid remote Git object SHA.' } + if ($object.type -ceq 'commit') { + Assert-Equal $object.sha $ExpectedCommit 'Remote tag commit' + return + } + if ($object.type -cne 'tag') { throw "Tag points to unsupported Git object type: $($object.type)" } + $tag = Get-GitHubJSON @('api', "repos/$Repository/git/tags/$($object.sha)") + $object = $tag.object + } + throw 'Remote annotated tag nesting exceeds the supported depth.' +} + +if ($Operation -eq 'Preflight') { + $resolved = Invoke-Checked git @('rev-parse', "$Commit^{commit}") + $tagged = Invoke-Checked git @('rev-parse', "refs/tags/$Version^{commit}") + Assert-Equal $tagged $resolved 'Checked-out tag commit' + Invoke-Checked git @('merge-base', '--is-ancestor', $resolved, 'origin/main') + Assert-RemoteTag $resolved + Assert-NoRelease + $resolved + return +} + +if (-not $AssetDirectory) { throw 'Draft creation requires AssetDirectory.' } +$downloads = (Resolve-Path -LiteralPath $AssetDirectory).Path +$directories = @(Get-ChildItem -LiteralPath $downloads -Force) +Assert-Equal (($directories.Name | Sort-Object) -join ',') 'linux,macos,windows' 'Downloaded platform directories' +foreach ($directory in $directories) { + if (-not $directory.PSIsContainer) { throw "Expected a platform directory: $($directory.Name)" } +} +$macPattern = '\Adscpkg-' + [regex]::Escape($Version) + '-(darwin-(?:amd64|arm64))\.tar\.gz\z' +$macArchives = @(Get-ChildItem -LiteralPath (Join-Path $downloads 'macos') -File | + Where-Object { $_.Name -cmatch $macPattern }) +Assert-Equal $macArchives.Count 1 'macOS archive count' +$macPlatform = [regex]::Match($macArchives[0].Name, $macPattern).Groups[1].Value +$platforms = @( + @{ Directory = 'linux'; Platform = 'linux-amd64' }, + @{ Directory = 'windows'; Platform = 'windows-amd64' }, + @{ Directory = 'macos'; Platform = $macPlatform } +) +$stage = Join-Path ([IO.Path]::GetTempPath()) ("dscpkg-release-" + [guid]::NewGuid()) +try { + New-Item -ItemType Directory -Path $stage | Out-Null + $allNames = @() + foreach ($platform in $platforms) { + $source = Join-Path $downloads $platform.Directory + $names = @(Get-AssetNames $Version $platform.Platform) + Assert-Checksums $source $names + foreach ($name in $names) { + if ($allNames -ccontains $name) { throw "Duplicate release asset: $name" } + Copy-Item -LiteralPath (Join-Path $source $name) -Destination $stage + $allNames += $name + } + } + Write-Checksums $stage $allNames + Assert-Checksums $stage $allNames + Assert-RemoteTag $Commit + Assert-NoRelease + $assets = @($allNames + 'SHA256SUMS' | ForEach-Object { Join-Path $stage $_ }) + $notes = "Version $Version, built from $Commit. These exact assets passed archive and package inspection, native CLI smoke tests, and Windows/Ubuntu/Fedora fresh-install and uninstall checks. Separate package fixtures passed upgrade checks; Windows also passed repair, PATH, and downgrade-rejection checks. Resource/cache retention was checked. Packages are unsigned. Review this draft before publishing." + Invoke-Checked gh (@('release', 'create', $Version) + $assets + @( + '--repo', $Repository, '--verify-tag', '--draft', '--title', "dscpkg $Version", + '--generate-notes', '--notes', $notes + )) +} finally { + if (Test-Path -LiteralPath $stage) { + Remove-Item -LiteralPath $stage -Recurse -Force + } +} diff --git a/packaging/test-fixtures.ps1 b/packaging/test-fixtures.ps1 new file mode 100644 index 0000000..ca9d7df --- /dev/null +++ b/packaging/test-fixtures.ps1 @@ -0,0 +1,24 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string] $OutputDirectory) + +. "$PSScriptRoot/common.ps1" +$output = [IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Path $output -Force | Out-Null +Assert-FileSet $output @() +$previousCGO = $env:CGO_ENABLED +Push-Location (Split-Path $PSScriptRoot -Parent) +try { + $env:CGO_ENABLED = '0' + foreach ($version in @('v1.0.0', 'v1.0.1')) { + $directory = Join-Path $output $version + New-Item -ItemType Directory -Path $directory | Out-Null + $binary = Join-Path $directory $(if ($IsWindows) { 'dscpkg.exe' } else { 'dscpkg' }) + # Distinct build IDs prove upgrades replace bytes, not just package metadata. + Invoke-Checked go @('build', '-trimpath', '-ldflags', "-buildid=dscpkg-upgrade-$version", '-o', $binary, './cmd/dscpkg') + & "$PSScriptRoot/build.ps1" -Version $version -BinaryPath $binary ` + -OutputDirectory (Join-Path $directory 'assets') + } +} finally { + $env:CGO_ENABLED = $previousCGO + Pop-Location +} diff --git a/packaging/test-release.ps1 b/packaging/test-release.ps1 new file mode 100644 index 0000000..07cd7b5 --- /dev/null +++ b/packaging/test-release.ps1 @@ -0,0 +1,190 @@ +. "$PSScriptRoot/common.ps1" +$releaseScript = Join-Path $PSScriptRoot 'release.ps1' +$stage = Join-Path ([IO.Path]::GetTempPath()) ("dscpkg-release-test-" + [guid]::NewGuid()) +$global:DscpkgReleaseTestState = @{ + Commit = ''; TagObject = $null; Releases = '[[]]'; FailAPI = $false + Created = 0; Assets = @(); Arguments = @() +} + +function global:gh { + param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) + $state = $global:DscpkgReleaseTestState + $global:LASTEXITCODE = 0 + if ($Arguments[0] -eq 'api') { + if ($state.FailAPI) { $global:LASTEXITCODE = 1; return } + $endpoint = $Arguments[-1] + if ($endpoint -match '/releases\?') { $state.Releases; return } + if ($endpoint -match '/git/ref/tags/') { + $object = if ($state.TagObject) { + @{ type = 'tag'; sha = $state.TagObject } + } else { @{ type = 'commit'; sha = $state.Commit } } + @{ object = $object } | ConvertTo-Json -Compress + return + } + if ($endpoint -match '/git/tags/') { + @{ object = @{ type = 'commit'; sha = $state.Commit } } | ConvertTo-Json -Compress + return + } + throw "Unexpected API request: $endpoint" + } + if ($Arguments[0] -eq 'release' -and $Arguments[1] -eq 'create') { + $state.Created++ + $state.Arguments = $Arguments + $state.Assets = @($Arguments | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + ForEach-Object { Split-Path $_ -Leaf }) + return + } + throw "Unexpected gh command: $Arguments" +} + +function Assert-Rejected { + param([scriptblock] $Action, [string] $Message) + $before = $global:DscpkgReleaseTestState.Created + $rejected = $false + try { & $Action | Out-Null } + catch { + if ($_.Exception.Message -notmatch $Message) { throw } + $rejected = $true + } + if (-not $rejected) { throw "Expected rejection matching: $Message" } + Assert-Equal $global:DscpkgReleaseTestState.Created $before 'Rejected operation must not create a release' +} + +function New-TestAssets { + param([string] $Directory) + foreach ($entry in @( + @{ Name = 'linux'; Platform = 'linux-amd64' }, + @{ Name = 'windows'; Platform = 'windows-amd64' }, + @{ Name = 'macos'; Platform = 'darwin-arm64' } + )) { + $path = Join-Path $Directory $entry.Name + New-Item -ItemType Directory -Path $path -Force | Out-Null + $names = @(Get-AssetNames 'v1.2.3' $entry.Platform) + foreach ($name in $names) { Set-Content -LiteralPath (Join-Path $path $name) -Value $name } + Write-Checksums $path $names + } +} + +New-Item -ItemType Directory -Path $stage | Out-Null +Push-Location $stage +try { + if ($IsWindows) { + $msi = Join-Path $stage 'table-fixture.msi' + $installer = New-Object -ComObject WindowsInstaller.Installer + $database = $installer.OpenDatabase($msi, 3) + try { + foreach ($query in @( + 'CREATE TABLE `Property` (`Property` CHAR(72) NOT NULL, `Value` CHAR(0) PRIMARY KEY `Property`)', + 'INSERT INTO `Property` (`Property`, `Value`) VALUES (''ProductName'', ''dscpkg'')' + )) { + $view = $database.OpenView($query) + try { $view.Execute() | Out-Null } + finally { + $view.Close() | Out-Null + [Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) | Out-Null + } + } + $database.Commit() | Out-Null + } finally { + foreach ($object in @($database, $installer)) { + [Runtime.InteropServices.Marshal]::FinalReleaseComObject($object) | Out-Null + } + } + $rows = @(Read-MsiTable $msi 'SELECT `Property`, `Value` FROM `Property`' @('Name', 'Value')) + Assert-Equal $rows.Count 1 'MSI table reader must not emit COM null results' + Assert-Equal $rows[0].Value 'dscpkg' 'MSI table value' + $empty = @(Read-MsiTable $msi 'SELECT `Value` FROM `Property` WHERE `Property` = ''Absent''' @('Value')) + Assert-Equal $empty.Count 0 'Empty MSI table result' + } + Invoke-Checked git @('init', '--quiet') + Invoke-Checked git @('config', 'user.name', 'Release tests') + Invoke-Checked git @('config', 'user.email', 'release-tests@example.invalid') + Invoke-Checked git @('-c', 'commit.gpgsign=false', 'commit', '--quiet', '--allow-empty', '-m', 'Fixture') + $commit = Invoke-Checked git @('rev-parse', 'HEAD') + $global:DscpkgReleaseTestState.Commit = $commit + Invoke-Checked git @('update-ref', 'refs/remotes/origin/main', $commit) + Invoke-Checked git @('-c', 'tag.gpgsign=false', 'tag', 'v1.2.3') + $parameters = @{ Version = 'v1.2.3'; Commit = $commit; Repository = 'example/dscpkg' } + Assert-Equal (& $releaseScript -Operation Preflight @parameters) $commit 'Lightweight tag preflight' + + Invoke-Checked git @('tag', '--delete', 'v1.2.3') | Out-Null + Invoke-Checked git @('-c', 'tag.gpgsign=false', 'tag', '-a', 'v1.2.3', '-m', 'Annotated fixture') + $tagObject = Invoke-Checked git @('rev-parse', 'refs/tags/v1.2.3') + $global:DscpkgReleaseTestState.TagObject = $tagObject + $parameters.Commit = $tagObject + Assert-Equal (& $releaseScript -Operation Preflight @parameters) $commit 'Annotated tag preflight' + $parameters.Commit = $commit + + $outside = Invoke-Checked git @('-c', 'commit.gpgsign=false', 'commit-tree', 'HEAD^{tree}', '-m', 'Outside main') + Invoke-Checked git @('update-ref', 'refs/tags/v1.2.3', $outside) + $parameters.Commit = $outside + Assert-Rejected { & $releaseScript -Operation Preflight @parameters } 'git failed' + Invoke-Checked git @('update-ref', 'refs/tags/v1.2.3', $commit) + $parameters.Commit = $commit + $global:DscpkgReleaseTestState.TagObject = $null + + $global:DscpkgReleaseTestState.Commit = $outside + Assert-Rejected { & $releaseScript -Operation Preflight @parameters } 'Remote tag commit' + $global:DscpkgReleaseTestState.Commit = $commit + $global:DscpkgReleaseTestState.FailAPI = $true + Assert-Rejected { & $releaseScript -Operation Preflight @parameters } 'gh failed' + $global:DscpkgReleaseTestState.FailAPI = $false + foreach ($draft in @('true', 'false')) { + $global:DscpkgReleaseTestState.Releases = "[[],[{`"tag_name`":`"v1.2.3`",`"draft`":$draft}]]" + Assert-Rejected { & $releaseScript -Operation Preflight @parameters } 'already exists' + } + $global:DscpkgReleaseTestState.Releases = '[[]]' + $parameters.Version = 'v1.2.3-rc.1' + Assert-Rejected { & $releaseScript -Operation Preflight @parameters } 'Expected a stable' + $parameters.Version = 'v1.2.3' + + $downloads = Join-Path $stage 'downloads' + New-TestAssets $downloads + & $releaseScript -Operation Draft @parameters -AssetDirectory $downloads + Assert-Equal $global:DscpkgReleaseTestState.Created 1 'Draft creation count' + Assert-Equal $global:DscpkgReleaseTestState.Assets.Count 7 'Draft asset count' + foreach ($flag in @('--draft', '--verify-tag', '--generate-notes')) { + if ($global:DscpkgReleaseTestState.Arguments -cnotcontains $flag) { throw "Missing release flag: $flag" } + } + $global:DscpkgReleaseTestState.Releases = '[[{"tag_name":"v1.2.3","draft":true}]]' + Assert-Rejected { & $releaseScript -Operation Draft @parameters -AssetDirectory $downloads } 'already exists' + $global:DscpkgReleaseTestState.Releases = '[[]]' + $global:DscpkgReleaseTestState.Commit = $outside + Assert-Rejected { & $releaseScript -Operation Draft @parameters -AssetDirectory $downloads } 'Remote tag commit' + $global:DscpkgReleaseTestState.Commit = $commit + $global:DscpkgReleaseTestState.FailAPI = $true + Assert-Rejected { & $releaseScript -Operation Draft @parameters -AssetDirectory $downloads } 'gh failed' + $global:DscpkgReleaseTestState.FailAPI = $false + + $extra = Join-Path $downloads 'linux/unexpected.txt' + Set-Content -LiteralPath $extra -Value extra + Assert-Rejected { & $releaseScript -Operation Draft @parameters -AssetDirectory $downloads } 'Files in' + Remove-Item -LiteralPath $extra + $asset = Join-Path $downloads 'linux/dscpkg-v1.2.3-linux-amd64.tar.gz' + $bytes = [IO.File]::ReadAllBytes($asset) + Add-Content -LiteralPath $asset -Value corrupt + Assert-Rejected { & $releaseScript -Operation Draft @parameters -AssetDirectory $downloads } 'Checksum for' + [IO.File]::WriteAllBytes($asset, $bytes) + Remove-Item -LiteralPath $asset + Assert-Rejected { & $releaseScript -Operation Draft @parameters -AssetDirectory $downloads } 'Files in' + [IO.File]::WriteAllBytes($asset, $bytes) + $manifest = Join-Path $downloads 'linux/SHA256SUMS' + $original = [IO.File]::ReadAllBytes($manifest) + Add-Content -LiteralPath $manifest -Value (Get-Content -LiteralPath $manifest)[0] + Assert-Rejected { & $releaseScript -Operation Draft @parameters -AssetDirectory $downloads } 'duplicate checksum' + [IO.File]::WriteAllBytes($manifest, $original) + Set-Content -LiteralPath $manifest -Value ('0' * 64 + ' ../escape') + Assert-Rejected { & $releaseScript -Operation Draft @parameters -AssetDirectory $downloads } 'Invalid checksum' + [IO.File]::WriteAllBytes($manifest, $original) + + Invoke-Checked git @('tag', '--delete', 'v1.2.3') | Out-Null + Assert-Rejected { & $releaseScript -Operation Preflight @parameters } 'git failed' + Write-Host 'Release preflight, API failures, draft-only creation, and asset rejection checks passed.' +} finally { + Pop-Location + Remove-Item -LiteralPath $stage -Recurse -Force + Remove-Item -LiteralPath Function:\gh + Remove-Variable -Name DscpkgReleaseTestState -Scope Global +} +# GitHub's PowerShell wrapper otherwise returns the last intentionally failed git command. +exit 0 diff --git a/packaging/test-version.ps1 b/packaging/test-version.ps1 new file mode 100644 index 0000000..a9dcc18 --- /dev/null +++ b/packaging/test-version.ps1 @@ -0,0 +1,19 @@ +. "$PSScriptRoot/common.ps1" + +foreach ($version in @('v0.0.0', 'v1.2.3', 'v255.255.65535')) { + Assert-Equal (& "$PSScriptRoot/version.ps1" $version) $version.Substring(1) $version +} +foreach ($version in @( + '', '1.2.3', 'V1.2.3', 'v1.2', 'v1.2.3.4', 'v01.2.3', 'v1.02.3', 'v1.2.03', + 'v1.2.3-rc.1', 'v1.2.3+build', "v1.2.3`n", ' v1.2.3', 'v-1.2.3', + 'v256.0.0', 'v0.256.0', 'v0.0.65536', 'v999999999999999999999999999.0.0' +)) { + $rejected = $false + try { & "$PSScriptRoot/version.ps1" $version | Out-Null } + catch { + if ($_.Exception.Message -notmatch 'Expected a stable|MSI versions require') { throw } + $rejected = $true + } + if (-not $rejected) { throw "Accepted invalid release version: '$version'." } +} +Write-Host 'Release version boundaries and invalid inputs passed.' diff --git a/packaging/verify.ps1 b/packaging/verify.ps1 new file mode 100644 index 0000000..eb320aa --- /dev/null +++ b/packaging/verify.ps1 @@ -0,0 +1,47 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Version, + [Parameter(Mandatory)][string] $BinaryPath, + [Parameter(Mandatory)][string] $Directory +) + +. "$PSScriptRoot/common.ps1" +$binary = (Resolve-Path -LiteralPath $BinaryPath).Path +$platform = Get-BinaryPlatform $binary +$output = (Resolve-Path -LiteralPath $Directory).Path +$names = @(Get-AssetNames $Version $platform) +Assert-Checksums $output $names +$name = "dscpkg-$Version-$platform" +$stage = Join-Path ([IO.Path]::GetTempPath()) ("dscpkg-verify-" + [guid]::NewGuid()) +$executable = if ($IsWindows) { 'dscpkg.exe' } else { 'dscpkg' } +try { + New-Item -ItemType Directory -Path $stage | Out-Null + if ($IsWindows) { + Expand-Archive -LiteralPath (Join-Path $output "$name.zip") -DestinationPath $stage + } else { + Invoke-Checked tar @('-xzf', (Join-Path $output "$name.tar.gz"), '-C', $stage) + } + $actual = @(Get-ChildItem -LiteralPath $stage -Recurse -File | ForEach-Object { + [IO.Path]::GetRelativePath($stage, $_.FullName).Replace('\', '/') + }) + $expected = @("$name/bin/$executable", "$name/LICENSE", "$name/README.md") + Assert-Equal (($actual | Sort-Object) -join "`n") (($expected | Sort-Object) -join "`n") 'Archive contents' + $unpacked = Join-Path $stage "$name/bin/$executable" + Assert-Equal (Get-SHA256 $unpacked) (Get-SHA256 $binary) 'Archive executable hash' + foreach ($file in @('README.md', 'LICENSE')) { + Assert-Equal (Get-SHA256 (Join-Path $stage "$name/$file")) ` + (Get-SHA256 (Join-Path (Split-Path $PSScriptRoot -Parent) $file)) "Archive $file" + } + Invoke-Checked $unpacked @('publish', '-h') + if ($IsWindows) { + & "$PSScriptRoot/windows/msi/inspect.ps1" -Version $Version -BinaryPath $binary ` + -MsiPath (Join-Path $output "$name.msi") + } elseif ($IsLinux) { + Invoke-Checked bash @("$PSScriptRoot/linux/inspect.sh", $Version, $binary, $output) + } +} finally { + if (Test-Path -LiteralPath $stage) { + Remove-Item -LiteralPath $stage -Recurse -Force + } +} +Write-Host "Verified $platform release assets." diff --git a/packaging/version.ps1 b/packaging/version.ps1 new file mode 100644 index 0000000..d136d4b --- /dev/null +++ b/packaging/version.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][AllowEmptyString()][string] $Version) + +$ErrorActionPreference = 'Stop' + +if ($Version -cnotmatch '\Av(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z') { + throw 'Expected a stable vMAJOR.MINOR.PATCH tag without leading zeroes.' +} + +$major = [System.Numerics.BigInteger]::Parse($Matches[1]) +$minor = [System.Numerics.BigInteger]::Parse($Matches[2]) +$patch = [System.Numerics.BigInteger]::Parse($Matches[3]) +if ($major -gt 255 -or $minor -gt 255 -or $patch -gt 65535) { + throw 'MSI versions require major/minor <= 255 and patch <= 65535.' +} + +"$major.$minor.$patch" diff --git a/packaging/windows/msi/Package.wxs b/packaging/windows/msi/Package.wxs new file mode 100644 index 0000000..c0cba6f --- /dev/null +++ b/packaging/windows/msi/Package.wxs @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/msi/build.ps1 b/packaging/windows/msi/build.ps1 new file mode 100644 index 0000000..1677705 --- /dev/null +++ b/packaging/windows/msi/build.ps1 @@ -0,0 +1,30 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Version, + [Parameter(Mandatory)][string] $BinaryPath, + [Parameter(Mandatory)][string] $OutputDirectory +) + +. "$PSScriptRoot/../../common.ps1" +if (-not $IsWindows) { throw 'MSI builds require Windows.' } +$productVersion = & "$PSScriptRoot/../../version.ps1" $Version +$binary = (Resolve-Path -LiteralPath $BinaryPath).Path +Assert-Equal (Get-BinaryPlatform $binary) 'windows-amd64' 'MSI binary target' +$output = [IO.Path]::GetFullPath($OutputDirectory) +$stage = Join-Path ([IO.Path]::GetTempPath()) ("dscpkg-msi-" + [guid]::NewGuid()) +try { + New-Item -ItemType Directory -Path $stage -Force | Out-Null + Invoke-Checked dotnet @( + 'build', "$PSScriptRoot/dscpkg.wixproj", '--configuration', 'Release', '--nologo', + '--no-incremental', "-p:ReleaseVersion=$Version", "-p:ProductVersion=$productVersion", + "-p:BinaryPath=$binary", "-p:BaseIntermediateOutputPath=$stage/obj/", + "-p:IntermediateOutputPath=$stage/obj/", "-p:MSBuildProjectExtensionsPath=$stage/obj/", + "-p:OutputPath=$stage/output/" + ) + New-Item -ItemType Directory -Path $output -Force | Out-Null + Copy-Item -LiteralPath "$stage/output/dscpkg-$Version-windows-amd64.msi" -Destination $output +} finally { + if (Test-Path -LiteralPath $stage) { + Remove-Item -LiteralPath $stage -Recurse -Force + } +} diff --git a/packaging/windows/msi/dscpkg.wixproj b/packaging/windows/msi/dscpkg.wixproj new file mode 100644 index 0000000..3565bc2 --- /dev/null +++ b/packaging/windows/msi/dscpkg.wixproj @@ -0,0 +1,10 @@ + + + wix7 + x64 + Package + dscpkg-$(ReleaseVersion)-windows-amd64 + ReleaseVersion=$(ReleaseVersion);ProductVersion=$(ProductVersion);BinaryPath=$(BinaryPath) + true + + diff --git a/packaging/windows/msi/inspect.ps1 b/packaging/windows/msi/inspect.ps1 new file mode 100644 index 0000000..da478e1 --- /dev/null +++ b/packaging/windows/msi/inspect.ps1 @@ -0,0 +1,78 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Version, + [Parameter(Mandatory)][string] $BinaryPath, + [Parameter(Mandatory)][string] $MsiPath +) + +. "$PSScriptRoot/../../common.ps1" +if (-not $IsWindows) { throw 'MSI inspection requires Windows.' } +$productVersion = & "$PSScriptRoot/../../version.ps1" $Version +$msi = (Resolve-Path -LiteralPath $MsiPath).Path +$properties = @{} +foreach ($row in Read-MsiTable $msi 'SELECT `Property`, `Value` FROM `Property`' @('Name', 'Value')) { + $properties[$row.Name] = $row.Value +} +Assert-Equal $properties['ProductName'] 'dscpkg' 'MSI product name' +Assert-Equal $properties['ProductVersion'] $productVersion 'MSI product version' +Assert-Equal $properties['UpgradeCode'] '{5E40E180-FD44-4F09-AB2F-09D02D59CE8A}' 'MSI upgrade identity' +Assert-Equal $properties['ALLUSERS'] '1' 'MSI installation scope' +$installer = New-Object -ComObject WindowsInstaller.Installer +$database = $installer.OpenDatabase($msi, 0) +$summary = $database.SummaryInformation(0) +try { + Assert-Equal $summary.Property(7) 'x64;1033' 'MSI platform' +} finally { + foreach ($object in @($summary, $database, $installer)) { + [Runtime.InteropServices.Marshal]::FinalReleaseComObject($object) | Out-Null + } +} +$files = @(Read-MsiTable $msi 'SELECT `FileName` FROM `File`' @('Name')) +$names = @($files | ForEach-Object { ($_.Name -split '\|')[-1] }) +Assert-Equal (($names | Sort-Object) -join ',') 'dscpkg.exe,LICENSE,README.md' 'MSI file set' +$directories = @(Read-MsiTable $msi 'SELECT `Directory_Parent`, `DefaultDir` FROM `Directory` WHERE `Directory` = ''INSTALLFOLDER''' @('Parent', 'Name')) +Assert-Equal $directories[0].Parent 'ProgramFiles64Folder' 'MSI destination parent' +Assert-Equal (($directories[0].Name -split '\|')[-1]) 'dscpkg' 'MSI destination directory' +foreach ($row in Read-MsiTable $msi 'SELECT `Attributes` FROM `Component`' @('Attributes')) { + Assert-Equal ([int]$row.Attributes -band 256) 256 'MSI component bitness' +} +$environment = @(Read-MsiTable $msi 'SELECT `Name`, `Value` FROM `Environment`' @('Name', 'Value')) +Assert-Equal $environment.Count 1 'MSI environment entry count' +if ($environment[0].Name -notmatch '\*PATH$') { throw 'MSI must manage the machine PATH.' } +Assert-Equal $environment[0].Value '[~];[INSTALLFOLDER]' 'MSI PATH append' +$tables = @(Read-MsiTable $msi 'SELECT `Name` FROM `_Tables`' @('Name')) +foreach ($table in @('CustomAction', 'ServiceInstall', 'ServiceControl')) { + if ($tables.Name -contains $table) { throw "Unexpected MSI table: $table" } +} + +$stage = Join-Path ([IO.Path]::GetTempPath()) ("dscpkg-inspect-" + [guid]::NewGuid()) +$image = Join-Path $stage 'image' +$logPath = Join-Path $stage 'extract.log' +$verified = $false +try { + New-Item -ItemType Directory -Path $stage | Out-Null + # Administrative extraction copies the payload; it does not install the product. + Invoke-Msi '/a' $msi $logPath @("TARGETDIR=`"$image`"") + $binaries = @(Get-ChildItem -LiteralPath $image -Recurse -File -Filter dscpkg.exe) + Assert-Equal $binaries.Count 1 'Extracted MSI executable count' + Assert-Equal (Get-SHA256 $binaries[0].FullName) (Get-SHA256 $BinaryPath) 'MSI executable hash' + $repo = [IO.Path]::GetFullPath("$PSScriptRoot/../../..") + foreach ($file in @('README.md', 'LICENSE')) { + Assert-Equal (Get-SHA256 (Join-Path $binaries[0].DirectoryName $file)) ` + (Get-SHA256 (Join-Path $repo $file)) "MSI $file" + } + Invoke-Checked $binaries[0].FullName @('publish', '-h') + $verified = $true +} finally { + if (-not $verified -and (Test-Path -LiteralPath $logPath)) { + Get-Content -LiteralPath $logPath -Tail 80 | Out-Host + if ($env:DSCPKG_LOG_DIRECTORY) { + New-Item -ItemType Directory -Path $env:DSCPKG_LOG_DIRECTORY -Force | Out-Null + Copy-Item -LiteralPath $logPath -Destination (Join-Path $env:DSCPKG_LOG_DIRECTORY 'msi-extract.log') + } + } + if (Test-Path -LiteralPath $stage) { + Remove-Item -LiteralPath $stage -Recurse -Force + } +} +Write-Host 'MSI scope, version, PATH, metadata, and executable payload verified.' diff --git a/packaging/windows/test-install.ps1 b/packaging/windows/test-install.ps1 new file mode 100644 index 0000000..85cd08f --- /dev/null +++ b/packaging/windows/test-install.ps1 @@ -0,0 +1,95 @@ +[CmdletBinding()] +param( + [switch] $Disposable, + [Parameter(Mandatory)][string] $Version, + [Parameter(Mandatory)][string] $Directory, + [Parameter(Mandatory)][string] $BinaryPath, + [Parameter(Mandatory)][string] $FixtureDirectory, + [Parameter(Mandatory)][string] $LogDirectory +) + +. "$PSScriptRoot/../common.ps1" +if (-not $IsWindows -or -not $Disposable -or $env:GITHUB_ACTIONS -ne 'true' -or + $env:RUNNER_ENVIRONMENT -ne 'github-hosted') { + throw 'Installation tests require -Disposable on a GitHub-hosted Windows runner.' +} +$null = & "$PSScriptRoot/../version.ps1" $Version +$installDirectory = Join-Path $env:ProgramFiles 'dscpkg' +$installedBinary = Join-Path $installDirectory 'dscpkg.exe' +if (Test-Path -LiteralPath $installDirectory) { throw 'dscpkg is already installed; refusing to modify it.' } +$logs = [IO.Path]::GetFullPath($LogDirectory) +New-Item -ItemType Directory -Path $logs -Force | Out-Null +$originalPath = [Environment]::GetEnvironmentVariable('Path', 'Machine') +$processPath = $env:PATH +$sentinelPath = Join-Path $env:SystemDrive 'dscpkg-unrelated-path-entry' +$baselinePath = "$originalPath;$sentinelPath" +$cache = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'dscpkg/packages' +if (Test-Path -LiteralPath $cache) { throw 'Test cache already exists; refusing to overwrite it.' } +$sentinels = @("$cache/resources.json", "$cache/.dscpkg/metadata", "$cache/test-resource/resource") +$installed = $null + +function Assert-Preserved { + foreach ($path in $sentinels) { Assert-Equal (Get-Content -LiteralPath $path -Raw).Trim() 'preserve-me' $path } + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + if (($machinePath -split ';') -notcontains $sentinelPath) { throw 'Unrelated PATH entry was removed.' } +} + +function Assert-Installed { + param([string] $ExpectedBinary) + Assert-Equal (Get-SHA256 $installedBinary) (Get-SHA256 $ExpectedBinary) 'Installed executable' + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $entries = @($machinePath -split ';' | ForEach-Object { $_.TrimEnd('\') }) + Assert-Equal @($entries | Where-Object { $_ -ieq $installDirectory }).Count 1 'Product PATH entry count' + $env:PATH = "$machinePath;$processPath" + Assert-Equal (Get-Command dscpkg -CommandType Application).Source $installedBinary 'PATH lookup' + Invoke-Checked $installedBinary @('publish', '-h') + Assert-Preserved +} + +function Remove-TestProduct { + param([string] $Label) + Invoke-Msi '/x' $script:installed (Join-Path $logs "$Label.log") + $script:installed = $null + if (Test-Path -LiteralPath $installedBinary) { throw 'Uninstall left the executable behind.' } + $entries = @([Environment]::GetEnvironmentVariable('Path', 'Machine') -split ';' | + ForEach-Object { $_.TrimEnd('\') }) + if ($entries -icontains $installDirectory) { throw 'Uninstall left its PATH entry behind.' } + Assert-Preserved +} + +try { + [Environment]::SetEnvironmentVariable('Path', $baselinePath, 'Machine') + New-Item -ItemType Directory -Path "$cache/.dscpkg", "$cache/test-resource" -Force | Out-Null + foreach ($path in $sentinels) { Set-Content -LiteralPath $path -Value 'preserve-me' } + $candidate = (Resolve-Path -LiteralPath (Join-Path $Directory "dscpkg-$Version-windows-amd64.msi")).Path + Invoke-Msi '/i' $candidate (Join-Path $logs 'install-release.log') + $installed = $candidate + Assert-Installed $BinaryPath + Invoke-Msi '/fa' $candidate (Join-Path $logs 'repair-release.log') + Assert-Installed $BinaryPath + Remove-TestProduct 'uninstall-release' + + $old = (Resolve-Path -LiteralPath "$FixtureDirectory/v1.0.0/assets/dscpkg-v1.0.0-windows-amd64.msi").Path + $new = (Resolve-Path -LiteralPath "$FixtureDirectory/v1.0.1/assets/dscpkg-v1.0.1-windows-amd64.msi").Path + $oldBinary = Join-Path $FixtureDirectory 'v1.0.0/dscpkg.exe' + $newBinary = Join-Path $FixtureDirectory 'v1.0.1/dscpkg.exe' + if ((Get-SHA256 $oldBinary) -eq (Get-SHA256 $newBinary)) { throw 'Upgrade fixture executables must differ.' } + Invoke-Msi '/i' $old (Join-Path $logs 'install-old.log') + $installed = $old + Assert-Installed $oldBinary + Invoke-Msi '/i' $new (Join-Path $logs 'upgrade.log') + $installed = $new + Assert-Installed $newBinary + $downgradeLog = Join-Path $logs 'reject-downgrade.log' + Invoke-Msi -Operation '/i' -Path $old -LogPath $downgradeLog -ExpectedCodes @(1603, 1638) + if (-not (Select-String -LiteralPath $downgradeLog -SimpleMatch 'A newer version of dscpkg is already installed.')) { + throw 'Downgrade did not fail for the intended version check.' + } + Assert-Installed $newBinary + Remove-TestProduct 'uninstall-upgrade' +} finally { + if ($installed) { Invoke-Msi '/x' $installed (Join-Path $logs 'cleanup.log') } + [Environment]::SetEnvironmentVariable('Path', $originalPath, 'Machine') + $env:PATH = $processPath +} +Write-Host 'MSI install, repair, upgrade, downgrade rejection, PATH and data retention passed.'