From d481ea1fcd262ebf33f1d5f861ea2c7be8182c82 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:03:18 +1100 Subject: [PATCH] add portable binary release validation --- .github/workflows/build-matrix.yaml | 152 +++++++++++++++ .github/workflows/release-binaries.yaml | 179 ++++++++++++++++++ .mise.toml | 3 + .../echohello-dev.skillet.installer.yaml | 4 +- scripts/smoke-artifact.ts | 46 ++++- scripts/validate-packaging.ts | 138 ++++++++++++++ src/distribution/winget.ts | 4 +- tests/distribution/winget.test.ts | 1 + 8 files changed, 514 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/release-binaries.yaml create mode 100644 scripts/validate-packaging.ts diff --git a/.github/workflows/build-matrix.yaml b/.github/workflows/build-matrix.yaml index e70a557..7c464ee 100644 --- a/.github/workflows/build-matrix.yaml +++ b/.github/workflows/build-matrix.yaml @@ -76,3 +76,155 @@ jobs: with: name: skillet-checksums path: dist/SHA256SUMS + + qemu-smoke: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Setup QEMU + uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e8 + + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + path: dist + merge-multiple: true + + - name: Make Linux artifacts executable + shell: bash + run: | + set -euo pipefail + chmod +x dist/skillet-linux-* + + - name: Smoke test arm64 musl artifact under emulation + shell: bash + run: | + set -euo pipefail + OUTPUT=$(docker run --rm --platform linux/arm64/v8 -v "$PWD/dist:/artifacts:ro" alpine:3.22 /artifacts/skillet-linux-arm64-musl --version) + printf '%s\n' "$OUTPUT" + grep -F "sklt/" <<< "$OUTPUT" + + - name: Smoke test arm64 gnu artifact under emulation + shell: bash + run: | + set -euo pipefail + OUTPUT=$(docker run --rm --platform linux/arm64/v8 -v "$PWD/dist:/artifacts:ro" ubuntu:24.04 /artifacts/skillet-linux-arm64-gnu --version) + printf '%s\n' "$OUTPUT" + grep -F "sklt/" <<< "$OUTPUT" + + wine-smoke: + needs: build + runs-on: ubuntu-latest + + env: + WINEDEBUG: -all + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Setup mise + uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac + + - name: Install Wine + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y wine64 + + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + path: dist + merge-multiple: true + + - name: Smoke test Windows artifact with Wine + run: bun scripts/smoke-artifact.ts --target=windows-x64 --artifact-dir=dist --runner=wine64 + + docker-integration: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Setup mise + uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac + + - name: Setup QEMU + uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e8 + + - name: Build musl binaries + run: mise run build -- --targets=linux-x64-musl,linux-arm64-musl + + - name: Smoke test amd64 Docker image + shell: bash + run: | + set -euo pipefail + docker buildx create --name skillet-smoke-builder --use || docker buildx use skillet-smoke-builder + docker buildx build --platform linux/amd64 --load -t skillet:amd64 . + docker run --rm --platform linux/amd64 skillet:amd64 --help + + - name: Smoke test arm64 Docker image + shell: bash + run: | + set -euo pipefail + docker buildx use skillet-smoke-builder + docker buildx build --platform linux/arm64 --load -t skillet:arm64 . + docker run --rm --platform linux/arm64 skillet:arm64 --help + + packaging-validate: + needs: checksums + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Setup mise + uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac + + - name: Download checksum artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: skillet-checksums + path: dist + + - name: Validate rendered packaging assets + run: mise run package-validate + + - name: Validate Homebrew formula syntax + run: ruby -c packaging/homebrew/skillet.rb + + windows-packaging-validate: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Pack Chocolatey package + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist/choco | Out-Null + choco pack packaging/chocolatey/skillet.nuspec --outputdirectory dist/choco + + - name: Validate Chocolatey PowerShell scripts + shell: pwsh + run: | + [scriptblock]::Create((Get-Content -Raw packaging/chocolatey/tools/chocolateyinstall.ps1)) | Out-Null + [scriptblock]::Create((Get-Content -Raw packaging/chocolatey/tools/chocolateyuninstall.ps1)) | Out-Null + + - name: Validate winget manifests parse as YAML + shell: pwsh + run: | + $package = Get-Content package.json -Raw | ConvertFrom-Json + $wingetDir = Join-Path packaging/winget $package.version + Get-Content (Join-Path $wingetDir 'echohello-dev.skillet.yaml') -Raw | ConvertFrom-Yaml | Out-Null + Get-Content (Join-Path $wingetDir 'echohello-dev.skillet.installer.yaml') -Raw | ConvertFrom-Yaml | Out-Null + Get-Content (Join-Path $wingetDir 'echohello-dev.skillet.locale.en-US.yaml') -Raw | ConvertFrom-Yaml | Out-Null diff --git a/.github/workflows/release-binaries.yaml b/.github/workflows/release-binaries.yaml new file mode 100644 index 0000000..20f6fe4 --- /dev/null +++ b/.github/workflows/release-binaries.yaml @@ -0,0 +1,179 @@ +name: Release Binaries + +on: + release: + types: + - published + workflow_dispatch: + inputs: + ref: + description: Git ref to release, usually a tag like v1.0.0 + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release-binaries-${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }} + cancel-in-progress: false + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + targets: linux-x64-gnu,linux-x64-musl,linux-arm64-gnu,linux-arm64-musl + - os: macos-latest + targets: darwin-arm64,darwin-x64 + - os: windows-latest + targets: windows-x64 + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }} + + - name: Setup mise + uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac + + - name: Build targets + run: mise run build -- --targets=${{ matrix.targets }} + + - name: Smoke test host artifact + run: bun scripts/smoke-artifact.ts + + - name: Upload build artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-${{ matrix.os }} + path: dist/* + + upload: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }} + + - name: Setup mise + uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac + + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + path: .artifacts + merge-multiple: true + + - name: Assemble release assets + shell: bash + run: | + set -euo pipefail + mkdir -p dist + cp .artifacts/skillet-* dist/ || true + cp .artifacts/*.exe dist/ || true + bun scripts/write-checksums.ts + + - name: Upload release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }} + shell: bash + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" dist/skillet-* dist/*.exe dist/SHA256SUMS --clobber + + verify-homebrew: + needs: upload + runs-on: macos-latest + env: + HOMEBREW_NO_AUTO_UPDATE: 1 + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }} + + - name: Install from Homebrew formula + run: brew install --formula ./packaging/homebrew/skillet.rb + + - name: Verify Homebrew install + run: skillet --version + + - name: Uninstall Homebrew formula + if: always() + run: brew uninstall skillet + + verify-chocolatey: + needs: upload + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }} + + - name: Pack Chocolatey package + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist/choco | Out-Null + choco pack packaging/chocolatey/skillet.nuspec --outputdirectory dist/choco + + - name: Install from Chocolatey package + shell: pwsh + run: | + choco install skillet --source "$PWD\dist\choco" --yes --no-progress + + - name: Verify Chocolatey install + shell: pwsh + run: skillet --version + + - name: Uninstall Chocolatey package + if: always() + shell: pwsh + run: | + choco uninstall skillet --yes --no-progress + + verify-winget: + needs: upload + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }} + + - name: Validate winget manifests + shell: pwsh + run: | + $package = Get-Content package.json -Raw | ConvertFrom-Json + $manifest = Join-Path (Join-Path packaging/winget $package.version) 'echohello-dev.skillet.yaml' + winget validate $manifest + + - name: Install from winget manifest + shell: pwsh + run: | + $package = Get-Content package.json -Raw | ConvertFrom-Json + $manifest = Join-Path (Join-Path packaging/winget $package.version) 'echohello-dev.skillet.yaml' + winget install --manifest $manifest --accept-source-agreements --accept-package-agreements --disable-interactivity + + - name: Verify winget install + shell: pwsh + run: skillet --version + + - name: Uninstall winget package + if: always() + shell: pwsh + run: | + winget uninstall --id echohello-dev.skillet --exact --disable-interactivity || exit 0 diff --git a/.mise.toml b/.mise.toml index 2fcdb13..7f8c01e 100644 --- a/.mise.toml +++ b/.mise.toml @@ -31,6 +31,9 @@ run = "mise run build -- --targets=linux-x64-musl,linux-arm64-musl && docker bui [tasks.build-npm] run = "mise run install && bun scripts/build-npm-cli.ts" +[tasks.package-validate] +run = "mise run install && bun scripts/validate-packaging.ts" + [tasks.npm-smoke] run = "mise run build-npm && npm pack >/tmp/skillet-npm-pack.log && PACKAGE=$(tail -n 1 /tmp/skillet-npm-pack.log) && npx --yes --package \"./$PACKAGE\" sklt --help && REPO_DIR=\"$PWD\" && TMP_DIR=$(mktemp -d) && (cd \"$TMP_DIR\" && bun init -y >/dev/null 2>&1 && bun add \"$REPO_DIR/$PACKAGE\" >/dev/null && bunx --bun sklt --help) && rm -rf \"$TMP_DIR\" \"$PACKAGE\"" diff --git a/packaging/winget/0.0.0/echohello-dev.skillet.installer.yaml b/packaging/winget/0.0.0/echohello-dev.skillet.installer.yaml index 8690aa1..78b1333 100644 --- a/packaging/winget/0.0.0/echohello-dev.skillet.installer.yaml +++ b/packaging/winget/0.0.0/echohello-dev.skillet.installer.yaml @@ -2,11 +2,9 @@ PackageIdentifier: echohello-dev.skillet PackageVersion: 0.0.0 Installers: - Architecture: x64 - InstallerType: exe + InstallerType: portable InstallerUrl: https://github.com/echohello-dev/skillet/releases/download/v0.0.0/skillet-windows-x64.exe InstallerSha256: 51CDDEFDE243F0F27E501CA420D5E1D1B9CAD548DC9884EA4052D2915AFEF179 - AppsAndFeaturesEntries: - - DisplayName: skillet Commands: - skillet ManifestType: installer diff --git a/scripts/smoke-artifact.ts b/scripts/smoke-artifact.ts index 98b5ba5..1e307a3 100644 --- a/scripts/smoke-artifact.ts +++ b/scripts/smoke-artifact.ts @@ -4,23 +4,31 @@ import { execFileSync } from "node:child_process"; import { RELEASE_TARGETS, getHostBuildTargetId } from "../src/build/targets"; function main(): void { + const args = process.argv.slice(2); const hostId = getHostBuildTargetId(process.platform, process.arch); - if (!hostId) { + const targetId = readArg(args, "--target") ?? hostId; + const artifactDir = path.resolve(readArg(args, "--artifact-dir") ?? "dist"); + const runner = readArg(args, "--runner"); + + if (!targetId) { console.log(`No smoke target mapping for ${process.platform}/${process.arch}`); return; } - const hostTarget = RELEASE_TARGETS.find((target) => target.id === hostId); - if (!hostTarget) { - throw new Error(`Host target not found in RELEASE_TARGETS: ${hostId}`); + const target = RELEASE_TARGETS.find((candidate) => candidate.id === targetId); + if (!target) { + throw new Error(`Target not found in RELEASE_TARGETS: ${targetId}`); } - const artifactPath = path.join(process.cwd(), "dist", hostTarget.artifactName); + const artifactPath = path.join(artifactDir, target.artifactName); if (!fs.existsSync(artifactPath)) { throw new Error(`Host artifact missing: ${artifactPath}`); } - const output = execFileSync(artifactPath, ["--version"], { + const command = runner ? runner : artifactPath; + const commandArgs = runner ? [artifactPath, "--version"] : ["--version"]; + + const output = execFileSync(command, commandArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); @@ -29,7 +37,31 @@ function main(): void { throw new Error(`Unexpected smoke output: ${output}`); } - console.log(`Smoke test passed for ${hostId}: ${output.trim()}`); + console.log(`Smoke test passed for ${targetId}: ${output.trim()}`); +} + +function readArg(args: string[], name: string): string | undefined { + for (let i = 0; i < args.length; i += 1) { + const token = args[i]; + if (token === name) { + const next = args[i + 1]; + if (!next || next.startsWith("--")) { + throw new Error(`Missing value for ${name}`); + } + return next; + } + + const prefix = `${name}=`; + if (token.startsWith(prefix)) { + const value = token.slice(prefix.length); + if (value.length === 0) { + throw new Error(`Missing value for ${name}`); + } + return value; + } + } + + return undefined; } main(); diff --git a/scripts/validate-packaging.ts b/scripts/validate-packaging.ts new file mode 100644 index 0000000..58f6125 --- /dev/null +++ b/scripts/validate-packaging.ts @@ -0,0 +1,138 @@ +import fs from "node:fs"; +import path from "node:path"; +import { parse } from "yaml"; +import { parseSha256Sums } from "../src/distribution/checksums"; +import { renderChocolateyPackageFiles } from "../src/distribution/chocolatey"; +import { renderHomebrewFormula } from "../src/distribution/homebrew"; +import { renderWingetManifestFiles } from "../src/distribution/winget"; + +function main(): void { + const args = process.argv.slice(2); + const version = readArg(args, "--version") ?? readVersionFromPackageJson(); + const checksumsPath = path.resolve(readArg(args, "--checksums") ?? "dist/SHA256SUMS"); + const packagingRoot = path.resolve(readArg(args, "--packaging-root") ?? "packaging"); + const releaseUrlBase = + readArg(args, "--release-url-base") ?? + `https://github.com/echohello-dev/skillet/releases/download/v${version}`; + + if (!fs.existsSync(checksumsPath)) { + throw new Error(`Checksums file not found: ${checksumsPath}`); + } + + const checksums = parseSha256Sums(fs.readFileSync(checksumsPath, "utf8")); + const expectedHomebrew = renderHomebrewFormula({ + version, + releaseUrlBase, + checksumsByArtifact: checksums, + }); + const expectedChocolatey = renderChocolateyPackageFiles({ + version, + releaseUrlBase, + checksumsByArtifact: checksums, + }); + const expectedWinget = renderWingetManifestFiles({ + version, + releaseUrlBase, + checksumsByArtifact: checksums, + }); + + const wingetDir = path.join(packagingRoot, "winget", version); + const wingetVersionPath = path.join(wingetDir, "echohello-dev.skillet.yaml"); + const wingetInstallerPath = path.join(wingetDir, "echohello-dev.skillet.installer.yaml"); + const wingetLocalePath = path.join(wingetDir, "echohello-dev.skillet.locale.en-US.yaml"); + + assertContains(expectedHomebrew, `version \"${version}\"`, "generated Homebrew formula"); + assertContains(expectedChocolatey.nuspec, `${version}`, "generated Chocolatey nuspec"); + assertContains(expectedChocolatey.installScript, releaseUrlBase, "generated Chocolatey install script"); + assertContains(expectedWinget.installerManifest, releaseUrlBase, "generated winget installer manifest"); + + const versionManifest = parse(expectedWinget.versionManifest) as Record; + const installerManifest = parse(expectedWinget.installerManifest) as Record; + const localeManifest = parse(expectedWinget.localeManifest) as Record; + + assertField(versionManifest, "PackageVersion", version, "generated winget version manifest"); + assertField(versionManifest, "ManifestType", "version", "generated winget version manifest"); + assertField(installerManifest, "PackageVersion", version, "generated winget installer manifest"); + assertField(installerManifest, "ManifestType", "installer", "generated winget installer manifest"); + assertField(localeManifest, "PackageVersion", version, "generated winget locale manifest"); + assertField(localeManifest, "ManifestType", "defaultLocale", "generated winget locale manifest"); + + assertCheckedInPackagingFilesExist(packagingRoot, version); + assertField(parse(fs.readFileSync(wingetVersionPath, "utf8")) as Record, "ManifestType", "version", wingetVersionPath); + assertField(parse(fs.readFileSync(wingetInstallerPath, "utf8")) as Record, "ManifestType", "installer", wingetInstallerPath); + assertField(parse(fs.readFileSync(wingetLocalePath, "utf8")) as Record, "ManifestType", "defaultLocale", wingetLocalePath); + + console.log(`Validated packaging assets for ${version}`); +} + +function assertCheckedInPackagingFilesExist(packagingRoot: string, version: string): void { + const requiredPaths = [ + path.join(packagingRoot, "homebrew", "skillet.rb"), + path.join(packagingRoot, "chocolatey", "skillet.nuspec"), + path.join(packagingRoot, "chocolatey", "tools", "chocolateyinstall.ps1"), + path.join(packagingRoot, "chocolatey", "tools", "chocolateyuninstall.ps1"), + path.join(packagingRoot, "winget", version, "echohello-dev.skillet.yaml"), + path.join(packagingRoot, "winget", version, "echohello-dev.skillet.installer.yaml"), + path.join(packagingRoot, "winget", version, "echohello-dev.skillet.locale.en-US.yaml"), + ]; + + for (const filePath of requiredPaths) { + if (!fs.existsSync(filePath)) { + throw new Error(`Packaging file not found: ${filePath}`); + } + } +} + +function assertContains(contents: string, expectedFragment: string, label: string): void { + if (!contents.includes(expectedFragment)) { + throw new Error(`Missing expected content in ${label}: ${expectedFragment}`); + } +} + +function assertField( + document: Record, + field: string, + expectedValue: string, + filePath: string, +): void { + const actualValue = document[field]; + if (actualValue !== expectedValue) { + throw new Error(`Unexpected ${field} in ${filePath}: ${String(actualValue)}`); + } +} + +function readArg(args: string[], name: string): string | undefined { + for (let i = 0; i < args.length; i += 1) { + const token = args[i]; + if (token === name) { + const next = args[i + 1]; + if (!next || next.startsWith("--")) { + throw new Error(`Missing value for ${name}`); + } + return next; + } + + const prefix = `${name}=`; + if (token.startsWith(prefix)) { + const value = token.slice(prefix.length); + if (value.length === 0) { + throw new Error(`Missing value for ${name}`); + } + return value; + } + } + + return undefined; +} + +function readVersionFromPackageJson(): string { + const packageJsonPath = path.resolve("package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { version?: string }; + if (!packageJson.version || packageJson.version.length === 0) { + throw new Error("package.json version is missing"); + } + + return packageJson.version; +} + +main(); diff --git a/src/distribution/winget.ts b/src/distribution/winget.ts index fc6b93f..61425d1 100644 --- a/src/distribution/winget.ts +++ b/src/distribution/winget.ts @@ -29,11 +29,9 @@ ManifestVersion: 1.6.0 PackageVersion: ${options.version} Installers: - Architecture: x64 - InstallerType: exe + InstallerType: portable InstallerUrl: ${installerUrl} InstallerSha256: ${installerSha256} - AppsAndFeaturesEntries: - - DisplayName: skillet Commands: - skillet ManifestType: installer diff --git a/tests/distribution/winget.test.ts b/tests/distribution/winget.test.ts index 2e68fba..f20e7d4 100644 --- a/tests/distribution/winget.test.ts +++ b/tests/distribution/winget.test.ts @@ -16,6 +16,7 @@ describe("renderWingetManifestFiles", () => { expect(files.installerManifest).toContain( "InstallerUrl: https://github.com/echohello-dev/skillet/releases/download/v1.2.3/skillet-windows-x64.exe", ); + expect(files.installerManifest).toContain("InstallerType: portable"); expect(files.installerManifest).toContain( "InstallerSha256: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", );