diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5efaa6e..d8372b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ env: GO_VERSION: '1.26' jobs: + # ── Quality gates (run on every push and PR) ─────────────────────────────── + lint: name: Lint runs-on: ubuntu-latest @@ -77,123 +79,167 @@ jobs: go install golang.org/x/vuln/cmd/govulncheck@latest govulncheck ./... - build: - name: Build (${{ matrix.os }}_${{ matrix.arch }}) - runs-on: ubuntu-latest - needs: [lint, security-scan, test] - strategy: - matrix: - include: - - os: linux - arch: amd64 - - os: linux - arch: arm64 - - os: darwin - arch: amd64 - - os: darwin - arch: arm64 - - os: windows - arch: amd64 - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - - - name: Build binary - env: - GOOS: ${{ matrix.os }} - GOARCH: ${{ matrix.arch }} - CGO_ENABLED: '0' - run: | - EXT="" - if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi - mkdir -p dist - go build -trimpath -ldflags="-X main.version=$(sed -n 's/version:.*"\(.*\)"/\1/p' plugin.yaml)" \ - -o "dist/helm-oci${EXT}" . - - - name: Package archive - env: - MATRIX_OS: ${{ matrix.os }} - MATRIX_ARCH: ${{ matrix.arch }} - run: | - mkdir -p release/helm-oci/bin - cp plugin.yaml install-binary.sh release/helm-oci/ - EXT="" - if [ "$MATRIX_OS" = "windows" ]; then EXT=".exe"; fi - cp "dist/helm-oci${EXT}" release/helm-oci/bin/ - tar -C release -zcvf "helm-oci-${MATRIX_OS}-${MATRIX_ARCH}.tgz" helm-oci/ - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: helm-oci-${{ matrix.os }}-${{ matrix.arch }} - path: helm-oci-${{ matrix.os }}-${{ matrix.arch }}.tgz + # ── Release (v* tags only) ───────────────────────────────────────────────── + # + # GoReleaser handles the core release: + # - Cross-compiles all 5 platform binaries (linux/amd64, linux/arm64, + # darwin/amd64, darwin/arm64, windows/amd64) + # - Packages each binary into a helm-oci--.tgz archive whose + # directory layout (helm-oci/bin/, helm-oci/plugin.yaml, …) is exactly + # what install-binary.sh expects + # - Generates a sha256 checksum file + # - GPG-signs the checksum file (--detach-sign --armor → .asc) + # - Creates the GitHub Release and uploads all artifacts + # + # Post-GoReleaser steps then: + # - Run `helm plugin package --sign` on each platform archive to produce + # Helm-native provenance (.prov) files (requires Helm 4) + # - Validate every .prov file with `helm plugin verify` before publishing + # - Upload the .prov files, Helm-packaged .tgz files, and the public + # signing key to the GitHub Release so consumers can run: + # helm plugin verify oci-.tgz release: name: Release runs-on: ubuntu-latest - needs: [build] + needs: [lint, test, security-scan] if: startsWith(github.ref, 'refs/tags/v') permissions: - contents: write + contents: write # create GitHub releases and upload assets + steps: - name: Checkout code uses: actions/checkout@v4 - - - name: Download all build artifacts - uses: actions/download-artifact@v4 with: - path: dist - pattern: helm-oci-* - merge-multiple: true + fetch-depth: 0 # GoReleaser needs full history for changelog - - name: Install Helm - uses: azure/setup-helm@v4 + - name: Set up Go + uses: actions/setup-go@v5 with: - version: 'latest' + go-version: ${{ env.GO_VERSION }} - - name: Package and sign plugin + # Import the GPG key before GoReleaser runs so the `signs:` block can + # invoke gpg with the key already present in the agent's keyring. + - name: Import GPG key env: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} run: | echo "$GPG_PRIVATE_KEY" | gpg --batch --import - gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" \ - --export-secret-keys "$GPG_KEY_ID" > /tmp/secring.gpg - - mkdir -p staging/oci - cp plugin.yaml install-binary.sh staging/oci/ + # Export the secret key to a temporary keyring so helm can use it + # in the plugin-package step below (helm requires a legacy keyring). + gpg --batch --pinentry-mode loopback \ + --passphrase "$GPG_PASSPHRASE" \ + --export-secret-keys "${{ secrets.GPG_KEY_ID }}" \ + > /tmp/secring.gpg + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} - echo "$GPG_PASSPHRASE" | helm plugin package \ - staging/oci/ \ - --destination dist/ \ - --key "$GPG_KEY_ID" \ - --keyring /tmp/secring.gpg \ - --passphrase-file - + # Install Helm 4 so we can use `helm plugin package --sign` and + # `helm plugin verify` — both are Helm 4-only commands. + - name: Install Helm 4 + run: | + curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 \ + | DESIRED_VERSION=v4.0.0 bash + + # For each GoReleaser-produced platform archive, extract the inner + # helm-oci/ plugin directory and run `helm plugin package --sign` against + # it. This produces a Helm-native tarball + .prov provenance file for + # every platform that users can verify with `helm plugin verify`. + # + # The passphrase is written to a temp file so it never appears in + # process arguments or logs, and cleaned up immediately afterwards. + - name: Sign plugin archives with helm plugin package + env: + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + # Write the passphrase to a temp file; helm reads it with --passphrase-file. + PASSPHRASE_FILE="$(mktemp)" + printf '%s' "$GPG_PASSPHRASE" > "$PASSPHRASE_FILE" + trap 'rm -f "$PASSPHRASE_FILE"' EXIT + + mkdir -p helm-provenance + + # Platforms matching the GoReleaser build matrix (windows/arm64 is excluded). + PLATFORMS="linux-amd64 linux-arm64 darwin-amd64 darwin-arm64 windows-amd64" + + for PLATFORM in $PLATFORMS; do + ARCHIVE="dist/helm-oci-${PLATFORM}.tgz" + if [ ! -f "$ARCHIVE" ]; then + echo "Warning: $ARCHIVE not found, skipping" + continue + fi + + WORK_DIR="$(mktemp -d)" + trap 'rm -rf "$WORK_DIR"' EXIT + + # Extract the archive; GoReleaser wraps everything under helm-oci/. + tar -xzf "$ARCHIVE" -C "$WORK_DIR" + + # Package and sign the extracted plugin directory. + # helm plugin package reads the plugin version from plugin.yaml, + # so the output tarball is named oci-.tgz (plugin name from manifest). + helm plugin package \ + --sign \ + --key "$GPG_KEY_ID" \ + --keyring /tmp/secring.gpg \ + --passphrase-file "$PASSPHRASE_FILE" \ + --destination helm-provenance/ \ + "$WORK_DIR/helm-oci" + + echo "Packaged and signed $PLATFORM" + done + + # Verify every provenance file we just produced before uploading anything. + # This catches key misconfiguration or packaging errors before they reach + # end users. + - name: Verify helm plugin provenance files + run: | + set -euo pipefail + for PROV in helm-provenance/*.prov; do + TARBALL="${PROV%.prov}" + echo "Verifying $TARBALL ..." + helm plugin verify --keyring ~/.gnupg/pubring.gpg "$TARBALL" + done + echo "All provenance files verified successfully." + + # Export the public key and attach it to the GitHub Release so consumers + # can import it and verify without hunting for the key. + - name: Upload release assets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail - gpg --batch --yes --armor --export "$GPG_KEY_ID" > dist/helm-oci-signing-key.asc + gpg --batch --yes --armor \ + --export "${{ secrets.GPG_KEY_ID }}" \ + > helm-oci-signing-key.asc rm -f /tmp/secring.gpg - - name: Get version - id: version - run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - - name: Create release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.version.outputs.VERSION }} - run: | - TITLE=$(git tag -l --format='%(contents:subject)' "$TAG") - gh release create "$TAG" \ - --title "${TITLE:-Release $TAG}" \ - --generate-notes \ - dist/helm-oci-*.tgz \ - dist/oci-*.tgz \ - dist/oci-*.tgz.prov \ - dist/helm-oci-signing-key.asc + # Upload the public signing key. + gh release upload "$TAG" \ + helm-oci-signing-key.asc \ + --clobber + + # Upload all Helm-native tarballs and their provenance files. + # These are separate from the GoReleaser archives and are what + # `helm plugin verify` operates on. + gh release upload "$TAG" \ + helm-provenance/*.tgz \ + helm-provenance/*.prov \ + --clobber diff --git a/.gitignore b/.gitignore index 03b558c..1ccf55e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin/ build/ release/ +dist diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..9aeb5b4 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,171 @@ +version: 2 + +# ── Project ─────────────────────────────────────────────────────────────────── +project_name: helm-oci + +before: + hooks: + - go mod tidy + - go mod verify + +# ── Build ───────────────────────────────────────────────────────────────────── +builds: + - id: helm-oci + main: . + binary: helm-oci + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + # Exclude unsupported combinations. + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + flags: + - -trimpath + mod_timestamp: "{{ .CommitTimestamp }}" + +# ── Archives ────────────────────────────────────────────────────────────────── +# Each archive must unpack as helm-oci/ so that install-binary.sh can do: +# tar xzf helm-oci-linux-amd64.tgz +# and find helm-oci/bin/helm-oci, helm-oci/plugin.yaml, helm-oci/install-binary.sh. +# wrap_in_directory names the top-level directory after the archive name_template; +# since we want "helm-oci" (not the versioned archive name) we set it explicitly. +archives: + - id: helm-oci + ids: + - helm-oci + formats: ["tgz"] + name_template: "helm-oci-{{ .Os }}-{{ .Arch }}" + # Wraps all archive contents inside a "helm-oci/" directory, which is what + # install-binary.sh expects when it untars the downloaded archive. + wrap_in_directory: "helm-oci" + # Windows gets a zip so PowerShell users can expand it natively; the inner + # directory layout remains the same. + format_overrides: + - goos: windows + formats: ["zip"] + # Include the plugin manifest and install hook alongside the binary so the + # archive is a self-contained Helm plugin directory. + files: + - plugin.yaml + - install-binary.sh + - README.md + - LICENSE.md + +# ── Checksums ───────────────────────────────────────────────────────────────── +checksum: + name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" + algorithm: sha256 + +# ── Signing ─────────────────────────────────────────────────────────────────── +# GoReleaser signs the checksum file (and therefore transitively covers all +# platform archives) using the GPG key supplied via CI secrets. +# Helm-native provenance (.prov) files are produced by a post-GoReleaser CI +# step using `helm plugin package --sign` and verified with `helm plugin verify` +# before being uploaded to the GitHub Release alongside the GoReleaser archives. +signs: + - id: gpg-sign + # Sign only the checksum file; individual archives are covered by it. + artifacts: checksum + args: + - "--batch" + - "--yes" + - "--pinentry-mode" + - "loopback" + - "--passphrase" + - "{{ .Env.GPG_PASSPHRASE }}" + - "--local-user" + - "{{ .Env.GPG_KEY_ID }}" + - "--output" + - "${signature}" + - "--detach-sign" + - "--armor" + - "${artifact}" + +# ── GitHub Release ──────────────────────────────────────────────────────────── +release: + github: + owner: esnet + name: helm-oci + draft: false + prerelease: auto + name_template: "{{ .ProjectName }} {{ .Tag }}" + header: | + ## helm-oci {{ .Tag }} + + Helm plugin that lets you bookmark OCI chart URLs and use them by name. + + ### Install / upgrade + + ```bash + helm plugin install https://github.com/esnet/helm-oci/releases/download/{{ .Tag }}/helm-oci-linux-amd64.tgz + ``` + + Or let Helm fetch the latest release automatically: + + ```bash + helm plugin install https://github.com/esnet/helm-oci + ``` + footer: | + **Full Changelog**: https://github.com/esnet/helm-oci/compare/{{ .PreviousTag }}...{{ .Tag }} + + ### Verify + + #### Using Helm (recommended, requires Helm 4) + + Download the signed tarball and its provenance file for your platform, then verify: + + ```bash + helm plugin verify oci-{{ .Version }}.tgz + ``` + + Import the signing key first if Helm reports an unknown key: + + ```bash + gpg --import helm-oci-signing-key.asc + helm plugin verify oci-{{ .Version }}.tgz + ``` + + #### Using GPG + sha256sum + + Import the signing key and verify the checksum file covers the GoReleaser archives: + + ```bash + gpg --import helm-oci-signing-key.asc + gpg --verify {{ .ProjectName }}_{{ .Version }}_checksums.txt.asc {{ .ProjectName }}_{{ .Version }}_checksums.txt + sha256sum -c {{ .ProjectName }}_{{ .Version }}_checksums.txt + ``` + +# ── Changelog ───────────────────────────────────────────────────────────────── +changelog: + sort: asc + use: github + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - Merge pull request + - Merge branch + groups: + - title: Features + regexp: "^.*feat.*:.*$" + order: 0 + - title: Bug Fixes + regexp: "^.*fix.*:.*$" + order: 1 + - title: Performance + regexp: "^.*perf.*:.*$" + order: 2 + - title: Other + order: 999 diff --git a/Makefile b/Makefile index d56a735..dd56af9 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,10 @@ uninstall: clean: rm -rf bin/ +.PHONY: snapshot +snapshot: + goreleaser release --snapshot --clean --skip=sign,publish + .PHONY: dist dist: export CGO_ENABLED=0 dist: