From 371cda6132ebd41bbad96213f0eea81a97ed9128 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:45:12 -0400 Subject: [PATCH] Gate signed provider publication Require protected release approval before importing the Registry key, signing the prepared checksum, and publishing retry-safe GitHub Release assets. --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 133 +++++++++++++++++++++++++++++-- .goreleaser.yml | 17 ---- CHANGELOG.md | 9 +-- SECURITY.md | 3 +- docs/architecture.md | 10 ++- docs/first-release.md | 2 +- docs/release.md | 60 ++++++++++---- scripts/check-release-tag.sh | 19 +++++ scripts/sign-release-checksum.sh | 35 ++++++++ scripts/test-release-scripts.sh | 83 +++++++++++++++++++ 11 files changed, 326 insertions(+), 48 deletions(-) create mode 100755 scripts/check-release-tag.sh create mode 100755 scripts/sign-release-checksum.sh create mode 100755 scripts/test-release-scripts.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0425ba4..27f1a8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,9 @@ jobs: - name: Check generated docs run: bash scripts/check-docs.sh + - name: Test release scripts + run: bash scripts/test-release-scripts.sh + - name: Set up GoReleaser uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42a1422..7508276 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,26 @@ -name: Release preparation +name: Release on: push: tags: - "v*" + workflow_dispatch: + inputs: + version: + description: Stable version to prepare without publishing + required: true + type: string permissions: contents: read +env: + RELEASE_TAG: ${{ inputs.version || github.ref_name }} + +defaults: + run: + shell: bash + concurrency: group: release-${{ github.ref }} cancel-in-progress: false @@ -31,8 +44,13 @@ jobs: run: | test -s LICENSE - if [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "Release tags must use stable semantic version format vMAJOR.MINOR.PATCH." >&2 + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Release versions must use stable semantic version format vMAJOR.MINOR.PATCH." >&2 + exit 1 + fi + + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "Manual release preflight must run from main." >&2 exit 1 fi @@ -47,6 +65,15 @@ jobs: exit 1 fi + - name: Create local preflight tag + if: github.event_name == 'workflow_dispatch' + run: | + if git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then + echo "Release version ${RELEASE_TAG} already exists." >&2 + exit 1 + fi + git tag "$RELEASE_TAG" "$GITHUB_SHA" + - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -60,18 +87,26 @@ jobs: version: v2.17.0 args: release --clean --skip=publish,sign env: - GORELEASER_CURRENT_TAG: ${{ github.ref_name }} + GORELEASER_CURRENT_TAG: ${{ env.RELEASE_TAG }} - name: Verify release artifacts run: | - version="${GITHUB_REF_NAME#v}" + version="${RELEASE_TAG#v}" cp terraform-registry-manifest.json "dist/terraform-provider-kernel_${version}_manifest.json" bash scripts/check-release-artifacts.sh "$version" + - name: Verify signing configuration + if: github.event_name == 'workflow_dispatch' + env: + EXPECTED_GPG_FINGERPRINT: ${{ vars.GPG_FINGERPRINT }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + PASSPHRASE: ${{ secrets.PASSPHRASE }} + run: bash scripts/sign-release-checksum.sh "${RELEASE_TAG#v}" + - name: Upload release artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-${{ github.ref_name }}-${{ github.run_id }} + name: release-${{ env.RELEASE_TAG }}-${{ github.run_id }} path: | dist/*.zip dist/*_manifest.json @@ -80,3 +115,89 @@ jobs: retention-days: 7 compression-level: 0 overwrite: true + + publish: + name: Sign and publish release + if: github.event_name == 'push' + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Download release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-${{ github.ref_name }}-${{ github.run_id }} + path: dist + + - name: Check publication preconditions + env: + GH_TOKEN: ${{ github.token }} + run: | + release="$( + gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" | + jq -c --arg tag "$GITHUB_REF_NAME" '[.[][] | select(.tag_name == $tag)][0] // empty' + )" + if [ -n "$release" ]; then + if [ "$(jq -r .draft <<<"$release")" = "true" ]; then + echo "An existing draft release must be inspected and removed before retrying ${GITHUB_REF_NAME}." >&2 + else + echo "A published release already exists for ${GITHUB_REF_NAME}." >&2 + fi + exit 1 + fi + + version="${GITHUB_REF_NAME#v}" + checksum="dist/terraform-provider-kernel_${version}_SHA256SUMS" + test -s "$checksum" + (cd dist && sha256sum --check "$(basename "$checksum")") + + - name: Sign and verify checksums + env: + EXPECTED_GPG_FINGERPRINT: ${{ vars.GPG_FINGERPRINT }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + PASSPHRASE: ${{ secrets.PASSPHRASE }} + run: bash scripts/sign-release-checksum.sh "${GITHUB_REF_NAME#v}" + + - name: Create draft release + env: + GH_TOKEN: ${{ github.token }} + run: | + version="${GITHUB_REF_NAME#v}" + first_release_url="https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/docs/first-release.md" + manifest="dist/terraform-provider-kernel_${version}_manifest.json" + checksum="dist/terraform-provider-kernel_${version}_SHA256SUMS" + signature="${checksum}.sig" + notes=() + if [ "$GITHUB_REF_NAME" = "v0.0.1" ]; then + notes=(--notes "First-release guidance: [supported surface and imports](${first_release_url}).") + fi + + bash scripts/check-release-tag.sh "$GITHUB_REF_NAME" "$GITHUB_SHA" + + gh release create "$GITHUB_REF_NAME" \ + dist/terraform-provider-kernel_"${version}"_*.zip \ + "$manifest" \ + "$checksum" \ + "$signature" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --draft \ + --generate-notes \ + "${notes[@]}" \ + --title "$GITHUB_REF_NAME" + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + bash scripts/check-release-tag.sh "$GITHUB_REF_NAME" "$GITHUB_SHA" + + gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false diff --git a/.goreleaser.yml b/.goreleaser.yml index d9f9456..4f1e36b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -45,22 +45,5 @@ checksum: - glob: terraform-registry-manifest.json name_template: "{{ .ProjectName }}_{{ .Version }}_manifest.json" -signs: - - artifacts: checksum - signature: "${artifact}.sig" - args: - - --batch - - --local-user - - "{{ .Env.GPG_FINGERPRINT }}" - - --output - - ${signature} - - --detach-sign - - ${artifact} - -release: - extra_files: - - glob: terraform-registry-manifest.json - name_template: "{{ .ProjectName }}_{{ .Version }}_manifest.json" - changelog: disable: true diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f5141..2c490f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ All notable changes to the Kernel Terraform provider are recorded here. -## Unreleased +## v0.0.1 -First public release candidate: +First public release: - Provider configuration for `api_key`, `base_url`, and `project_id`. - `kernel_project` and `kernel_browser_pool` resources for durable desired state. @@ -20,6 +20,5 @@ Intentionally not included: - API key, profile, proxy, extension, deployment, or app resources. - `force_destroy` browser-pool deletion. -There is no upgrade or migration path from an earlier published version because -this repository has no published provider tags. See the -[first public release guide](docs/first-release.md). +v0.0.1 has no upgrade or migration path from an earlier published version. See +the [first public release guide](docs/first-release.md). diff --git a/SECURITY.md b/SECURITY.md index bb29ffb..72a3c3d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,8 @@ ## Supported Versions -The provider has no public release yet; the first public release ships as a complete v1, and v0 tags stay internal. Before the first public release, confirm which released versions receive security fixes and update this section if support differs from latest-only. +Security fixes are provided for the latest released version. Before the first +release, this policy applies to the `main` branch. ## Reporting Security Issues diff --git a/docs/architecture.md b/docs/architecture.md index e32acae..8aecbad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Kernel Terraform Provider Architecture -This document records the durable-only architecture, the implemented v0 baseline, and the target scope for the first public v1 of the Kernel Terraform provider. +This document records the durable-only architecture, the initial public v0.0.1 surface, and the target scope for a future v1 of the Kernel Terraform provider. ## First Principles @@ -25,8 +25,9 @@ Provider configuration: - optional `base_url` - optional `project_id` -Resource: +Resources: +- `kernel_project` - `kernel_browser_pool` Data sources: @@ -38,11 +39,12 @@ Data sources: Import: +- `kernel_project` imports by canonical project ID. - `kernel_browser_pool` imports by canonical browser pool ID, optionally qualified as `/`. ## v1 Target Scope -The first public v1 should make durable Kernel configuration production-ready without turning Terraform into a runtime control plane. Core items are release-blocking unless the release notes explicitly defer them with an upstream API or SDK blocker. +A future v1 should broaden production-ready durable Kernel configuration without turning Terraform into a runtime control plane. Resources require stable identity, refresh, delete, import, and, where applicable, project-scoping and sensitive-state semantics. Data sources require stable identity, deterministic exact lookup, and, where applicable, masked sensitive metadata, pagination, and project scoping. Tooling experiments require deterministic regeneration and must preserve the handwritten lifecycle boundary. @@ -268,4 +270,4 @@ Release checklist: - Runtime operations are absent from Terraform resources. - Import behavior is documented. - API and SDK blockers are either resolved or explicitly deferred. -- Release process, signing, licensing, and versioning are complete before the first public v1 publication. +- Release process, signing, licensing, and versioning are complete before the first public v0.0.1 publication. diff --git a/docs/first-release.md b/docs/first-release.md index d79db81..f2239d7 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -1,6 +1,6 @@ # First Public Release -The first published Kernel Terraform provider version will be v1. This +The first published Kernel Terraform provider version is v0.0.1. This repository has no earlier published tags, so this release has no provider upgrade or state migration path. diff --git a/docs/release.md b/docs/release.md index 972bedd..80c08e5 100644 --- a/docs/release.md +++ b/docs/release.md @@ -4,8 +4,8 @@ Use this checklist before publishing a Kernel Terraform provider version. ## Release Preconditions -- The repository has no published provider tags. Present v1 as the first public release, not as an upgrade or migration from v0. -- Review the [first public release guide](first-release.md) and include its supported-surface and import guidance in the release notes. +- For v0.0.1, present it as the first public release, not as an upgrade or migration from an earlier provider version. +- For v0.0.1, review the [first public release guide](first-release.md) and include its supported-surface and import guidance in the release notes. - Work from a clean `main` checkout after the PR stack is merged. - Run `bash scripts/check-docs.sh`. - Run `bash scripts/check-markdown-links.sh`. @@ -28,12 +28,26 @@ Use this checklist before publishing a Kernel Terraform provider version. - Verify unscoped API calls send no `X-Kernel-Project-Id` header; it is sent only when a resource-level `project_id` or the provider default resolves a project. - Confirm `terraform-registry-manifest.json` contains protocol `["6.0"]` for Terraform Plugin Framework. - Confirm `LICENSE` contains the approved Apache License 2.0 text. +- Confirm immutable GitHub Releases are enabled for the repository. The + publication job intentionally has no repository-administration permission to + inspect or change this setting. - Confirm GitHub private vulnerability reporting or a public security contact is configured and reflected in `SECURITY.md`. -- Confirm there is no branch named like the release tag, for example `v1.0.0`. - +- Store `GPG_PRIVATE_KEY` and `PASSPHRASE` as repository Actions secrets. Set + the repository Actions variable `GPG_FINGERPRINT` to the fingerprint + registered with the Terraform Registry. +- Add a repository ruleset that restricts creation, update, and deletion of + `v*` tags to the Kernel engineering team. Inspect the ruleset's bypass list + before releasing; do not allow repository roles, outside collaborators, or + organization administrators to bypass it. Ruleset configuration is an + administrator-owned setup requirement, not a workflow runtime check. +- GitHub repository writers can create Releases through the API; GitHub does not + provide a separate release-publisher role. Treat every account with repository + write access as release-authorized and keep that group limited to Kernel + engineers. The tag ruleset remains the control that authorizes a release + workflow run. ## Registry Release Assets -Terraform Registry provider releases are GitHub Releases with semver tags prefixed by `v`, such as `v1.0.0`. +Terraform Registry provider releases are GitHub Releases with semver tags prefixed by `v`, such as `v0.0.1`. Each release must include: @@ -58,16 +72,34 @@ Do not replace or mutate assets for a published version. If an asset, checksum, ## GoReleaser Notes - `.goreleaser.yml` is the source of truth for registry artifact names, target - platforms, checksums, manifest inclusion, and checksum signing. + platforms, checksums, and manifest inclusion. The release workflow owns + checksum signing and publication. - Normal CI validates the GoReleaser configuration and registry manifest without building the complete platform matrix. -- `.github/workflows/release.yml` prepares unsigned, unpublished assets for - stable `vMAJOR.MINOR.PATCH` tags after confirming the repository is public - and the tag commit is reachable from `main`. It pins GoReleaser to the pushed - tag, verifies the release contract, and retains the assets for seven days. -- Real releases sign the checksum file once with the GPG key selected by - `GPG_FINGERPRINT`. The detached signature is named by appending `.sig` to the - checksum filename. Publication remains a separate release step. +- `.github/workflows/release.yml` runs for `v*` tags. Its preparation job has + read-only repository access and accepts only stable `vMAJOR.MINOR.PATCH` + versions. It requires the Apache 2.0 license, public repository visibility, + and a commit reachable from `main`, then builds and verifies the unsigned + assets. The workflow artifact is retained for seven days. Only the + tag-triggered publication job receives `contents: write`. +- Before creating a tag, run the workflow manually with the intended version. + Manual runs create an unpushed tag only inside the ephemeral runner, build + the same unsigned assets, and exercise checksum and GPG signing with + `contents: read`. They never create a remote tag or GitHub Release. +- For a tag-triggered release, confirm the acceptance matrix passed and the tag + ruleset's bypass list still contains only the Kernel engineering team before + creating the tag. The job revalidates the tag and checksums, requires the + imported key to match `GPG_FINGERPRINT`, signs the checksum file, and publishes + the GitHub Release. +- Failed-job reruns reuse the prepared artifact from the same workflow run. A + full rerun replaces that run's artifact. If publication fails or is + interrupted, it may leave a draft. Any existing draft stops retries until a + Kernel engineer inspects and removes it manually. An existing published + release always stops the workflow. +- For `v0.0.1`, GitHub includes the tagged + [first public release guide](first-release.md) with the generated release + notes. Later versions use generated release notes without first-release + guidance. ## Registry Setup @@ -96,4 +128,4 @@ References: - HashiCorp Terraform provider publishing: https://developer.hashicorp.com/terraform/registry/providers/publishing - HashiCorp provider registry protocol: https://developer.hashicorp.com/terraform/internals/provider-registry-protocol -- GoReleaser checksum signing: https://goreleaser.com/customization/sign/ +- GitHub rulesets: https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets diff --git a/scripts/check-release-tag.sh b/scripts/check-release-tag.sh new file mode 100755 index 0000000..45e81a3 --- /dev/null +++ b/scripts/check-release-tag.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +tag="${1:?usage: check-release-tag.sh TAG EXPECTED_COMMIT}" +expected="${2:?usage: check-release-tag.sh TAG EXPECTED_COMMIT}" +ref="refs/tags/${tag}" + +actual="$( + git ls-remote origin "$ref" "${ref}^{}" | + awk -v ref="$ref" ' + $2 == ref { direct = $1 } + $2 == ref "^{}" { peeled = $1 } + END { print (peeled != "" ? peeled : direct) } + ' +)" +if [ "$actual" != "$expected" ]; then + echo "Release tag ${tag} no longer points to the workflow commit." >&2 + exit 1 +fi diff --git a/scripts/sign-release-checksum.sh b/scripts/sign-release-checksum.sh new file mode 100755 index 0000000..b54cbb4 --- /dev/null +++ b/scripts/sign-release-checksum.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version="${1:?usage: sign-release-checksum.sh VERSION}" + +: "${EXPECTED_GPG_FINGERPRINT:?Set GPG_FINGERPRINT as a repository Actions variable before releasing.}" +: "${GPG_PRIVATE_KEY:?Set GPG_PRIVATE_KEY as a repository Actions secret before releasing.}" +: "${PASSPHRASE:?Set PASSPHRASE as a repository Actions secret before releasing.}" + +expected="$(printf '%s' "$EXPECTED_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')" +key_details="$( + printf '%s' "$GPG_PRIVATE_KEY" | + gpg --batch --with-colons --import-options show-only --import 2>/dev/null +)" +fingerprints="$( + awk -F: ' + $1 == "sec" { primary = 1; next } + primary && $1 == "fpr" { print $10; primary = 0 } + ' <<<"$key_details" +)" +fingerprint_count="$(printf '%s\n' "$fingerprints" | sed '/^$/d' | wc -l | tr -d ' ')" +if [ "$fingerprint_count" -ne 1 ] || [ "$fingerprints" != "$expected" ]; then + echo "The private key must contain exactly the Registry signing key." >&2 + exit 1 +fi + +printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import + +checksum="$root/dist/terraform-provider-kernel_${version}_SHA256SUMS" +test -s "$checksum" +printf '%s' "$PASSPHRASE" | + gpg --batch --pinentry-mode loopback --passphrase-fd 0 \ + --local-user "$expected" --output "${checksum}.sig" --detach-sign "$checksum" +gpg --verify "${checksum}.sig" "$checksum" diff --git a/scripts/test-release-scripts.sh b/scripts/test-release-scripts.sh new file mode 100755 index 0000000..38bc5e7 --- /dev/null +++ b/scripts/test-release-scripts.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmpdir="$(mktemp -d)" +checksum="$root/dist/terraform-provider-kernel_test_SHA256SUMS" + +cleanup() { + rm -rf "$tmpdir" + rm -f "$checksum" "${checksum}.sig" +} +trap cleanup EXIT + +remote="$tmpdir/remote.git" +work="$tmpdir/work" +git init --bare "$remote" >/dev/null +git init "$work" >/dev/null +git -C "$work" config user.name test +git -C "$work" config user.email test@example.invalid +git -C "$work" commit --allow-empty -m first >/dev/null +first_commit="$(git -C "$work" rev-parse HEAD)" +git -C "$work" tag v0.0.1 +git -C "$work" tag -a v0.0.2 -m annotated +git -C "$work" remote add origin "$remote" +git -C "$work" push origin --tags >/dev/null + +( + cd "$work" + bash "$root/scripts/check-release-tag.sh" v0.0.1 "$first_commit" + bash "$root/scripts/check-release-tag.sh" v0.0.2 "$first_commit" + if bash "$root/scripts/check-release-tag.sh" v0.0.3 "$first_commit" >/dev/null 2>&1; then + echo "Missing release tag unexpectedly passed." >&2 + exit 1 + fi + + git commit --allow-empty -m second >/dev/null + git tag --force v0.0.1 + git push --force origin v0.0.1 >/dev/null + if bash "$root/scripts/check-release-tag.sh" v0.0.1 "$first_commit" >/dev/null 2>&1; then + echo "Moved release tag unexpectedly passed." >&2 + exit 1 + fi +) + +passphrase="release-script-test" +generate_home="$tmpdir/generate-gnupg" +sign_home="$tmpdir/sign-gnupg" +mkdir -m 700 "$generate_home" "$sign_home" + +GNUPGHOME="$generate_home" gpg --batch --pinentry-mode loopback \ + --passphrase "$passphrase" \ + --quick-generate-key "Release Test " rsa2048 sign 0 +fingerprint="$( + GNUPGHOME="$generate_home" gpg --batch --with-colons --list-secret-keys | + awk -F: '$1 == "fpr" { print $10; exit }' +)" +private_key="$( + printf '%s' "$passphrase" | + GNUPGHOME="$generate_home" gpg --batch --pinentry-mode loopback \ + --passphrase-fd 0 --armor --export-secret-keys "$fingerprint" +)" + +mkdir -p "$root/dist" +printf '%s\n' "release script test" >"$checksum" +if GNUPGHOME="$sign_home" \ + EXPECTED_GPG_FINGERPRINT=0000000000000000000000000000000000000000 \ + GPG_PRIVATE_KEY="$private_key" \ + PASSPHRASE="$passphrase" \ + bash "$root/scripts/sign-release-checksum.sh" test >"$tmpdir/fingerprint-mismatch.out" 2>&1; then + echo "Mismatched signing fingerprint unexpectedly passed." >&2 + exit 1 +fi +grep -F "The private key must contain exactly the Registry signing key." \ + "$tmpdir/fingerprint-mismatch.out" >/dev/null +test ! -e "${checksum}.sig" + +GNUPGHOME="$sign_home" \ + EXPECTED_GPG_FINGERPRINT="$fingerprint" \ + GPG_PRIVATE_KEY="$private_key" \ + PASSPHRASE="$passphrase" \ + bash "$root/scripts/sign-release-checksum.sh" test + +GNUPGHOME="$sign_home" gpg --batch --verify "${checksum}.sig" "$checksum"