diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 89ea8fdf..4959ed8c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,3 +1,15 @@ +# Release workflow +# +# Prerequisites (configure in Settings > Secrets and variables > Actions): +# - GPG_PRIVATE_KEY: base64-encoded GPG private key for signing release artifacts +# - GPG_FINGERPRINT: Fingerprint of the GPG key +# - GPG_PASSPHRASE: Passphrase for the GPG private key +# +# Key management notes: +# - Use a key with no expiration or set a calendar reminder before expiry +# - To rotate: generate a new keypair, update all three secrets, and verify +# with a test release (see the provenance-smoke-test job) + name: Release on: @@ -11,6 +23,7 @@ on: branches: - 'main' - 'master' + workflow_dispatch: permissions: contents: write @@ -21,7 +34,7 @@ jobs: steps: - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "flags=--snapshot" >> $GITHUB_ENV + run: echo "flags=--snapshot --skip=sign" >> $GITHUB_ENV - name: Checkout uses: actions/checkout@v6 @@ -32,6 +45,18 @@ jobs: uses: actions/setup-go@v6 with: go-version-file: 'go.mod' + - + name: Import GPG key + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + gpgconf --launch gpg-agent + printf '%s' "${{ secrets.GPG_PRIVATE_KEY }}" | base64 --decode | gpg --batch --import + - + name: Set GPG environment for signing + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "GPG_FINGERPRINT=${{ secrets.GPG_FINGERPRINT }}" >> "$GITHUB_ENV" + echo "GPG_PASSPHRASE=${{ secrets.GPG_PASSPHRASE }}" >> "$GITHUB_ENV" - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 @@ -41,3 +66,49 @@ jobs: args: release --clean ${{ env.flags }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + provenance-smoke-test: + runs-on: ubuntu-latest + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + steps: + - + name: Checkout + uses: actions/checkout@v6 + - + name: Test provenance signing with disposable key + run: | + export GNUPGHOME="$(mktemp -d)" + tmpdir="$(mktemp -d)" + trap 'rm -rf "$GNUPGHOME" "$tmpdir"' EXIT + chmod 700 "$GNUPGHOME" + + gpg --batch --pinentry-mode loopback --passphrase '' \ + --quick-generate-key "helm-diff-test" ed25519 sign 0 + GPG_FINGERPRINT=$(gpg --batch --with-colons --list-secret-keys "helm-diff-test" \ + | grep '^fpr:' | head -1 | cut -d: -f10) + export GPG_FINGERPRINT + export GPG_PASSPHRASE="" + + echo "dummy binary" > "$tmpdir/bin" + tar czf "$tmpdir/helm-diff-linux-amd64.tgz" -C "$tmpdir" bin + + ./scripts/sign-provenance.sh "$tmpdir/helm-diff-linux-amd64.tgz" "$tmpdir/helm-diff-linux-amd64.tgz.prov" + + if [ ! -f "$tmpdir/helm-diff-linux-amd64.tgz.prov" ]; then + echo "ERROR: provenance file was not created" + exit 1 + fi + + echo "=== gpg --verify ===" + gpg --verify "$tmpdir/helm-diff-linux-amd64.tgz.prov" + + echo "" + echo "=== Signed .prov content ===" + cat "$tmpdir/helm-diff-linux-amd64.tgz.prov" + + echo "" + echo "=== Parsed provenance block ===" + gpg --batch --output - "$tmpdir/helm-diff-linux-amd64.tgz.prov" 2>/dev/null + + echo "" + echo "Provenance smoke test passed" diff --git a/.goreleaser.yml b/.goreleaser.yml index 1c2d5f01..c0f53c1e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -52,6 +52,15 @@ archives: - README.md - plugin.yaml - LICENSE + +signs: + - id: plugin-provenance + artifacts: archive + signature: "${artifact}.prov" + cmd: ./scripts/sign-provenance.sh + args: + - ${artifact} + - ${signature} changelog: use: github-native diff --git a/README.md b/README.md index 6e6f257d..99fe1660 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,21 @@ The install script will skip the GitHub download and instead install from the `. **For Helm 4 users:** -Helm 4 requires plugin verification by default. Since this plugin does not yet provide provenance artifacts, you need to use the `--verify=false` flag: +Helm 4 verifies plugin provenance by default. This project publishes GPG-signed provenance artifacts (`.prov`) alongside release tarballs. To verify, import the project's public key into your keyring before running `helm plugin install`: ```shell -helm plugin install https://github.com/databus23/helm-diff --verify=false +gpg --keyserver hkps://keys.openpgp.org --recv-keys +helm plugin install https://github.com/databus23/helm-diff +``` + +For offline/airgapped environments, download the public key from the GitHub release assets on a connected machine, transfer it, and import it locally: + +```shell +gpg --import ``` +The public key fingerprint is published in the notes for each GitHub release. + For more information about Helm 4's plugin verification, see: - [Helm 4 Overview](https://helm.sh/docs/overview) - [HIP-0026: Plugin Provenance](https://github.com/helm/community/blob/main/hips/hip-0026.md) diff --git a/scripts/sign-provenance.sh b/scripts/sign-provenance.sh new file mode 100755 index 00000000..056e75c2 --- /dev/null +++ b/scripts/sign-provenance.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "Usage: $0 [plugin.yaml path]" + exit 1 +fi + +artifact="$1" +signature="$2" +plugin_yaml="${3:-plugin.yaml}" + +if [ -z "${GPG_FINGERPRINT:-}" ]; then + echo "ERROR: GPG_FINGERPRINT is not set. Cannot sign provenance artifact." + exit 1 +fi + +filename="$(basename "$artifact")" +digest="$(sha256sum "$artifact" 2>/dev/null | cut -d' ' -f1 || shasum -a 256 "$artifact" | cut -d' ' -f1)" + +passphrase_file="$(mktemp)" +trap 'rm -f "$passphrase_file"' EXIT +printf '%s' "${GPG_PASSPHRASE:-}" > "$passphrase_file" +chmod 600 "$passphrase_file" + +{ + cat "$plugin_yaml" + printf '...\n' + printf 'files:\n %s: "sha256:%s"\n' "$filename" "$digest" + # NOTE: The ...\n separator is required by Helm's provenance parser. + # See helm/helm pkg/provenance/sign.go: parseMessageBlock splits on "\n...\n" + # and messageBlock writes the same separator between metadata and checksums. +} | gpg --batch --yes --armor --pinentry-mode loopback \ + --passphrase-file "$passphrase_file" \ + --local-user "$GPG_FINGERPRINT" \ + --clearsign --output "$signature"