Skip to content

Commit 2107314

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 a89c140 commit 2107314

2 files changed

Lines changed: 213 additions & 9 deletions

File tree

.github/workflows/release.yml

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

33
on:
44
push:
@@ -29,6 +29,13 @@ jobs:
2929
env:
3030
GH_TOKEN: ${{ github.token }}
3131
run: |
32+
major="${GITHUB_REF_NAME#v}"
33+
major="${major%%.*}"
34+
if ! [[ "$major" =~ ^[1-9][0-9]*$ ]]; then
35+
echo "Public provider release tags must have a semantic-version major of at least 1." >&2
36+
exit 1
37+
fi
38+
3239
test -s LICENSE || {
3340
echo "LICENSE must exist before a public provider release." >&2
3441
exit 1
@@ -113,3 +120,167 @@ jobs:
113120
retention-days: 7
114121
compression-level: 0
115122
overwrite: true
123+
124+
publish:
125+
name: Sign and publish release
126+
needs: prepare
127+
runs-on: ubuntu-latest
128+
timeout-minutes: 10
129+
permissions:
130+
actions: read
131+
contents: write
132+
environment:
133+
name: release
134+
135+
steps:
136+
- name: Check release protections
137+
env:
138+
GH_TOKEN: ${{ github.token }}
139+
RELEASE_RULESET_ID: ${{ vars.RELEASE_RULESET_ID }}
140+
run: |
141+
: "${RELEASE_RULESET_ID:?Set RELEASE_RULESET_ID on the release environment before releasing.}"
142+
143+
environment="$(gh api "repos/${GITHUB_REPOSITORY}/environments/release")"
144+
if ! jq -e '
145+
[.protection_rules[]? | select(.type == "required_reviewers")] as $rules |
146+
($rules | length) == 1 and
147+
$rules[0].prevent_self_review == true and
148+
($rules[0].reviewers | length) > 0 and
149+
.deployment_branch_policy == {
150+
protected_branches: false,
151+
custom_branch_policies: true
152+
}
153+
' <<<"$environment" >/dev/null; then
154+
echo "The release environment must require a reviewer, prevent self-review, and use a custom tag policy." >&2
155+
exit 1
156+
fi
157+
158+
deployment_policies="$(
159+
gh api --paginate --slurp \
160+
"repos/${GITHUB_REPOSITORY}/environments/release/deployment-branch-policies?per_page=100"
161+
)"
162+
if ! jq -e '
163+
[.[] | .branch_policies[] | {name, type}] == [{name: "v*", type: "tag"}]
164+
' <<<"$deployment_policies" >/dev/null; then
165+
echo "The release environment must allow only tags matching v*." >&2
166+
exit 1
167+
fi
168+
169+
ruleset="$(gh api "repos/${GITHUB_REPOSITORY}/rulesets/${RELEASE_RULESET_ID}")"
170+
if ! jq -e '
171+
.target == "tag" and
172+
.enforcement == "active" and
173+
(.conditions.ref_name.include | sort) == ["refs/tags/v*"] and
174+
.conditions.ref_name.exclude == [] and
175+
([.rules[].type] | index("creation") != null) and
176+
([.rules[].type] | index("update") != null) and
177+
([.rules[].type] | index("deletion") != null)
178+
' <<<"$ruleset" >/dev/null; then
179+
echo "The release ruleset must actively restrict v* tag creation, update, and deletion." >&2
180+
exit 1
181+
fi
182+
183+
- name: Download release artifacts
184+
env:
185+
GH_TOKEN: ${{ github.token }}
186+
run: |
187+
gh run download "$GITHUB_RUN_ID" \
188+
--repo "$GITHUB_REPOSITORY" \
189+
--name "release-${GITHUB_REF_NAME}-${GITHUB_RUN_ID}" \
190+
--dir dist
191+
192+
- name: Check publication preconditions
193+
env:
194+
GH_TOKEN: ${{ github.token }}
195+
run: |
196+
tag_commit="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_REF_NAME}" --jq .sha)"
197+
if [ "$tag_commit" != "$GITHUB_SHA" ]; then
198+
echo "The release tag no longer points to the workflow commit." >&2
199+
exit 1
200+
fi
201+
202+
release="$(
203+
gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" |
204+
jq -c --arg tag "$GITHUB_REF_NAME" '[.[][] | select(.tag_name == $tag)][0] // empty'
205+
)"
206+
if [ -n "$release" ]; then
207+
if [ "$(jq -r .draft <<<"$release")" = "true" ]; then
208+
echo "An existing draft release must be inspected and removed before retrying ${GITHUB_REF_NAME}." >&2
209+
else
210+
echo "A published release already exists for ${GITHUB_REF_NAME}." >&2
211+
fi
212+
exit 1
213+
fi
214+
215+
checksum="$(find dist -maxdepth 1 -name '*_SHA256SUMS' -print -quit)"
216+
test -n "$checksum"
217+
(cd dist && sha256sum --check "$(basename "$checksum")")
218+
219+
- name: Check signing configuration
220+
env:
221+
EXPECTED_GPG_FINGERPRINT: ${{ vars.GPG_FINGERPRINT }}
222+
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
223+
PASSPHRASE: ${{ secrets.PASSPHRASE }}
224+
run: |
225+
: "${EXPECTED_GPG_FINGERPRINT:?Set GPG_FINGERPRINT on the release environment before releasing.}"
226+
: "${GPG_PRIVATE_KEY:?Set GPG_PRIVATE_KEY on the release environment before releasing.}"
227+
: "${PASSPHRASE:?Set PASSPHRASE on the release environment before releasing.}"
228+
229+
- name: Import GPG key
230+
env:
231+
EXPECTED_GPG_FINGERPRINT: ${{ vars.GPG_FINGERPRINT }}
232+
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
233+
run: |
234+
expected="$(printf '%s' "$EXPECTED_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')"
235+
key_details="$(
236+
printf '%s' "$GPG_PRIVATE_KEY" |
237+
gpg --batch --with-colons --import-options show-only --import 2>/dev/null
238+
)"
239+
mapfile -t fingerprints < <(
240+
awk -F: '
241+
$1 == "sec" { primary = 1; next }
242+
primary && $1 == "fpr" { print $10; primary = 0 }
243+
' <<<"$key_details"
244+
)
245+
if [ "${#fingerprints[@]}" -ne 1 ] || [ "${fingerprints[0]}" != "$expected" ]; then
246+
echo "The private key must contain exactly the Registry signing key." >&2
247+
exit 1
248+
fi
249+
250+
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
251+
252+
- name: Sign checksums
253+
env:
254+
EXPECTED_GPG_FINGERPRINT: ${{ vars.GPG_FINGERPRINT }}
255+
PASSPHRASE: ${{ secrets.PASSPHRASE }}
256+
run: |
257+
expected="$(printf '%s' "$EXPECTED_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')"
258+
checksum="$(find dist -maxdepth 1 -name '*_SHA256SUMS' -print -quit)"
259+
printf '%s' "$PASSPHRASE" |
260+
gpg --batch --pinentry-mode loopback --passphrase-fd 0 \
261+
--local-user "$expected" --output "${checksum}.sig" --detach-sign "$checksum"
262+
gpg --verify "${checksum}.sig" "$checksum"
263+
264+
- name: Publish release
265+
env:
266+
GH_TOKEN: ${{ github.token }}
267+
run: |
268+
version="${GITHUB_REF_NAME#v}"
269+
version_without_build="${version%%+*}"
270+
prerelease=()
271+
if [[ "$version_without_build" == *-* ]]; then
272+
prerelease+=(--prerelease)
273+
fi
274+
migration_url="https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/docs/migration-v1.md"
275+
276+
gh release create "$GITHUB_REF_NAME" \
277+
dist/*.zip \
278+
dist/*_manifest.json \
279+
dist/*_SHA256SUMS \
280+
dist/*_SHA256SUMS.sig \
281+
--repo "$GITHUB_REPOSITORY" \
282+
--verify-tag \
283+
--generate-notes \
284+
--notes "Migration guidance: [v1 migration guide](${migration_url})." \
285+
--title "$GITHUB_REF_NAME" \
286+
"${prerelease[@]}"

docs/release.md

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,27 @@ Use this checklist before publishing a Kernel Terraform provider version.
3232
7. Read each test-owned canonical resource ID and require a 404 before considering cleanup complete.
3333
- 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.
3434
- Confirm `terraform-registry-manifest.json` contains protocol `["6.0"]` for Terraform Plugin Framework.
35-
- Confirm the repository license before the first public release. Do not publish a public tag until `LICENSE` exists or the release owner has explicitly documented the licensing decision.
35+
- Confirm the repository license before the first public release. The release workflow fails unless a non-empty `LICENSE` exists.
3636
- Confirm GitHub private vulnerability reporting or a public security contact is configured and reflected in `SECURITY.md`.
37+
- Create a protected GitHub environment named `release`, require approval from
38+
the designated release owners, prevent self-review, and disable administrator
39+
bypass. Add exactly one deployment policy: tags matching `v*`. Store
40+
`GPG_PRIVATE_KEY` and `PASSPHRASE` as environment secrets, not repository
41+
secrets. Set the environment variable `GPG_FINGERPRINT` to the fingerprint
42+
registered with the Terraform Registry. GitHub's environment API does not
43+
report whether administrator bypass is disabled, so verify that setting when
44+
approving a release.
45+
- Add a repository ruleset that restricts creation, update, and deletion of
46+
`v*` tags to the designated release owners. Do not rely on general repository
47+
write access as release authority. Set the `release` environment variable
48+
`RELEASE_RULESET_ID` to that active ruleset's numeric ID. The workflow verifies
49+
the live environment reviewer, tag policy, and ruleset controls before
50+
touching signing configuration. Before setting the ID, inspect the ruleset's
51+
bypass list and require explicit release-owner teams or GitHub Apps; do not
52+
allow repository roles or organization administrators to bypass it. GitHub
53+
omits bypass actors from ruleset API responses unless the caller has ruleset
54+
write access, so the least-privilege workflow cannot verify their identities
55+
at runtime.
3756
- Confirm there is no branch named like the release tag, for example `v1.0.0`.
3857

3958
## Registry Release Assets
@@ -57,13 +76,26 @@ Do not replace or mutate assets for a published version. If an asset, checksum,
5776
platforms, checksums, and manifest inclusion.
5877
- Normal CI runs `goreleaser check`; it does not cross-compile every target or
5978
publish artifacts.
60-
- `.github/workflows/release.yml` runs for `v*` tags with read-only repository
61-
access. It requires a non-empty `LICENSE`, public repository visibility, and a
62-
tag commit reachable from `main`, then builds and validates the unsigned
63-
registry assets. The workflow artifact is retained for seven days for release
64-
inspection.
65-
- Signing and publication remain separate manual release gates until release
66-
ownership and a protected publication workflow are configured.
79+
- `.github/workflows/release.yml` runs for `v*` tags. Its preparation job has
80+
read-only repository access and rejects major version zero before requiring a
81+
non-empty `LICENSE`, public repository visibility, and a tag commit reachable
82+
from `main`. GoReleaser validates the complete SemVer tag, then builds and
83+
validates the unsigned registry assets. The workflow artifact is retained for
84+
seven days for release inspection. Only the protected publication job receives
85+
`contents: write`.
86+
- Inspect the prepared artifact and confirm the acceptance matrix passed before
87+
approving the protected `release` job. Confirm the tag ruleset's bypass list
88+
still contains only designated release owners as part of that approval. After
89+
approval, the job revalidates the tag and checksums, requires the imported key
90+
to match `GPG_FINGERPRINT`, signs the checksum file, and publishes the GitHub
91+
Release. Semantic prerelease tags are marked as GitHub prereleases.
92+
- Failed-job reruns reuse the prepared artifact from the same workflow run. A
93+
full rerun replaces that run's artifact and requires a fresh environment
94+
approval. If publication fails or is interrupted, it may leave a draft. Any
95+
existing draft stops retries until a release owner inspects and removes it
96+
manually. An existing published release always stops the workflow.
97+
- GitHub prepends the tagged [v1 migration guide](migration-v1.md) to release
98+
notes generated from `.github/release.yml`.
6799

68100
## Registry Setup
69101

@@ -96,3 +128,4 @@ References:
96128

97129
- HashiCorp Terraform provider publishing: https://developer.hashicorp.com/terraform/registry/providers/publishing
98130
- HashiCorp provider registry protocol: https://developer.hashicorp.com/terraform/internals/provider-registry-protocol
131+
- GitHub deployment environments: https://docs.github.com/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments

0 commit comments

Comments
 (0)