name: Prepare Release on: workflow_dispatch: inputs: version: description: Optional semantic version override, with or without leading v. When omitted, the recommended version is used. required: false workflow_call: inputs: version: description: Optional semantic version override, with or without leading v. When omitted, the recommended version is used. required: false default: '' type: string jobs: prepare: runs-on: ubuntu-latest container: docker.io/catthehacker/ubuntu:act-latest outputs: tag: ${{ steps.prepare.outputs.version }} defaults: run: shell: bash env: SUMMARY_FILE: ${{ runner.temp }}/prepare-release-summary.md steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Go uses: actions/setup-go@v5 with: go-version-file: go.mod check-latest: false cache: true cache-dependency-path: go.sum - name: Validate formatting run: test -z "$(gofmt -l .)" - name: Module hygiene run: | set -euo pipefail go mod tidy if ! go mod verify; then echo "go mod verify failed; refreshing module cache and retrying" >&2 go clean -modcache go mod download go mod verify fi - name: Restore cached gosec binary id: cache-gosec uses: actions/cache@v4 with: path: ${{ runner.temp }}/gosec-bin key: gosec-v2.22.4-${{ runner.os }}-${{ runner.arch }} - name: Install gosec binary if: steps.cache-gosec.outputs.cache-hit != 'true' run: | set -euo pipefail mkdir -p "${RUNNER_TEMP}/gosec-bin" GOBIN="${RUNNER_TEMP}/gosec-bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4 - name: Run gosec security analysis run: | set -euo pipefail "${RUNNER_TEMP}/gosec-bin/gosec" ./... - name: Run govulncheck uses: golang/govulncheck-action@v1.0.4 with: go-version-file: go.mod check-latest: false go-package: ./... cache: true cache-dependency-path: go.sum - name: Run tests run: | set -euo pipefail go test ./... - name: Resolve cache token id: cache-token run: echo "value=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" - name: Resolve release tag id: resolve-version run: | set -euo pipefail provided_version="$(printf '%s' "${{ inputs.version }}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')" if [[ -z "$provided_version" ]]; then release_tag="$(go run ./cmd/vociferate --recommend --root .)" elif [[ "$provided_version" == v* ]]; then release_tag="$provided_version" else release_tag="v${provided_version}" fi echo "tag=${release_tag}" >> "$GITHUB_OUTPUT" - name: Update agent docs action tags run: | set -euo pipefail release_tag="${{ steps.resolve-version.outputs.tag }}" for file in README.md AGENTS.md; do sed -E -i "s/@v[0-9]+\.[0-9]+\.[0-9]+/@${release_tag}/g" "$file" done - name: Prepare and tag release id: prepare uses: ./prepare env: VOCIFERATE_CACHE_TOKEN: ${{ steps.cache-token.outputs.value }} with: version: ${{ steps.resolve-version.outputs.tag }} token: ${{ secrets.RELEASE_PAT }} git-add-files: CHANGELOG.md release-version README.md AGENTS.md - name: Summary if: ${{ always() }} run: | set -euo pipefail tag="${{ steps.prepare.outputs.version }}" { echo "## Release Prepared" echo echo "- Tag pushed: ${tag}" } >> "$SUMMARY_FILE" echo 'Summary' echo if [[ -s "$SUMMARY_FILE" ]]; then cat "$SUMMARY_FILE" else echo 'No summary generated.' fi release: runs-on: ubuntu-latest container: docker.io/catthehacker/ubuntu:act-latest needs: prepare outputs: tag: ${{ steps.publish.outputs.tag }} version: ${{ steps.publish.outputs.version }} defaults: run: shell: bash env: RELEASE_TOKEN: ${{ secrets.RELEASE_PAT }} SUMMARY_FILE: ${{ runner.temp }}/release-summary.md steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Resolve release version id: resolve-version env: PREPARE_TAG: ${{ needs.prepare.outputs.tag }} run: | set -euo pipefail tag="$(printf '%s' "${PREPARE_TAG}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')" # Unwrap Teacup expression strings if present. if [[ "${tag}" =~ ^%\!t\(string=(.*)\)$ ]]; then tag="${BASH_REMATCH[1]}" fi normalized="${tag#v}" tag="v${normalized}" echo "tag=${tag}" >> "$GITHUB_OUTPUT" echo "version=${normalized}" >> "$GITHUB_OUTPUT" - name: Checkout release tag uses: actions/checkout@v4 with: fetch-depth: 0 ref: refs/tags/${{ steps.resolve-version.outputs.tag }} - name: Setup Go uses: actions/setup-go@v5 with: go-version-file: go.mod check-latest: false cache: true cache-dependency-path: go.sum - name: Preflight release API access env: TAG_NAME: ${{ steps.resolve-version.outputs.tag }} run: | set -euo pipefail if [[ -z "${RELEASE_TOKEN:-}" ]]; then echo "No release token available. Set secrets.RELEASE_PAT." >&2 exit 1 fi api_base="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}" repo_api="${api_base}/repos/${GITHUB_REPOSITORY}" curl --fail-with-body -sS \ -H "Authorization: token ${RELEASE_TOKEN}" \ -H "Content-Type: application/json" \ "${repo_api}" >/dev/null curl --fail-with-body -sS \ -H "Authorization: token ${RELEASE_TOKEN}" \ -H "Content-Type: application/json" \ "${repo_api}/releases?limit=1" >/dev/null if ! git rev-parse --verify --quiet "refs/tags/${TAG_NAME}" >/dev/null; then echo "Tag ${TAG_NAME} was not found in the checked out repository." >&2 exit 1 fi - name: Create or update release id: publish uses: ./publish with: token: ${{ secrets.RELEASE_PAT }} version: ${{ steps.resolve-version.outputs.version }} - name: Build release binaries env: RELEASE_VERSION: ${{ steps.publish.outputs.version }} run: | set -euo pipefail mkdir -p dist for target in linux/amd64 linux/arm64; do os="${target%/*}" arch="${target#*/}" bin="vociferate_${RELEASE_VERSION}_${os}_${arch}" GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/vociferate done ( cd dist shasum -a 256 * > checksums.txt ) - name: Upload release binaries env: RELEASE_ID: ${{ steps.publish.outputs.release-id }} RELEASE_VERSION: ${{ steps.publish.outputs.version }} run: | set -euo pipefail 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")" assets_json="$(curl -sS --fail-with-body \ -H "Authorization: token ${RELEASE_TOKEN}" \ -H "Content-Type: application/json" \ "${release_api}")" escaped_name="$(printf '%s' "$name" | sed 's/[][(){}.^$*+?|\\/]/\\&/g')" existing_asset_id="$(printf '%s' "$assets_json" | tr -d '\n' | sed -n "s/.*{\"id\":\([0-9][0-9]*\)[^}]*\"name\":\"${escaped_name}\".*/\1/p")" if [[ -n "$existing_asset_id" ]]; then curl --fail-with-body \ -X DELETE \ -H "Authorization: token ${RELEASE_TOKEN}" \ -H "Content-Type: application/json" \ "${release_api}/${existing_asset_id}" fi 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 - name: Summary if: ${{ always() }} env: TAG_NAME: ${{ steps.publish.outputs.tag }} RELEASE_VERSION: ${{ steps.publish.outputs.version }} PUBLISH_OUTCOME: ${{ steps.publish.outcome }} run: | set -euo pipefail if [[ "${PUBLISH_OUTCOME}" == "success" ]]; then { echo "## Release Published" echo echo "- Tag: ${TAG_NAME}" echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}." echo "- Published assets: vociferate_${RELEASE_VERSION}_linux_amd64, vociferate_${RELEASE_VERSION}_linux_arm64, checksums.txt" } >> "$SUMMARY_FILE" else { echo "## Release Failed" echo echo "- Tag: ${TAG_NAME:-unknown}" echo "- Create or update release step did not complete successfully." } >> "$SUMMARY_FILE" fi echo 'Summary' echo if [[ -s "$SUMMARY_FILE" ]]; then cat "$SUMMARY_FILE" else echo 'No summary generated.' fi validate: runs-on: ubuntu-latest container: docker.io/catthehacker/ubuntu:act-latest needs: release strategy: fail-fast: false matrix: include: - asset_arch: amd64 run_command: ./vociferate - asset_arch: arm64 run_command: qemu-aarch64-static ./vociferate defaults: run: shell: bash env: SUMMARY_FILE: ${{ runner.temp }}/validate-summary.md steps: - name: Checkout tagged revision uses: actions/checkout@v4 with: ref: refs/tags/${{ needs.release.outputs.tag }} - name: Install arm64 emulator if: ${{ matrix.asset_arch == 'arm64' }} run: | set -euo pipefail apt-get update apt-get install -y qemu-user-static - name: Download released binary env: TOKEN: ${{ secrets.RELEASE_PAT }} TAG_NAME: ${{ needs.release.outputs.tag }} RELEASE_VERSION: ${{ needs.release.outputs.version }} ASSET_ARCH: ${{ matrix.asset_arch }} SERVER_URL: ${{ github.server_url }} run: | set -euo pipefail asset_name="vociferate_${RELEASE_VERSION}_linux_${ASSET_ARCH}" asset_url="${SERVER_URL}/aether/vociferate/releases/download/${TAG_NAME}/${asset_name}" curl --fail --location \ -H "Authorization: token ${TOKEN}" \ -o vociferate \ "$asset_url" chmod +x vociferate echo "asset_name=${asset_name}" >> "$GITHUB_ENV" - name: Smoke test released binary env: RUN_COMMAND: ${{ matrix.run_command }} TAG_NAME: ${{ needs.release.outputs.tag }} run: | set -euo pipefail ${RUN_COMMAND} --help >/dev/null recommend_stderr="$(mktemp)" if ${RUN_COMMAND} --recommend --root . >/dev/null 2>"${recommend_stderr}"; then echo "Expected --recommend to fail on the tagged release checkout" >&2 exit 1 fi recommend_error="$(cat "${recommend_stderr}")" case "${recommend_error}" in *"unreleased section is empty"*) ;; *) echo "Unexpected recommend failure output: ${recommend_error}" >&2 exit 1 ;; esac { echo "## Released Binary Validation (${{ matrix.asset_arch }})" echo echo "- Release tag: ${TAG_NAME}" echo "- Asset: ${asset_name}" echo "- Binary executed successfully via --help." echo "- --recommend failed as expected on the tagged checkout because Unreleased is empty." } >> "$SUMMARY_FILE" - name: Summary if: ${{ always() }} run: | set -euo pipefail echo 'Summary' echo if [[ -s "$SUMMARY_FILE" ]]; then cat "$SUMMARY_FILE" else echo 'No summary generated.' fi