|
1 | | -name: Release preparation |
| 1 | +name: Release |
2 | 2 |
|
3 | 3 | on: |
4 | 4 | push: |
|
29 | 29 | env: |
30 | 30 | GH_TOKEN: ${{ github.token }} |
31 | 31 | 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 | +
|
32 | 39 | test -s LICENSE || { |
33 | 40 | echo "LICENSE must exist before a public provider release." >&2 |
34 | 41 | exit 1 |
@@ -113,3 +120,167 @@ jobs: |
113 | 120 | retention-days: 7 |
114 | 121 | compression-level: 0 |
115 | 122 | 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[@]}" |
0 commit comments