diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09f3f5c..522d7aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,11 @@ jobs: - uses: actions/setup-go@v6 with: go-version-file: go.mod + - name: Stamp plugin version + run: | + version="${GITHUB_REF_NAME#v}" + sed -i "s/^version:.*/version: \"${version}\"/" plugin.yaml + echo "Stamped plugin.yaml version: ${version}" - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: @@ -26,6 +31,23 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Sign plugin archives + if: ${{ vars.HELM_SCRIBE_SIGNING_ENABLED == 'true' }} + env: + HELM_SCRIBE_SIGNING_KEY: ${{ secrets.HELM_SCRIBE_SIGNING_KEY }} + HELM_SCRIBE_SIGNING_KEY_ID: ${{ secrets.HELM_SCRIBE_SIGNING_KEY_ID }} + run: | + set -euo pipefail + echo "$HELM_SCRIBE_SIGNING_KEY" | gpg --batch --import + version="${GITHUB_REF_NAME#v}" + ./scripts/sign-plugin.sh "$version" dist + - name: Upload provenance files + if: ${{ vars.HELM_SCRIBE_SIGNING_ENABLED == 'true' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh release upload "${GITHUB_REF_NAME}" dist/*.prov --clobber - name: Summary run: | tag="${GITHUB_REF_NAME}" diff --git a/.gitignore b/.gitignore index 4a89ba1..01d6318 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ go.work.sum helm-scribe docs/ .test-fixture/ +.signing-key-id +.signing-private-key.asc diff --git a/.goreleaser.yml b/.goreleaser.yml index f9a4506..72ba0fd 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -18,6 +18,15 @@ archives: formats: [zip] name_template: >- {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + files: + - plugin.yaml + - scripts/install-binary.sh + - scripts/install-binary.ps1 + - README.md + - LICENSE + +checksum: + name_template: checksums.txt changelog: sort: asc diff --git a/README.md b/README.md index 55c3dc4..e46b6cc 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,28 @@ Or, if you wish to install with go: go install github.com/miosp/helm-scribe@latest ``` +### As a Helm plugin + +Install as a Helm plugin and run it as `helm scribe`: + +```sh +# Helm v3 or v4 (git sources cannot be signature-verified) +helm plugin install https://github.com/Miosp/helm-scribe --verify=false +``` + +For a verified install on Helm v4 (from a release tarball), import the signing +key first, then point `helm plugin install` at a release archive: + +```sh +curl -fsSL https://raw.githubusercontent.com/Miosp/helm-scribe/main/signing-key.asc \ + | gpg --dearmor > helm-scribe-keyring.gpg +helm plugin install \ + https://github.com/Miosp/helm-scribe/releases/download/v0.3.1/helm-scribe_v0.3.1_linux_amd64.tar.gz \ + --verify +``` + +Then run `helm scribe` in any chart directory. + ## Add README markers Insert these where the parameters table should appear: diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..9fe95a2 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,28 @@ +name: "scribe" +# Auto-stamped from the release tag by the Release workflow; not load-bearing +# for installs (the install hooks resolve the latest release themselves). +version: "0.0.0" +usage: "generate a Helm values schema and README parameters table" +description: "Generate a values.schema.json and README parameters table from annotated values.yaml" +platformCommand: + - os: linux + command: $HELM_PLUGIN_DIR/bin/helm-scribe + - os: darwin + command: $HELM_PLUGIN_DIR/bin/helm-scribe + - os: windows + command: $HELM_PLUGIN_DIR/bin/helm-scribe.exe +platformHooks: + install: + - os: linux + command: $HELM_PLUGIN_DIR/scripts/install-binary.sh + - os: darwin + command: $HELM_PLUGIN_DIR/scripts/install-binary.sh + - os: windows + command: powershell -ExecutionPolicy Bypass -File $HELM_PLUGIN_DIR/scripts/install-binary.ps1 + update: + - os: linux + command: $HELM_PLUGIN_DIR/scripts/install-binary.sh + - os: darwin + command: $HELM_PLUGIN_DIR/scripts/install-binary.sh + - os: windows + command: powershell -ExecutionPolicy Bypass -File $HELM_PLUGIN_DIR/scripts/install-binary.ps1 diff --git a/scripts/install-binary.ps1 b/scripts/install-binary.ps1 new file mode 100644 index 0000000..110cc18 --- /dev/null +++ b/scripts/install-binary.ps1 @@ -0,0 +1,91 @@ +# Download and install the helm-scribe binary into the Helm plugin directory. +# Invoked by Helm as the plugin install/update hook on Windows. +# Targets Windows PowerShell 5.1 (built into Windows) and PowerShell 7+. +#Requires -Version 5.1 + +$ErrorActionPreference = "Stop" + +$ProjectName = "helm-scribe" +$ProjectGh = "Miosp/$ProjectName" + +$PluginDir = $env:HELM_PLUGIN_DIR +if (-not $PluginDir) { throw "HELM_PLUGIN_DIR is not set." } + +# GitHub requires TLS 1.2. +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + +# Resolve the latest release tag via GitHub's /releases/latest redirect (no API, +# no rate limit). AllowAutoRedirect=$false makes GetResponse throw on the 302; +# the redirect Location lives on the exception's response. +function Resolve-LatestTag { + $req = [System.Net.HttpWebRequest]::Create("https://github.com/$ProjectGh/releases/latest") + $req.AllowAutoRedirect = $false + try { + $resp = $req.GetResponse() + } catch [System.Net.WebException] { + $resp = $_.Exception.Response + } + if (-not $resp) { throw "Could not resolve the latest release tag for $ProjectGh." } + $location = $resp.Headers["Location"] + $resp.Close() + if (-not $location) { throw "Could not resolve the latest release tag for $ProjectGh." } + return ($location -split '/')[-1] +} + +# Map the Windows architecture to the goreleaser arch name we publish. +switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { $arch = "amd64" } + "ARM64" { $arch = "arm64" } + default { throw "Architecture '$env:PROCESSOR_ARCHITECTURE' is not supported." } +} + +$tag = Resolve-LatestTag +$version = $tag -replace '^v', '' +$archive = "${ProjectName}_${version}_windows_${arch}.zip" +$base = "https://github.com/$ProjectGh/releases/download/$tag" +$downloadUrl = "$base/$archive" +$checksumUrl = "$base/checksums.txt" + +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) "helm-scribe-install-$([System.Guid]::NewGuid())" +New-Item -ItemType Directory -Path $tmp -Force | Out-Null +try { + $zip = Join-Path $tmp $archive + Write-Host "Downloading $downloadUrl" + Invoke-WebRequest -Uri $downloadUrl -OutFile $zip -UseBasicParsing + + # Checksum verification against checksums.txt (best-effort; older releases + # may not ship one). + try { + $checksums = (Invoke-WebRequest -Uri $checksumUrl -UseBasicParsing).Content + $expected = $null + foreach ($line in ($checksums -split "`n")) { + if ($line -match "\s$([regex]::Escape($archive))\s*$") { + $expected = ($line -split '\s+')[0] + break + } + } + if ($expected) { + $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected.ToLower()) { + throw "Checksum mismatch for $archive.`n expected: $expected`n actual: $actual" + } + } else { + Write-Warning "$archive not found in checksums.txt; skipping verification." + } + } catch { + Write-Warning "Could not download checksums.txt; skipping verification." + } + + # Extract and install the binary. + Expand-Archive -Path $zip -DestinationPath $tmp -Force + $binDir = Join-Path $PluginDir "bin" + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + $exe = Join-Path $tmp "$ProjectName.exe" + if (-not (Test-Path $exe)) { throw "Binary $ProjectName.exe not found in the archive." } + Move-Item -Force $exe (Join-Path $binDir "$ProjectName.exe") + + Write-Host "$ProjectName installed into $binDir\$ProjectName.exe" + Write-Host "Run 'helm scribe --help' to get started." +} finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} diff --git a/scripts/install-binary.sh b/scripts/install-binary.sh new file mode 100755 index 0000000..2d2e57a --- /dev/null +++ b/scripts/install-binary.sh @@ -0,0 +1,183 @@ +#!/bin/sh +# Download and install the helm-scribe binary into the Helm plugin directory. +# Invoked by Helm as the plugin install/update hook. +# +# Adapted from the helm-template / helm-schema plugin installers. + +set -eu + +PROJECT_NAME="helm-scribe" +BINARY_NAME="helm-scribe" +PROJECT_GH="Miosp/$PROJECT_NAME" +BIN_DIR_SUFFIX="bin" + +# Convert HELM_BIN and HELM_PLUGIN_DIR to unix paths when running under +# MSYS2/Cygwin on Windows, where helm returns Windows paths. +if command -v cygpath >/dev/null 2>&1; then + HELM_BIN="$(cygpath -u "${HELM_BIN}")" + HELM_PLUGIN_DIR="$(cygpath -u "${HELM_PLUGIN_DIR}")" +fi + +[ -z "$HELM_BIN" ] && HELM_BIN="$(command -v helm || true)" + +if [ "${SKIP_BIN_INSTALL:-}" = "1" ]; then + echo "Skipping binary install" + exit 0 +fi + +# Helm passes -u for updates. Both install and update resolve the latest +# release tag, so plugin.yaml's version field is never load-bearing here. + +# initArch maps uname -m to the goreleaser arch names we publish. +initArch() { + ARCH="$(uname -m)" + case "$ARCH" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) + echo "Architecture '$(uname -m)' is not supported." >&2 + exit 1 + ;; + esac +} + +# initOS maps uname -s to the lowercase goreleaser os names we publish. +initOS() { + OS="$(uname -s)" + case "$OS" in + Windows_NT|MSYS*|MINGW*|CYGWIN*) OS="windows" ;; + Darwin) OS="darwin" ;; + Linux) OS="linux" ;; + *) + echo "OS '$(uname)' is not supported." >&2 + exit 1 + ;; + esac +} + +# verifySupported rejects unsupported os/arch combos and requires a downloader. +verifySupported() { + supported="linux-amd64 +linux-arm64 +darwin-amd64 +darwin-arm64 +windows-amd64 +windows-arm64" + if ! echo "$supported" | grep -q "^${OS}-${ARCH}$"; then + echo "No prebuilt binary for ${OS}-${ARCH}." >&2 + exit 1 + fi + if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then + echo "Either curl or wget is required." >&2 + exit 1 + fi +} + +# downloadFile fetches a URL to stdout. +downloadFile() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" + else + wget -qO- "$1" + fi +} + +# resolveLatestTag returns the latest release tag (e.g. v0.3.1) by following +# GitHub's /releases/latest redirect. No API call, so no rate limit. +resolveLatestTag() { + url="https://github.com/${PROJECT_GH}/releases/latest" + if command -v curl >/dev/null 2>&1; then + headers="$(curl -fsSI "$url" 2>/dev/null || true)" + else + headers="$(wget -Sq --spider "$url" 2>&1 >/dev/null || true)" + fi + echo "$headers" | tr -d '\r' | awk 'tolower($1)=="location:"{print $2}' | sed 's:.*/tag/::; s:.*/::' +} + +# getDownloadURL sets DOWNLOAD_URL and CHECKSUM_URL against the latest release. +# Release tags are v-prefixed (e.g. v0.3.1); archive filenames use the bare +# version (e.g. helm-scribe_0.3.1_linux_amd64.tar.gz). +getDownloadURL() { + ext="tar.gz" + if [ "$OS" = "windows" ]; then + ext="zip" + fi + version="$(resolveLatestTag)" + version="${version#v}" + if [ -z "$version" ]; then + echo "Could not resolve the latest release tag for ${PROJECT_GH}." >&2 + exit 1 + fi + tag="v${version}" + archive="${PROJECT_NAME}_${version}_${OS}_${ARCH}.${ext}" + base="https://github.com/${PROJECT_GH}/releases" + DOWNLOAD_URL="${base}/download/${tag}/${archive}" + CHECKSUM_URL="${base}/download/${tag}/checksums.txt" +} + +mkTempDir() { + HELM_TMP="$(mktemp -d 2>/dev/null || mktemp -d -t "${PROJECT_NAME}-XXXXXX")" +} + +rmTempDir() { + if [ -n "${HELM_TMP:-}" ] && [ -d "$HELM_TMP" ]; then + rm -rf "$HELM_TMP" + fi +} + +# extractFile extracts the downloaded archive into the plugin bin dir. +extractFile() { + bin_dir="${HELM_PLUGIN_DIR}/${BIN_DIR_SUFFIX}" + mkdir -p "$bin_dir" + archive="$(basename "$DOWNLOAD_URL")" + if [ "$OS" = "windows" ]; then + unzip -o "$HELM_TMP/$archive" -d "$HELM_TMP/unpacked" >/dev/null + mv "$HELM_TMP/unpacked/${BINARY_NAME}.exe" "${bin_dir}/${BINARY_NAME}.exe" 2>/dev/null \ + || mv "$HELM_TMP/unpacked/${BINARY_NAME}" "${bin_dir}/${BINARY_NAME}.exe" + else + tar -xzf "$HELM_TMP/$archive" -C "$HELM_TMP" + mv "$HELM_TMP/${BINARY_NAME}" "${bin_dir}/${BINARY_NAME}" + chmod +x "${bin_dir}/${BINARY_NAME}" + fi +} + +# verifyChecksum checks the archive hash against checksums.txt. +verifyChecksum() { + archive="$(basename "$DOWNLOAD_URL")" + checksums="$(downloadFile "$CHECKSUM_URL" || true)" + if [ -z "$checksums" ]; then + echo "Warning: could not download checksums.txt; skipping verification." >&2 + return 0 + fi + expected="$(echo "$checksums" | grep -E " ${archive}\$" | awk '{print $1}')" + if [ -z "$expected" ]; then + echo "Warning: ${archive} not found in checksums.txt; skipping verification." >&2 + return 0 + fi + actual="$(sha256sum "$HELM_TMP/$archive" | awk '{print $1}')" + if [ "$actual" != "$expected" ]; then + echo "Checksum mismatch for ${archive}." >&2 + echo " expected: ${expected}" >&2 + echo " actual: ${actual}" >&2 + rmTempDir + exit 1 + fi +} + +initArch +initOS +verifySupported +getDownloadURL + +echo "Downloading ${DOWNLOAD_URL}" +mkTempDir +trap rmTempDir EXIT + +archive="$(basename "$DOWNLOAD_URL")" +downloadFile "$DOWNLOAD_URL" > "$HELM_TMP/$archive" + +verifyChecksum +extractFile + +echo "$PROJECT_NAME installed into ${HELM_PLUGIN_DIR}/${BIN_DIR_SUFFIX}/${BINARY_NAME}" +echo "Run 'helm scribe --help' to get started." diff --git a/scripts/setup-signing-key.sh b/scripts/setup-signing-key.sh new file mode 100755 index 0000000..2fe58a4 --- /dev/null +++ b/scripts/setup-signing-key.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# One-time helper: generate the helm-scribe GPG signing key. +# +# Run locally (never in CI). Produces: +# 1. signing-key.asc -> commit to the repository (public key) +# 2. private-key armor -> print to stdout for the GitHub secret +# HELM_SCRIBE_SIGNING_KEY +# 3. the key fingerprint -> for the GitHub secret +# HELM_SCRIBE_SIGNING_KEY_ID +# +# Usage: ./scripts/setup-signing-key.sh + +set -euo pipefail + +KEY_FILE="signing-key.asc" +KEY_ID_FILE=".signing-key-id" +PRIVATE_FILE=".signing-private-key.asc" + +if [ -f "$KEY_FILE" ] && [ "${1:-}" != "--force" ]; then + echo "$KEY_FILE already exists. Re-run with --force to regenerate." >&2 + exit 1 +fi + +echo "Generating a new RSA 4096 signing key for helm-scribe..." +gpg --batch --full-generate-key <<'EOF' +%no-protection +Key-Type: RSA +Key-Length: 4096 +Key-Usage: sign +Name-Real: helm-scribe +Name-Email: helm-scribe@users.noreply.github.com +Expire-Date: 0 +%commit +EOF + +fingerprint="$(gpg --list-keys --with-colons "helm-scribe@users.noreply.github.com" | awk -F: '/^fpr:/ {print $10; exit}')" + +if [ -z "$fingerprint" ]; then + echo "Failed to locate the generated key fingerprint." >&2 + exit 1 +fi + +gpg --armor --export "$fingerprint" > "$KEY_FILE" +echo "$fingerprint" > "$KEY_ID_FILE" +gpg --armor --export-secret-keys "$fingerprint" > "$PRIVATE_FILE" +for f in "$KEY_ID_FILE" "$PRIVATE_FILE"; do + grep -qx "$f" .gitignore 2>/dev/null || echo "$f" >> .gitignore +done + +cat < Secrets and variables > Actions > Secrets + > New repository secret. Add two secrets: + + Name: HELM_SCRIBE_SIGNING_KEY_ID + Value: ${fingerprint} + + Name: HELM_SCRIBE_SIGNING_KEY + Value: + (also written to ${PRIVATE_FILE}, e.g. \`pbcopy < ${PRIVATE_FILE}\`) + + 3. GitHub: Settings > Secrets and variables > Actions > Variables + > New repository variable: + + Name: HELM_SCRIBE_SIGNING_ENABLED + Value: true + +----- BEGIN HELM_SCRIBE_SIGNING_KEY ----- +$(cat "$PRIVATE_FILE")----- END HELM_SCRIBE_SIGNING_KEY ----- + +EOF diff --git a/scripts/sign-plugin.sh b/scripts/sign-plugin.sh new file mode 100755 index 0000000..fe6b6af --- /dev/null +++ b/scripts/sign-plugin.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Sign helm-scribe release archives for Helm v4 plugin verification. +# +# Creates a .prov (provenance) file per archive in Helm's provenance format, +# then GPG clear-signs it. Run from the repository root after GoReleaser has +# produced dist/. +# +# Env: +# HELM_SCRIBE_SIGNING_KEY_ID GPG key id/fingerprint to sign with (required) +# +# Usage: ./sign-plugin.sh [dist-dir] + +set -euo pipefail + +PROJECT_NAME="helm-scribe" +PLUGIN_NAME="scribe" +PROJECT_GH="Miosp/${PROJECT_NAME}" +DESCRIPTION="Generate a values.schema.json and README parameters table from annotated values.yaml" +VERSION="${1:-}" +DIST_DIR="${2:-dist}" +GPG_KEY="${HELM_SCRIBE_SIGNING_KEY_ID:-}" + +if [ -z "$VERSION" ]; then + echo "Usage: $0 [dist-dir]" >&2 + exit 1 +fi +if [ -z "$GPG_KEY" ]; then + echo "Error: HELM_SCRIBE_SIGNING_KEY_ID must be set." >&2 + exit 1 +fi + +shopt -s nullglob +archives=("$DIST_DIR"/*/*.tar.gz "$DIST_DIR"/*/*.zip "$DIST_DIR"/*.tar.gz "$DIST_DIR"/*.zip) +shopt -u nullglob + +if [ "${#archives[@]}" -eq 0 ]; then + echo "No archives found in $DIST_DIR." >&2 + exit 1 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +for archive in "${archives[@]}"; do + [ -f "$archive" ] || continue + name="$(basename "$archive")" + hash="$(sha256sum "$archive" | awk '{print $1}')" + + cat > "$tmp/payload.yaml" <