Skip to content

Commit 9e33a53

Browse files
committed
Gate signed provider publication
Require protected release approval before importing the Registry key, signing the prepared checksum, and publishing retry-safe GitHub Release assets.
1 parent 17d0c8a commit 9e33a53

2 files changed

Lines changed: 233 additions & 10 deletions

File tree

.github/workflows/release.yml

Lines changed: 190 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Release preparation
1+
name: Release
22

33
on:
44
push:
@@ -8,6 +8,10 @@ on:
88
permissions:
99
contents: read
1010

11+
defaults:
12+
run:
13+
shell: bash
14+
1115
concurrency:
1216
group: release-${{ github.ref }}
1317
cancel-in-progress: false
@@ -31,8 +35,8 @@ jobs:
3135
run: |
3236
test -s LICENSE
3337
34-
if [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
35-
echo "Release tags must use stable semantic version format vMAJOR.MINOR.PATCH." >&2
38+
if [[ ! "$GITHUB_REF_NAME" =~ ^v([1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
39+
echo "Release tags must use stable semantic version format vMAJOR.MINOR.PATCH with a major version of at least 1." >&2
3640
exit 1
3741
fi
3842
@@ -80,3 +84,186 @@ jobs:
8084
retention-days: 7
8185
compression-level: 0
8286
overwrite: true
87+
88+
publish:
89+
name: Sign and publish release
90+
needs: prepare
91+
runs-on: ubuntu-latest
92+
timeout-minutes: 10
93+
permissions:
94+
actions: read
95+
contents: write
96+
deployments: read
97+
environment:
98+
name: release
99+
100+
steps:
101+
- name: Check release protections
102+
env:
103+
GH_TOKEN: ${{ github.token }}
104+
RELEASE_RULESET_ID: ${{ vars.RELEASE_RULESET_ID }}
105+
run: |
106+
: "${RELEASE_RULESET_ID:?Set RELEASE_RULESET_ID on the release environment before releasing.}"
107+
108+
environment="$(gh api "repos/${GITHUB_REPOSITORY}/environments/release")"
109+
if ! jq -e '
110+
[.protection_rules[]? | select(.type == "required_reviewers")] as $rules |
111+
($rules | length) == 1 and
112+
$rules[0].prevent_self_review == true and
113+
($rules[0].reviewers | length) > 0 and
114+
.deployment_branch_policy == {
115+
protected_branches: false,
116+
custom_branch_policies: true
117+
}
118+
' <<<"$environment" >/dev/null; then
119+
echo "The release environment must require a reviewer, prevent self-review, and use a custom tag policy." >&2
120+
exit 1
121+
fi
122+
123+
deployment_policies="$(
124+
gh api --paginate --slurp \
125+
"repos/${GITHUB_REPOSITORY}/environments/release/deployment-branch-policies?per_page=100"
126+
)"
127+
if ! jq -e '
128+
[.[] | .branch_policies[] | {name, type}] == [{name: "v*", type: "tag"}]
129+
' <<<"$deployment_policies" >/dev/null; then
130+
echo "The release environment must allow only tags matching v*." >&2
131+
exit 1
132+
fi
133+
134+
ruleset="$(gh api "repos/${GITHUB_REPOSITORY}/rulesets/${RELEASE_RULESET_ID}")"
135+
if ! jq -e '
136+
.target == "tag" and
137+
.enforcement == "active" and
138+
(.conditions.ref_name.include | sort) == ["refs/tags/v*"] and
139+
.conditions.ref_name.exclude == [] and
140+
([.rules[].type] | index("creation") != null) and
141+
([.rules[].type] | index("update") != null) and
142+
([.rules[].type] | index("deletion") != null)
143+
' <<<"$ruleset" >/dev/null; then
144+
echo "The release ruleset must actively restrict v* tag creation, update, and deletion." >&2
145+
exit 1
146+
fi
147+
148+
- name: Download release artifacts
149+
env:
150+
GH_TOKEN: ${{ github.token }}
151+
run: |
152+
gh run download "$GITHUB_RUN_ID" \
153+
--repo "$GITHUB_REPOSITORY" \
154+
--name "release-${GITHUB_REF_NAME}-${GITHUB_RUN_ID}" \
155+
--dir dist
156+
157+
- name: Check publication preconditions
158+
env:
159+
GH_TOKEN: ${{ github.token }}
160+
run: |
161+
tag_commit="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_REF_NAME}" --jq .sha)"
162+
if [ "$tag_commit" != "$GITHUB_SHA" ]; then
163+
echo "The release tag no longer points to the workflow commit." >&2
164+
exit 1
165+
fi
166+
167+
release="$(
168+
gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" |
169+
jq -c --arg tag "$GITHUB_REF_NAME" '[.[][] | select(.tag_name == $tag)][0] // empty'
170+
)"
171+
if [ -n "$release" ]; then
172+
if [ "$(jq -r .draft <<<"$release")" = "true" ]; then
173+
echo "An existing draft release must be inspected and removed before retrying ${GITHUB_REF_NAME}." >&2
174+
else
175+
echo "A published release already exists for ${GITHUB_REF_NAME}." >&2
176+
fi
177+
exit 1
178+
fi
179+
180+
version="${GITHUB_REF_NAME#v}"
181+
checksum="dist/terraform-provider-kernel_${version}_SHA256SUMS"
182+
test -s "$checksum"
183+
(cd dist && sha256sum --check "$(basename "$checksum")")
184+
185+
- name: Check signing configuration
186+
env:
187+
EXPECTED_GPG_FINGERPRINT: ${{ vars.GPG_FINGERPRINT }}
188+
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
189+
PASSPHRASE: ${{ secrets.PASSPHRASE }}
190+
run: |
191+
: "${EXPECTED_GPG_FINGERPRINT:?Set GPG_FINGERPRINT on the release environment before releasing.}"
192+
: "${GPG_PRIVATE_KEY:?Set GPG_PRIVATE_KEY on the release environment before releasing.}"
193+
: "${PASSPHRASE:?Set PASSPHRASE on the release environment before releasing.}"
194+
195+
- name: Import GPG key
196+
env:
197+
EXPECTED_GPG_FINGERPRINT: ${{ vars.GPG_FINGERPRINT }}
198+
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
199+
run: |
200+
expected="$(printf '%s' "$EXPECTED_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')"
201+
key_details="$(
202+
printf '%s' "$GPG_PRIVATE_KEY" |
203+
gpg --batch --with-colons --import-options show-only --import 2>/dev/null
204+
)"
205+
mapfile -t fingerprints < <(
206+
awk -F: '
207+
$1 == "sec" { primary = 1; next }
208+
primary && $1 == "fpr" { print $10; primary = 0 }
209+
' <<<"$key_details"
210+
)
211+
if [ "${#fingerprints[@]}" -ne 1 ] || [ "${fingerprints[0]}" != "$expected" ]; then
212+
echo "The private key must contain exactly the Registry signing key." >&2
213+
exit 1
214+
fi
215+
216+
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
217+
218+
- name: Sign checksums
219+
env:
220+
EXPECTED_GPG_FINGERPRINT: ${{ vars.GPG_FINGERPRINT }}
221+
PASSPHRASE: ${{ secrets.PASSPHRASE }}
222+
run: |
223+
expected="$(printf '%s' "$EXPECTED_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')"
224+
version="${GITHUB_REF_NAME#v}"
225+
checksum="dist/terraform-provider-kernel_${version}_SHA256SUMS"
226+
printf '%s' "$PASSPHRASE" |
227+
gpg --batch --pinentry-mode loopback --passphrase-fd 0 \
228+
--local-user "$expected" --output "${checksum}.sig" --detach-sign "$checksum"
229+
gpg --verify "${checksum}.sig" "$checksum"
230+
231+
- name: Create draft release
232+
env:
233+
GH_TOKEN: ${{ github.token }}
234+
run: |
235+
version="${GITHUB_REF_NAME#v}"
236+
first_release_url="https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/docs/first-release.md"
237+
manifest="dist/terraform-provider-kernel_${version}_manifest.json"
238+
checksum="dist/terraform-provider-kernel_${version}_SHA256SUMS"
239+
signature="${checksum}.sig"
240+
241+
tag_commit="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_REF_NAME}" --jq .sha)"
242+
if [ "$tag_commit" != "$GITHUB_SHA" ]; then
243+
echo "The release tag no longer points to the workflow commit." >&2
244+
exit 1
245+
fi
246+
247+
gh release create "$GITHUB_REF_NAME" \
248+
dist/terraform-provider-kernel_"${version}"_*.zip \
249+
"$manifest" \
250+
"$checksum" \
251+
"$signature" \
252+
--repo "$GITHUB_REPOSITORY" \
253+
--verify-tag \
254+
--draft \
255+
--generate-notes \
256+
--notes "First-release guidance: [supported surface and imports](${first_release_url})." \
257+
--title "$GITHUB_REF_NAME"
258+
259+
- name: Publish release
260+
env:
261+
GH_TOKEN: ${{ github.token }}
262+
run: |
263+
tag_commit="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_REF_NAME}" --jq .sha)"
264+
if [ "$tag_commit" != "$GITHUB_SHA" ]; then
265+
echo "The release tag no longer points to the workflow commit." >&2
266+
exit 1
267+
fi
268+
269+
gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false

docs/release.md

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,29 @@ Use this checklist before publishing a Kernel Terraform provider version.
2828
- 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.
2929
- Confirm `terraform-registry-manifest.json` contains protocol `["6.0"]` for Terraform Plugin Framework.
3030
- Confirm `LICENSE` contains the approved Apache License 2.0 text.
31+
- Confirm immutable GitHub Releases are enabled for the repository. The
32+
publication job intentionally has no repository-administration permission to
33+
inspect or change this setting.
3134
- Confirm GitHub private vulnerability reporting or a public security contact is configured and reflected in `SECURITY.md`.
35+
- Create a protected GitHub environment named `release`, require approval from
36+
the designated release owners, prevent self-review, and disable administrator
37+
bypass. Add exactly one deployment policy: tags matching `v*`. Store
38+
`GPG_PRIVATE_KEY` and `PASSPHRASE` as environment secrets, not repository
39+
secrets. Set the environment variable `GPG_FINGERPRINT` to the fingerprint
40+
registered with the Terraform Registry. GitHub's environment API does not
41+
report whether administrator bypass is disabled, so verify that setting when
42+
approving a release.
43+
- Add a repository ruleset that restricts creation, update, and deletion of
44+
`v*` tags to the designated release owners. Do not rely on general repository
45+
write access as release authority. Set the `release` environment variable
46+
`RELEASE_RULESET_ID` to that active ruleset's numeric ID. The workflow verifies
47+
the live environment reviewer, tag policy, and ruleset controls before
48+
touching signing configuration. Before setting the ID, inspect the ruleset's
49+
bypass list and require explicit release-owner teams or GitHub Apps; do not
50+
allow repository roles or organization administrators to bypass it. GitHub
51+
omits bypass actors from ruleset API responses unless the caller has ruleset
52+
write access, so the least-privilege workflow cannot verify their identities
53+
at runtime.
3254
- Confirm there is no branch named like the release tag, for example `v1.0.0`.
3355

3456
## Registry Release Assets
@@ -61,13 +83,26 @@ Do not replace or mutate assets for a published version. If an asset, checksum,
6183
platforms, checksums, manifest inclusion, and checksum signing.
6284
- Normal CI validates the GoReleaser configuration and registry manifest without
6385
building the complete platform matrix.
64-
- `.github/workflows/release.yml` prepares unsigned, unpublished assets for
65-
stable `vMAJOR.MINOR.PATCH` tags after confirming the repository is public
66-
and the tag commit is reachable from `main`. It pins GoReleaser to the pushed
67-
tag, verifies the release contract, and retains the assets for seven days.
68-
- Real releases sign the checksum file once with the GPG key selected by
69-
`GPG_FINGERPRINT`. The detached signature is named by appending `.sig` to the
70-
checksum filename. Publication remains a separate release step.
86+
- `.github/workflows/release.yml` runs for `v*` tags. Its preparation job has
87+
read-only repository access and accepts only stable `vMAJOR.MINOR.PATCH`
88+
versions with a major version of at least 1. It requires the Apache 2.0
89+
license, public repository visibility, and a tag commit reachable from
90+
`main`, then builds and verifies the unsigned assets. The workflow artifact is
91+
retained for seven days. Only the protected publication job receives
92+
`contents: write`.
93+
- Inspect the prepared artifact and confirm the acceptance matrix passed before
94+
approving the protected `release` job. Confirm the tag ruleset's bypass list
95+
still contains only designated release owners as part of that approval. After
96+
approval, the job revalidates the tag and checksums, requires the imported key
97+
to match `GPG_FINGERPRINT`, signs the checksum file, and publishes the GitHub
98+
Release.
99+
- Failed-job reruns reuse the prepared artifact from the same workflow run. A
100+
full rerun replaces that run's artifact and requires a fresh environment
101+
approval. If publication fails or is interrupted, it may leave a draft. Any
102+
existing draft stops retries until a release owner inspects and removes it
103+
manually. An existing published release always stops the workflow.
104+
- GitHub includes the tagged [first public release guide](first-release.md) with
105+
the generated release notes.
71106

72107
## Registry Setup
73108

@@ -97,3 +132,4 @@ References:
97132
- HashiCorp Terraform provider publishing: https://developer.hashicorp.com/terraform/registry/providers/publishing
98133
- HashiCorp provider registry protocol: https://developer.hashicorp.com/terraform/internals/provider-registry-protocol
99134
- GoReleaser checksum signing: https://goreleaser.com/customization/sign/
135+
- GitHub deployment environments: https://docs.github.com/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments

0 commit comments

Comments
 (0)