From 71e411e12d1271b2cffd9760d8148f5a84a55aa9 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:41:49 +0000 Subject: [PATCH] ci(release): make release notes idempotent and publish binaries --- .gitea/workflows/prepare-release.yml | 120 +++++++++++++++++++++++++++ README.md | 50 +++++++++++ 2 files changed, 170 insertions(+) diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml index 6fc9a5d..bc870e5 100644 --- a/.gitea/workflows/prepare-release.yml +++ b/.gitea/workflows/prepare-release.yml @@ -6,6 +6,12 @@ on: version: description: Semantic version to release, with or without leading v. required: true + workflow_call: + inputs: + version: + description: Semantic version to release, with or without leading v. + required: true + type: string jobs: prepare: @@ -81,3 +87,117 @@ jobs: git tag "$tag" git push origin HEAD git push origin "$tag" + + - name: Create release with changelog notes + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + normalized_version="${RELEASE_VERSION#v}" + tag="v${normalized_version}" + + release_notes="$(awk -v version="$normalized_version" ' + $0 ~ "^## \\\\[" version "\\\\] - " {capture=1} + capture { + if ($0 ~ "^## \\\\[" && $0 !~ "^## \\\\[" version "\\\\] - ") exit + print + } + ' changelog.md)" + + if [[ -z "${release_notes//[[:space:]]/}" ]]; then + echo "Release notes section for ${normalized_version} was not found in changelog.md" >&2 + exit 1 + fi + + escaped_release_notes="$(printf '%s' "$release_notes" | sed 's/\\/\\\\/g; s/"/\\"/g; :a;N;$!ba;s/\n/\\n/g')" + release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases" + release_by_tag_api="${release_api}/tags/${tag}" + + status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${release_by_tag_api}")" + + if [[ "$status_code" == "200" ]]; then + existing_release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release-existing.json | head -n 1)" + if [[ -z "$existing_release_id" ]]; then + echo "Failed to parse existing release id for ${tag}" >&2 + cat release-existing.json >&2 + exit 1 + fi + + curl --fail-with-body \ + -X DELETE \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${release_api}/${existing_release_id}" + elif [[ "$status_code" != "404" ]]; then + echo "Unexpected response while checking release ${tag}: HTTP ${status_code}" >&2 + cat release-existing.json >&2 + exit 1 + fi + + curl --fail-with-body \ + -X POST \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${release_api}" \ + --data "{\"tag_name\":\"${tag}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${tag}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \ + --output release.json + + release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release.json | head -n 1)" + if [[ -z "$release_id" ]]; then + echo "Failed to parse release id from API response" >&2 + cat release.json >&2 + exit 1 + fi + + echo "RELEASE_ID=$release_id" >> "$GITHUB_ENV" + + - name: Build release binaries + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + normalized_version="${RELEASE_VERSION#v}" + mkdir -p dist + + for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do + os="${target%/*}" + arch="${target#*/}" + ext="" + if [[ "$os" == "windows" ]]; then + ext=".exe" + fi + + bin="vociferate_${normalized_version}_${os}_${arch}${ext}" + GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/releaseprep + done + + ( + cd dist + shasum -a 256 * > checksums.txt + ) + + - name: Upload release binaries + run: | + set -euo pipefail + + if [[ -z "${RELEASE_ID:-}" ]]; then + echo "RELEASE_ID is not available for asset upload" >&2 + exit 1 + fi + + release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" + + for asset in dist/*; do + name="$(basename "$asset")" + curl --fail-with-body \ + -X POST \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + "${release_api}?name=${name}" \ + --data-binary "@${asset}" + done diff --git a/README.md b/README.md index e5f1b2e..3d4cf5c 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,53 @@ Defaults: ```bash just go-test ``` + +## Release Artifacts + +The `Prepare Release` workflow creates a release and uploads prebuilt `vociferate` binaries for: + +- `darwin/amd64` +- `darwin/arm64` +- `linux/amd64` +- `linux/arm64` +- `windows/amd64` +- `windows/arm64` + +It also uploads `checksums.txt` for integrity verification. +If a release already exists for the same tag, the workflow replaces it so release notes and attached binaries stay in sync. + +## Reuse In Other Repositories + +You can reuse vociferate in two ways. + +Use the composite action directly: + +```yaml +- name: Prepare release files + uses: git.hrafn.xyz/aether/vociferate@main + with: + version: v1.2.3 + version-file: internal/myapp/version/version.go + version-pattern: 'const Version = "([^"]+)"' + changelog: changelog.md +``` + +Call the reusable release workflow: + +```yaml +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: Semantic version to release. + required: true + +jobs: + release: + uses: aether/vociferate/.gitea/workflows/prepare-release.yml@main + with: + version: ${{ inputs.version }} + secrets: inherit +```