From 993ffe717aca473ba7bde75f9d6439f015165613 Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Tue, 13 Oct 2026 16:00:48 -0700 Subject: [PATCH 1/7] Add tag-triggered draft releases and native installers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: afbfb6ff-84bc-42ed-854f-36575fcd2c3a --- .github/workflows/ci.yaml | 126 ++++++++++++++++++++- .github/workflows/release.yaml | 76 +++++++++++++ README.md | 125 +++++++++++++++++++++ packaging/build.ps1 | 45 ++++++++ packaging/common.ps1 | 126 +++++++++++++++++++++ packaging/linux/build.sh | 17 +++ packaging/linux/inspect.sh | 38 +++++++ packaging/linux/nfpm.yaml | 31 ++++++ packaging/linux/test-install.sh | 83 ++++++++++++++ packaging/release.ps1 | 106 ++++++++++++++++++ packaging/test-fixtures.ps1 | 24 ++++ packaging/test-release.ps1 | 160 +++++++++++++++++++++++++++ packaging/test-version.ps1 | 19 ++++ packaging/verify.ps1 | 47 ++++++++ packaging/version.ps1 | 17 +++ packaging/windows/msi/Package.wxs | 33 ++++++ packaging/windows/msi/build.ps1 | 30 +++++ packaging/windows/msi/dscpkg.wixproj | 10 ++ packaging/windows/msi/inspect.ps1 | 77 +++++++++++++ packaging/windows/test-install.ps1 | 95 ++++++++++++++++ 20 files changed, 1280 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 packaging/build.ps1 create mode 100644 packaging/common.ps1 create mode 100644 packaging/linux/build.sh create mode 100644 packaging/linux/inspect.sh create mode 100644 packaging/linux/nfpm.yaml create mode 100644 packaging/linux/test-install.sh create mode 100644 packaging/release.ps1 create mode 100644 packaging/test-fixtures.ps1 create mode 100644 packaging/test-release.ps1 create mode 100644 packaging/test-version.ps1 create mode 100644 packaging/verify.ps1 create mode 100644 packaging/version.ps1 create mode 100644 packaging/windows/msi/Package.wxs create mode 100644 packaging/windows/msi/build.ps1 create mode 100644 packaging/windows/msi/dscpkg.wixproj create mode 100644 packaging/windows/msi/inspect.ps1 create mode 100644 packaging/windows/test-install.ps1 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 789cc2c..44e3700 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,28 +20,51 @@ 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 @@ -44,11 +76,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 cpio 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..ac3f05d 100644 --- a/README.md +++ b/README.md @@ -208,3 +208,128 @@ 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`, `rpm2cpio`, +and `cpio`. 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..8ee3ee6 --- /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() + 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() + 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..11cde6b --- /dev/null +++ b/packaging/linux/build.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ $# == 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..8c2324d --- /dev/null +++ b/packaging/linux/inspect.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ $# == 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" +rpm2cpio "$rpm" | (cd "$stage/rpm" && cpio --quiet -idm) +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..6e19034 --- /dev/null +++ b/packaging/linux/nfpm.yaml @@ -0,0 +1,31 @@ +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 +overrides: + rpm: + release: "1" diff --git a/packaging/linux/test-install.sh b/packaging/linux/test-install.sh new file mode 100644 index 0000000..128c25d --- /dev/null +++ b/packaging/linux/test-install.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +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() { + [[ $(command -v dscpkg) == /usr/bin/dscpkg ]] + cmp "$1" /usr/bin/dscpkg + dscpkg publish -h + if [[ $format == deb ]]; then + [[ $(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..8f248b1 --- /dev/null +++ b/packaging/test-release.ps1 @@ -0,0 +1,160 @@ +. "$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 { + 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 +} 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..22eb3d2 --- /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..a62f3af --- /dev/null +++ b/packaging/windows/msi/inspect.ps1 @@ -0,0 +1,77 @@ +[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()) +$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=`"$stage/image`"") + $binaries = @(Get-ChildItem -LiteralPath "$stage/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..38086cb --- /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" +[Environment]::SetEnvironmentVariable('Path', $baselinePath, 'Machine') +$cache = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'dscpkg/packages' +if (Test-Path -LiteralPath $cache) { throw 'Test cache already exists; refusing to overwrite it.' } +New-Item -ItemType Directory -Path "$cache/.dscpkg", "$cache/test-resource" -Force | Out-Null +$sentinels = @("$cache/resources.json", "$cache/.dscpkg/metadata", "$cache/test-resource/resource") +foreach ($path in $sentinels) { Set-Content -LiteralPath $path -Value 'preserve-me' } +$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 { + $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.' From a60e750e26eb0fc63ec335d335e1ed4c476f2b3a Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Tue, 13 Oct 2026 16:04:23 -0700 Subject: [PATCH 2/7] Preserve successful test exit status after expected native failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: afbfb6ff-84bc-42ed-854f-36575fcd2c3a --- packaging/test-release.ps1 | 2 ++ packaging/windows/test-install.ps1 | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packaging/test-release.ps1 b/packaging/test-release.ps1 index 8f248b1..dd4b2bf 100644 --- a/packaging/test-release.ps1 +++ b/packaging/test-release.ps1 @@ -158,3 +158,5 @@ try { 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/windows/test-install.ps1 b/packaging/windows/test-install.ps1 index 38086cb..85cd08f 100644 --- a/packaging/windows/test-install.ps1 +++ b/packaging/windows/test-install.ps1 @@ -23,12 +23,9 @@ $originalPath = [Environment]::GetEnvironmentVariable('Path', 'Machine') $processPath = $env:PATH $sentinelPath = Join-Path $env:SystemDrive 'dscpkg-unrelated-path-entry' $baselinePath = "$originalPath;$sentinelPath" -[Environment]::SetEnvironmentVariable('Path', $baselinePath, 'Machine') $cache = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'dscpkg/packages' if (Test-Path -LiteralPath $cache) { throw 'Test cache already exists; refusing to overwrite it.' } -New-Item -ItemType Directory -Path "$cache/.dscpkg", "$cache/test-resource" -Force | Out-Null $sentinels = @("$cache/resources.json", "$cache/.dscpkg/metadata", "$cache/test-resource/resource") -foreach ($path in $sentinels) { Set-Content -LiteralPath $path -Value 'preserve-me' } $installed = $null function Assert-Preserved { @@ -61,6 +58,9 @@ function Remove-TestProduct { } 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 From ce9a61e9b9433ed1b0fdbdc286b9a7b289ab4f6e Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Tue, 13 Oct 2026 16:09:48 -0700 Subject: [PATCH 3/7] Keep MSI validation strict and make packaging lint cross-platform Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: afbfb6ff-84bc-42ed-854f-36575fcd2c3a --- .gitattributes | 1 + .github/workflows/ci.yaml | 4 +++- packaging/linux/test-install.sh | 2 ++ packaging/windows/msi/Package.wxs | 2 +- 4 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .gitattributes 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 44e3700..ec82ade 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -68,7 +68,9 @@ jobs: - 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 ./... diff --git a/packaging/linux/test-install.sh b/packaging/linux/test-install.sh index 128c25d..74c5637 100644 --- a/packaging/linux/test-install.sh +++ b/packaging/linux/test-install.sh @@ -51,6 +51,8 @@ verify_install() { 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" ]] diff --git a/packaging/windows/msi/Package.wxs b/packaging/windows/msi/Package.wxs index 22eb3d2..c0cba6f 100644 --- a/packaging/windows/msi/Package.wxs +++ b/packaging/windows/msi/Package.wxs @@ -5,7 +5,7 @@ + Schedule="afterInstallInitialize" /> From bd500155ae74d27cdbb15d987f4a1c92f05a23f1 Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Tue, 13 Oct 2026 16:15:13 -0700 Subject: [PATCH 4/7] Fix native package metadata and filter COM table-reader results Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: afbfb6ff-84bc-42ed-854f-36575fcd2c3a --- packaging/common.ps1 | 4 ++-- packaging/linux/nfpm.yaml | 3 --- packaging/test-release.ps1 | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packaging/common.ps1 b/packaging/common.ps1 index 8ee3ee6..c15af32 100644 --- a/packaging/common.ps1 +++ b/packaging/common.ps1 @@ -95,7 +95,7 @@ function Read-MsiTable { $database = $installer.OpenDatabase($Path, 0) $view = $database.OpenView($Query) try { - $view.Execute() + $view.Execute() | Out-Null while ($record = $view.Fetch()) { $row = [ordered]@{} for ($i = 0; $i -lt $Columns.Count; $i++) { @@ -105,7 +105,7 @@ function Read-MsiTable { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) | Out-Null } } finally { - $view.Close() + $view.Close() | Out-Null foreach ($object in @($view, $database, $installer)) { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($object) | Out-Null } diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index 6e19034..c6d822b 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -26,6 +26,3 @@ contents: mode: 0644 owner: root group: root -overrides: - rpm: - release: "1" diff --git a/packaging/test-release.ps1 b/packaging/test-release.ps1 index dd4b2bf..07cd7b5 100644 --- a/packaging/test-release.ps1 +++ b/packaging/test-release.ps1 @@ -68,6 +68,34 @@ function New-TestAssets { 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') From d0f7d2678079fcd0f2b7495468338e93abdb2e7c Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Tue, 13 Oct 2026 16:20:14 -0700 Subject: [PATCH 5/7] Confine package inspection to native temporary extraction paths Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: afbfb6ff-84bc-42ed-854f-36575fcd2c3a --- packaging/linux/build.sh | 3 ++- packaging/linux/inspect.sh | 5 +++-- packaging/linux/test-install.sh | 3 ++- packaging/windows/msi/inspect.ps1 | 5 +++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packaging/linux/build.sh b/packaging/linux/build.sh index 11cde6b..badd10e 100644 --- a/packaging/linux/build.sh +++ b/packaging/linux/build.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -set -euo pipefail +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) diff --git a/packaging/linux/inspect.sh b/packaging/linux/inspect.sh index 8c2324d..7be317c 100644 --- a/packaging/linux/inspect.sh +++ b/packaging/linux/inspect.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -set -euo pipefail +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) @@ -21,7 +22,7 @@ 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" -rpm2cpio "$rpm" | (cd "$stage/rpm" && cpio --quiet -idm) +rpm2cpio "$rpm" | (cd "$stage/rpm" && cpio --quiet -idm --no-absolute-filenames) 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) \ diff --git a/packaging/linux/test-install.sh b/packaging/linux/test-install.sh index 74c5637..65cc2b0 100644 --- a/packaging/linux/test-install.sh +++ b/packaging/linux/test-install.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -set -euo pipefail +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 diff --git a/packaging/windows/msi/inspect.ps1 b/packaging/windows/msi/inspect.ps1 index a62f3af..da478e1 100644 --- a/packaging/windows/msi/inspect.ps1 +++ b/packaging/windows/msi/inspect.ps1 @@ -46,13 +46,14 @@ foreach ($table in @('CustomAction', 'ServiceInstall', 'ServiceControl')) { } $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=`"$stage/image`"") - $binaries = @(Get-ChildItem -LiteralPath "$stage/image" -Recurse -File -Filter dscpkg.exe) + 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/../../..") From ab212b822309fb40e5fb6e3f31f9b34f5fc2ab67 Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Tue, 13 Oct 2026 16:23:49 -0700 Subject: [PATCH 6/7] Inspect RPM payloads through relative tar extraction Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: afbfb6ff-84bc-42ed-854f-36575fcd2c3a --- .github/workflows/ci.yaml | 2 +- README.md | 5 +++-- packaging/linux/inspect.sh | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ec82ade..b40a9fe 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -89,7 +89,7 @@ jobs: shell: bash run: | sudo apt-get update - sudo apt-get install -y rpm cpio shellcheck + 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 diff --git a/README.md b/README.md index ac3f05d..1e02b74 100644 --- a/README.md +++ b/README.md @@ -263,8 +263,9 @@ toolchain); the project pins WiX SDK 7.0.0. Review the 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`, `rpm2cpio`, -and `cpio`. Install nFPM as a separate build tool, not a runtime module dependency: +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 diff --git a/packaging/linux/inspect.sh b/packaging/linux/inspect.sh index 7be317c..8c8402b 100644 --- a/packaging/linux/inspect.sh +++ b/packaging/linux/inspect.sh @@ -22,7 +22,8 @@ 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" -rpm2cpio "$rpm" | (cd "$stage/rpm" && cpio --quiet -idm --no-absolute-filenames) +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) \ From dc7c6274f3f262c51e0fc389a307be8fd9bcf33b Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Tue, 13 Oct 2026 16:28:39 -0700 Subject: [PATCH 7/7] Verify installed executable identity on merged Fedora bin paths Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: afbfb6ff-84bc-42ed-854f-36575fcd2c3a --- packaging/linux/test-install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packaging/linux/test-install.sh b/packaging/linux/test-install.sh index 65cc2b0..568adda 100644 --- a/packaging/linux/test-install.sh +++ b/packaging/linux/test-install.sh @@ -48,7 +48,8 @@ preserved() { for file in "${sentinels[@]}"; do [[ $(cat "$file") == preserve-me ]]; done } verify_install() { - [[ $(command -v dscpkg) == /usr/bin/dscpkg ]] + # 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