29 Commits

Author SHA1 Message Date
gitea-actions[bot]
45bb09af27 release: prepare v1.2.0 2026-03-21 23:15:26 +00:00
Micheal Wilkinson
995e397bff docs: update upx fallback note
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m24s
Push Validation / recommend-release (push) Successful in 28s
2026-03-21 23:12:30 +00:00
Micheal Wilkinson
8bf7184479 chore(workflows): install upx via ghaction-upx 2026-03-21 23:12:30 +00:00
Micheal Wilkinson
41918cd5de docs: note containerized upx fallback
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m23s
Push Validation / recommend-release (push) Successful in 25s
2026-03-21 23:02:35 +00:00
Micheal Wilkinson
0cec30c9bb chore(workflows): add container upx fallback 2026-03-21 23:02:29 +00:00
Micheal Wilkinson
24dd65da67 docs: log upx fallback in release workflows
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m18s
Push Validation / recommend-release (push) Successful in 25s
2026-03-21 23:00:08 +00:00
Micheal Wilkinson
1ab56b0536 chore(workflows): allow release builds without upx 2026-03-21 23:00:05 +00:00
Micheal Wilkinson
6919061240 docs: log coverage-gate security hardening
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
2026-03-21 22:47:04 +00:00
Micheal Wilkinson
7b739e04c8 chore(go): harden coverage-gate file input handling 2026-03-21 22:47:02 +00:00
Micheal Wilkinson
98ea91f2df docs: log coverage-gate extraction to Unreleased
Some checks failed
Push Validation / coverage-badge (push) Failing after 32s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 22:39:21 +00:00
Micheal Wilkinson
532f6a98d8 feat: extract coverage-gate action from Cue for reuse across projects
Some checks failed
Push Validation / coverage-badge (push) Failing after 44s
Push Validation / recommend-release (push) Has been skipped
- Move coveragegate tool from cue/tools to vociferate/coverage-gate
- Create composite action with JSON metrics output for CI
- Update tool to export passes/total_coverage/packages_checked/packages_failed
- Support per-package threshold policy via JSON configuration
- Change module path to git.hrafn.xyz/aether/vociferate/coverage-gate
2026-03-21 22:34:01 +00:00
Micheal Wilkinson
a3e2b4e44e refactor(release): rename workflows and align update-release path
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m30s
Push Validation / recommend-release (push) Successful in 23s
Rename the reusable workflows to release.yml and update-release.yml,
add UPX compression for release binaries, and sync the standalone
update-release workflow with the active release pipeline fixes.
Update README, AGENTS, compliance notes, and changelog references to
match the new workflow names and usage patterns.
2026-03-21 20:26:13 +00:00
gitea-actions[bot]
f82dace4b2 release: prepare v1.1.0 2026-03-21 20:16:35 +00:00
Micheal Wilkinson
81dced6ada fix(publish): avoid heredoc parsing in composite json helper
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m53s
Push Validation / recommend-release (push) Successful in 28s
Replace the embedded python heredoc with python3 -c so the composite
action remains valid YAML and shell across teacup parsing.
2026-03-21 20:14:49 +00:00
Micheal Wilkinson
62693935d0 fix(release): parse release id robustly and validate upload endpoint
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m36s
Push Validation / recommend-release (push) Successful in 47s
Use JSON parsing for release id extraction in publish action instead of
regex matching, preventing wrong id selection from nested fields.
Add a pre-upload release endpoint check to fail early with explicit
release URL diagnostics when the resolved id/path is invalid.
2026-03-21 20:07:45 +00:00
Micheal Wilkinson
c0b5ec385c fix(release): normalize wrapped release-id before asset upload
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
Teacup can wrap workflow outputs as %touch docker-compose.yml(string=...), which produced an
invalid releases/{id}/assets URL and a 404 in Upload release binaries.
Unwrap and validate release-id before building the API path.
2026-03-21 20:03:00 +00:00
Micheal Wilkinson
84f6fbcfc8 fix(release): unwrap teacup token inputs and correct failure summary
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
Normalize %touch docker-compose.yml(string=...) wrapped token values in publish composite before
API calls. This prevents malformed Authorization headers under teacup.
Also only print 'Release Published' summary when the publish step succeeds,
and print a failure summary otherwise.
2026-03-21 19:51:26 +00:00
Micheal Wilkinson
4a2d234ba3 fix(publish): stop sending target field in release payload
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
Prepare already creates and pushes the release tag, so publish should not
retarget it. Sending target can trigger 403 on Gitea when tag retargeting
is restricted. Build PATCH/POST payloads from tag_name + notes only.
2026-03-21 19:46:53 +00:00
Micheal Wilkinson
4841b04076 fix(publish): use tag's actual commit SHA for release target
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
GITHUB_SHA (github.sha) reflects the workflow trigger commit, which is
the main HEAD before the prepare job ran. The tag itself points to the
release commit created by prepare — a different SHA. Gitea rejects PATCH
and POST with 403 when target_commitish doesn't match the tag's commit.

Use git rev-list -n 1 TAG to resolve the exact SHA the tag points to,
ensuring the target field is always correct regardless of when or how
the release workflow is called.
2026-03-21 19:39:17 +00:00
Micheal Wilkinson
993768ae9b refactor(release): inline release and validate jobs into prepare-release
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m24s
Push Validation / recommend-release (push) Successful in 42s
Replaces the workflow_call to do-release with directly inlined release
and validate jobs. All steps now appear flat in the Actions UI with full
individual step visibility instead of being collapsed under a setup job.

Tag resolution in the release job is simplified: the tag always comes
from needs.prepare.outputs.tag, removing the detect-tag guessing needed
for standalone dispatch.

do-release.yml is unchanged and remains available for manual dispatch.
2026-03-21 19:30:53 +00:00
Micheal Wilkinson
624b9d154c fix(release): re-enable workflow_call publish path in prepare-release
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m18s
Push Validation / recommend-release (push) Successful in 23s
Tag push events never fire on this Gitea 1.25.x instance (confirmed
across 159 workflow run history). The workflow_call path is reliable and
has worked consistently. Remove the temporary if-false guard.
2026-03-21 19:23:31 +00:00
Micheal Wilkinson
53a097784e chore(workflows): use go-version-file for setup-go
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m14s
Push Validation / recommend-release (push) Successful in 23s
2026-03-21 16:26:16 +00:00
Micheal Wilkinson
511110f466 chore(release): temporarily disable inline do-release call
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m10s
Push Validation / recommend-release (push) Successful in 26s
Disable the publish job in prepare-release to validate that tag push triggers do-release automatically via workflow trigger path.
2026-03-21 16:19:22 +00:00
Micheal Wilkinson
d5170b6874 fix(release): require RELEASE_PAT for tag and release updates
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m11s
Push Validation / recommend-release (push) Successful in 24s
Stop using GITHUB_TOKEN/GITEA_TOKEN fallbacks in prepare/do-release/publish mutation paths. Require explicit PAT wiring via secrets.RELEASE_PAT for commit/push/tag and release update operations so downstream workflows trigger reliably.
2026-03-21 16:17:17 +00:00
Micheal Wilkinson
925c99bb9e docs: note govulncheck api limit workflow fix
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m20s
Push Validation / recommend-release (push) Successful in 22s
2026-03-21 16:08:51 +00:00
Micheal Wilkinson
d65af508a3 chore(go): avoid setup-go stable resolution in ci 2026-03-21 16:08:49 +00:00
Micheal Wilkinson
eae70bb20f fix(workflows): prefer caller tag in do-release resolution
Some checks failed
Push Validation / coverage-badge (push) Failing after 38s
Push Validation / recommend-release (push) Has been skipped
Handle Teacup workflow_call input forwarding gaps by using needs.prepare.outputs.tag as a fallback and normalizing %touch docker-compose.yml(string=...) wrappers before selecting the release tag.
2026-03-21 16:04:26 +00:00
Micheal Wilkinson
c96cab58ff docs: document HEAD-prioritized tag detection fix
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
2026-03-21 15:58:57 +00:00
Micheal Wilkinson
a6d57e4048 fix(workflows): prioritize HEAD tag detection over global latest tag
When prepare-release tags HEAD with a new release version, do-release should
immediately detect that tag rather than finding the latest tag chronologically.

Changes:
- Modified detect-tag step to check if HEAD is exactly at a tag first
- Falls back to latest tag only if HEAD is not tagged
- Fixes issue where v1.0.2 was detected instead of v1.1.0 at HEAD

This ensures correct version detection in prepare-release → do-release workflow chain.
2026-03-21 15:58:25 +00:00
19 changed files with 1485 additions and 284 deletions

View File

@@ -1,157 +0,0 @@
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: '1.26.1'
check-latest: true
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-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 }}
git-add-files: CHANGELOG.md release-version README.md AGENTS.md
- name: Summarize prepared release
run: |
set -euo pipefail
tag="${{ steps.prepare.outputs.version }}"
{
echo "## Release Prepared"
echo
echo "- Tag pushed: ${tag}"
echo "- Calling Do Release workflow for ${tag}."
} >> "$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
publish:
needs: prepare
uses: ./.gitea/workflows/do-release.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
secrets: inherit

View File

@@ -30,8 +30,8 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.26.1' go-version-file: go.mod
check-latest: true check-latest: false
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum
@@ -73,6 +73,8 @@ jobs:
- name: Run govulncheck - name: Run govulncheck
uses: golang/govulncheck-action@v1.0.4 uses: golang/govulncheck-action@v1.0.4
with: with:
go-version-file: go.mod
check-latest: false
go-package: ./... go-package: ./...
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum
@@ -122,8 +124,8 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.26.1' go-version-file: go.mod
check-latest: true check-latest: false
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum

View File

@@ -0,0 +1,466 @@
name: 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 }}/release-prepare-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: Install UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- 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
upx_cmd=""
if command -v upx >/dev/null 2>&1; then
upx_cmd=upx
elif command -v upx-ucl >/dev/null 2>&1; then
upx_cmd=upx-ucl
else
echo "UPX is not available on PATH after install step; continuing without binary compression." >&2
fi
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
if [[ -n "${upx_cmd}" ]]; then
"${upx_cmd}" --best --lzma "dist/${bin}"
fi
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
raw_release_id="$(printf '%s' "${RELEASE_ID:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ "$raw_release_id" =~ ^%\!t\(string=(.*)\)$ ]]; then
raw_release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$raw_release_id" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if ! [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "Invalid release id from publish step: '${RELEASE_ID}'" >&2
exit 1
fi
release_detail_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${release_id}"
if ! curl --fail-with-body -sS \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"$release_detail_api" >/dev/null; then
echo "Resolved release endpoint is not accessible: ${release_detail_api}" >&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")"
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"
echo "- Release binaries are compressed with UPX from crazy-max/ghaction-upx@v3 when available, otherwise uploaded uncompressed."
} >> "$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

View File

@@ -1,4 +1,4 @@
name: Do Release name: Update Release
on: on:
push: push:
@@ -28,8 +28,8 @@ jobs:
run: run:
shell: bash shell: bash
env: env:
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_PAT }}
SUMMARY_FILE: ${{ runner.temp }}/do-release-summary.md SUMMARY_FILE: ${{ runner.temp }}/update-release-summary.md
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -41,13 +41,17 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
# Fetch all tags from origin to ensure we have the latest # Fetch all tags from origin first
git fetch origin --tags --force --quiet 2>/dev/null || true git fetch origin --tags --force --quiet 2>/dev/null || true
# Try to find the most recent tag (in case it was just created by prepare-release) # Check if HEAD is at a tag (prepare-release may have just tagged it)
latest_tag="$(git describe --tags --abbrev=0 2>/dev/null || echo '')" if head_tag="$(git describe --exact-match --tags HEAD 2>/dev/null)" && [[ -n "$head_tag" ]]; then
echo "detected_tag=$head_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ -n "$latest_tag" ]]; then # Fall back to finding the most recent tag
if latest_tag="$(git describe --tags --abbrev=0 2>/dev/null)" && [[ -n "$latest_tag" ]]; then
echo "detected_tag=$latest_tag" >> "$GITHUB_OUTPUT" echo "detected_tag=$latest_tag" >> "$GITHUB_OUTPUT"
fi fi
@@ -59,12 +63,29 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
# Try to use explicit input first normalize_candidate() {
requested_tag="$(printf '%s' "${INPUT_TAG}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')" local raw="$1"
raw="$(printf '%s' "$raw" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
# Teacup can surface expression strings as %!t(string=value); unwrap it.
if [[ "$raw" =~ ^%\!t\(string=(.*)\)$ ]]; then
raw="${BASH_REMATCH[1]}"
fi
raw="$(printf '%s' "$raw" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
printf '%s' "$raw"
}
input_tag="$(normalize_candidate "${INPUT_TAG}")"
detected_tag="$(normalize_candidate "${DETECTED_TAG}")"
# Try explicit input first.
requested_tag="$input_tag"
# Fall back to detected tag if input is empty # Fall back to detected tag if neither input nor caller tag is available.
if [[ -z "$requested_tag" && -n "${DETECTED_TAG}" ]]; then if [[ -z "$requested_tag" && -n "$detected_tag" ]]; then
requested_tag="$DETECTED_TAG" requested_tag="$detected_tag"
fi fi
# Try GITHUB_REF if still empty # Try GITHUB_REF if still empty
@@ -78,8 +99,8 @@ jobs:
tag="v${normalized}" tag="v${normalized}"
else else
echo "Error: Could not resolve release version" >&2 echo "Error: Could not resolve release version" >&2
echo " - inputs.tag: '$INPUT_TAG'" >&2 echo " - inputs.tag(raw): '$INPUT_TAG'" >&2
echo " - detected_tag: '${DETECTED_TAG}'" >&2 echo " - detected_tag(raw): '${DETECTED_TAG}'" >&2
echo " - GITHUB_REF: '$GITHUB_REF'" >&2 echo " - GITHUB_REF: '$GITHUB_REF'" >&2
exit 1 exit 1
fi fi
@@ -96,11 +117,16 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.26.1' go-version-file: go.mod
check-latest: true check-latest: false
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum
- name: Install UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- name: Preflight release API access - name: Preflight release API access
env: env:
TAG_NAME: ${{ steps.resolve-version.outputs.tag }} TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
@@ -108,7 +134,7 @@ jobs:
set -euo pipefail set -euo pipefail
if [[ -z "${RELEASE_TOKEN:-}" ]]; then if [[ -z "${RELEASE_TOKEN:-}" ]]; then
echo "No release token available. Set GITEA_TOKEN (or GITHUB_TOKEN on GitHub)." >&2 echo "No release token available. Set secrets.RELEASE_PAT." >&2
exit 1 exit 1
fi fi
@@ -134,7 +160,7 @@ jobs:
id: publish id: publish
uses: ./publish uses: ./publish
with: with:
token: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }} token: ${{ secrets.RELEASE_PAT }}
version: ${{ steps.resolve-version.outputs.version }} version: ${{ steps.resolve-version.outputs.version }}
- name: Build release binaries - name: Build release binaries
@@ -143,6 +169,15 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
upx_cmd=""
if command -v upx >/dev/null 2>&1; then
upx_cmd=upx
elif command -v upx-ucl >/dev/null 2>&1; then
upx_cmd=upx-ucl
else
echo "UPX is not available on PATH after install step; continuing without binary compression." >&2
fi
mkdir -p dist mkdir -p dist
for target in linux/amd64 linux/arm64; do for target in linux/amd64 linux/arm64; do
@@ -151,6 +186,9 @@ jobs:
bin="vociferate_${RELEASE_VERSION}_${os}_${arch}" bin="vociferate_${RELEASE_VERSION}_${os}_${arch}"
GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/vociferate GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/vociferate
if [[ -n "${upx_cmd}" ]]; then
"${upx_cmd}" --best --lzma "dist/${bin}"
fi
done done
( (
@@ -165,7 +203,27 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" raw_release_id="$(printf '%s' "${RELEASE_ID:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ "$raw_release_id" =~ ^%\!t\(string=(.*)\)$ ]]; then
raw_release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$raw_release_id" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if ! [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "Invalid release id from publish step: '${RELEASE_ID}'" >&2
exit 1
fi
release_detail_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${release_id}"
if ! curl --fail-with-body -sS \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"$release_detail_api" >/dev/null; then
echo "Resolved release endpoint is not accessible: ${release_detail_api}" >&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 for asset in dist/*; do
name="$(basename "$asset")" name="$(basename "$asset")"
@@ -194,25 +252,32 @@ jobs:
--data-binary "@${asset}" --data-binary "@${asset}"
done done
- name: Summarize published release - name: Summary
if: ${{ always() }}
env: env:
TAG_NAME: ${{ steps.publish.outputs.tag }} TAG_NAME: ${{ steps.publish.outputs.tag }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }} RELEASE_VERSION: ${{ steps.publish.outputs.version }}
PUBLISH_OUTCOME: ${{ steps.publish.outcome }}
run: | run: |
set -euo pipefail set -euo pipefail
{ if [[ "${PUBLISH_OUTCOME}" == "success" ]]; then
echo "## Release Published" {
echo echo "## Release Published"
echo "- Tag: ${TAG_NAME}" echo
echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}." echo "- Tag: ${TAG_NAME}"
echo "- Published assets: vociferate_${RELEASE_VERSION}_linux_amd64, vociferate_${RELEASE_VERSION}_linux_arm64, checksums.txt" echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}."
} >> "$SUMMARY_FILE" echo "- Published assets: vociferate_${RELEASE_VERSION}_linux_amd64, vociferate_${RELEASE_VERSION}_linux_arm64, checksums.txt"
echo "- Release binaries are compressed with UPX from crazy-max/ghaction-upx@v3 when available, otherwise uploaded uncompressed."
- name: Summary } >> "$SUMMARY_FILE"
if: ${{ always() }} else
run: | {
set -euo pipefail 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 'Summary'
echo echo
@@ -239,7 +304,7 @@ jobs:
run: run:
shell: bash shell: bash
env: env:
SUMMARY_FILE: ${{ runner.temp }}/do-release-validate-summary.md SUMMARY_FILE: ${{ runner.temp }}/update-release-validate-summary.md
steps: steps:
- name: Checkout tagged revision - name: Checkout tagged revision
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -255,7 +320,7 @@ jobs:
- name: Download released binary - name: Download released binary
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }} TOKEN: ${{ secrets.RELEASE_PAT }}
TAG_NAME: ${{ needs.release.outputs.tag }} TAG_NAME: ${{ needs.release.outputs.tag }}
RELEASE_VERSION: ${{ needs.release.outputs.version }} RELEASE_VERSION: ${{ needs.release.outputs.version }}
ASSET_ARCH: ${{ matrix.asset_arch }} ASSET_ARCH: ${{ matrix.asset_arch }}

View File

@@ -4,15 +4,15 @@ This guide is for agentic coding partners that need to integrate the composite a
## Source Of Truth ## Source Of Truth
Pin all action references to a released tag (for example `@v1.0.2`) and keep all vociferate references on the same tag in a workflow. Pin all action references to a released tag (for example `@v1.2.0`) and keep all vociferate references on the same tag in a workflow.
Published composite actions: Published composite actions:
- `https://git.hrafn.xyz/aether/vociferate@v1.0.2` (root action) - `https://git.hrafn.xyz/aether/vociferate@v1.2.0` (root action)
- `https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2` - `https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2` - `https://git.hrafn.xyz/aether/vociferate/publish@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2` - `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2` - `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0`
## Action Selection Matrix ## Action Selection Matrix
@@ -30,8 +30,8 @@ Apply these checks before invoking actions:
- Checkout repository first. - Checkout repository first.
- For prepare/publish flows that depend on tags/history, use full history checkout (`fetch-depth: 0`). - For prepare/publish flows that depend on tags/history, use full history checkout (`fetch-depth: 0`).
- Use valid credentials for release/comment API calls. On GitHub, `secrets.GITHUB_TOKEN` is used; on self-hosted Gitea, set `secrets.GITEA_TOKEN`. - Use `secrets.RELEASE_PAT` for release/tag/update operations (`prepare`, `publish`, `release`, `update-release`) so authenticated release changes can be pushed and published reliably.
- `do-release` and `decorate-pr` now run preflight API checks and fail fast when token credentials are missing or insufficient. - `release`, `update-release`, and `decorate-pr` run preflight API checks and fail fast when token credentials are missing or insufficient.
- Set required vars/secrets for coverage uploads: - Set required vars/secrets for coverage uploads:
- `vars.ARTEFACT_BUCKET_NAME` - `vars.ARTEFACT_BUCKET_NAME`
- `vars.ARTEFACT_BUCKET_ENDPONT` - `vars.ARTEFACT_BUCKET_ENDPONT`
@@ -83,41 +83,26 @@ Minimal template:
## Minimal Integration Patterns ## Minimal Integration Patterns
### 1. Prepare Then Publish ### 1. Full Release Workflow
```yaml
jobs:
prepare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: prepare
uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
publish:
needs: prepare
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
with:
tag: ${{ needs.prepare.outputs.version }}
secrets: inherit
```
### 2. Publish Existing Tag
```yaml ```yaml
jobs: jobs:
release: release:
runs-on: ubuntu-latest uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/release.yml@v1.2.0
steps: with:
- uses: actions/checkout@v4 version: ${{ inputs.version }}
with: secrets: inherit
fetch-depth: 0 ```
- id: publish
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2 ### 2. Update Existing Release Tag
with:
version: v1.2.3 ```yaml
jobs:
release:
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0
with:
tag: v1.2.3
secrets: inherit
``` ```
### 3. Coverage Badge Publication ### 3. Coverage Badge Publication
@@ -136,7 +121,7 @@ jobs:
- name: Run tests with coverage - name: Run tests with coverage
run: go test -covermode=atomic -coverprofile=coverage.out ./... run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: badge - id: badge
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0
with: with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
@@ -159,12 +144,12 @@ jobs:
- name: Run tests with coverage - name: Run tests with coverage
run: go test -covermode=atomic -coverprofile=coverage.out ./... run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: badge - id: badge
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0
with: with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
- name: Decorate PR - name: Decorate PR
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0
with: with:
coverage-percentage: ${{ steps.badge.outputs.total }} coverage-percentage: ${{ steps.badge.outputs.total }}
badge-url: ${{ steps.badge.outputs.badge-url }} badge-url: ${{ steps.badge.outputs.badge-url }}

View File

@@ -13,6 +13,37 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
### Added ### Added
### Changed
### Removed
### Fixed
## [1.2.0] - 2026-03-21
### Breaking
### Added
- Extracted `coverage-gate` action and tool from Cue for reuse across Æther projects.
- Coverage gate now available as reusable composite action with JSON metrics output (`passes`, `total_coverage`, `packages_checked`, `packages_failed`).
- Support for per-package coverage threshold policy via JSON configuration in `coverage-gate` tool.
### Changed
### Removed
### Fixed
- Hardened `coverage-gate` file input handling by validating and normalizing policy/profile paths before opening files, resolving `G304` findings in `coverage-gate/parse.go`.
- Made release binary builds resilient by installing UPX via `crazy-max/ghaction-upx@v3` and falling back to uncompressed artifacts when UPX is still unavailable in both `release.yml` and `update-release.yml`.
## [1.1.0] - 2026-03-21
### Breaking
### Added
- Added changelog gate validation to `decorate-pr` action for enforcing changelog updates on qualifying code changes. - Added changelog gate validation to `decorate-pr` action for enforcing changelog updates on qualifying code changes.
- Changelog gate modes: `strict` (fails job on violation) and `soft` (warns via PR comment). - Changelog gate modes: `strict` (fails job on violation) and `soft` (warns via PR comment).
- Docs-only PR exemption with customizable glob patterns for documentation files. - Docs-only PR exemption with customizable glob patterns for documentation files.
@@ -30,13 +61,20 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
- `publish` now extracts tagged release notes through the `vociferate` Go CLI instead of duplicating changelog section parsing in shell. - `publish` now extracts tagged release notes through the `vociferate` Go CLI instead of duplicating changelog section parsing in shell.
- Composite actions now share a centralized `run-vociferate` orchestration flow, with binary-versus-source execution delegated through shared composite actions and single-use runtime/download logic folded back into `run-vociferate.binary`. - Composite actions now share a centralized `run-vociferate` orchestration flow, with binary-versus-source execution delegated through shared composite actions and single-use runtime/download logic folded back into `run-vociferate.binary`.
- `run-vociferate` now contains both binary and source execution flows directly in a single action implementation, removing nested local action wrappers for better runner compatibility. - `run-vociferate` now contains both binary and source execution flows directly in a single action implementation, removing nested local action wrappers for better runner compatibility.
- Release automation now requires `secrets.RELEASE_PAT` for prepare/publish/do-release operations instead of defaulting to `GITHUB_TOKEN`/`GITEA_TOKEN`.
- Renamed the reusable Gitea workflows to `release.yml` and `update-release.yml`, and inlined release publication into the main `release` workflow for clearer per-step job output.
- Release binary builds now compress published linux artifacts with UPX before checksum generation and upload.
### Removed ### Removed
### Fixed ### Fixed
- Prevented `govulncheck-action` from defaulting to `setup-go` version `stable` by explicitly setting `go-version-file` and disabling `check-latest`, avoiding unauthenticated GitHub API rate-limit failures on self-hosted/act-style runners.
- Made `do-release` version resolution resilient to `workflow_call` input passing issues by adding a separate tag detection step that fetches and discovers the latest tag from origin as a fallback when `inputs.tag` is empty, enabling proper operation even when Gitea's workflow_call doesn't pass inputs through correctly. - Made `do-release` version resolution resilient to `workflow_call` input passing issues by adding a separate tag detection step that fetches and discovers the latest tag from origin as a fallback when `inputs.tag` is empty, enabling proper operation even when Gitea's workflow_call doesn't pass inputs through correctly.
- Fixed version resolution in `do-release` workflow by moving version calculation before checkout, resolving from inputs/git tags, and always passing explicit version to `publish` action. - Fixed version resolution in `do-release` workflow by moving version calculation before checkout, resolving from inputs/git tags, and always passing explicit version to `publish` action.
- Fixed tag detection in `do-release` to prioritize the tag at current HEAD (created by `prepare-release`) over the globally latest tag, ensuring correct version is detected when called from `prepare-release` workflow.
- Fixed `do-release` workflow_call resolution on Teacup runners by explicitly falling back to `needs.prepare.outputs.tag` and normalizing `%!t(string=...)` wrapped values before choosing a release tag.
- Fixed release-chain triggering by using a PAT for release commit/tag pushes so downstream release workflows are triggered reliably.
- Made `publish` action version resolution more robust with clearer error messages when version input is missing and workflow is not running from a tag push. - Made `publish` action version resolution more robust with clearer error messages when version input is missing and workflow is not running from a tag push.
- Fixed `do-release` workflow to always checkout the resolved release tag, eliminating conditional checkout logic that could skip the checkout when called from `prepare-release` workflow. - Fixed `do-release` workflow to always checkout the resolved release tag, eliminating conditional checkout logic that could skip the checkout when called from `prepare-release` workflow.
- Pinned `securego/gosec` and `golang/govulncheck-action` to concrete version tags (`v2.22.4` and `v1.0.4`) so self-hosted Gitea runners can resolve them via direct git clone without relying on the GitHub Actions floating-tag API. - Pinned `securego/gosec` and `golang/govulncheck-action` to concrete version tags (`v2.22.4` and `v1.0.4`) so self-hosted Gitea runners can resolve them via direct git clone without relying on the GitHub Actions floating-tag API.
@@ -45,6 +83,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
- Fixed nested local composite-action references to use repository-local `./run-vociferate` paths so strict runners do not misparse parent-directory (`../`) action references as malformed remote coordinates. - Fixed nested local composite-action references to use repository-local `./run-vociferate` paths so strict runners do not misparse parent-directory (`../`) action references as malformed remote coordinates.
- Consolidated `run-vociferate` binary and source execution flows directly into the main `run-vociferate` action to avoid nested local-action path resolution issues on strict runners. - Consolidated `run-vociferate` binary and source execution flows directly into the main `run-vociferate` action to avoid nested local-action path resolution issues on strict runners.
- Hardened workflow module hygiene by retrying `go mod verify` after a module-cache refresh (`go clean -modcache` + `go mod download`) when runners report modified cached dependency directories. - Hardened workflow module hygiene by retrying `go mod verify` after a module-cache refresh (`go clean -modcache` + `go mod download`) when runners report modified cached dependency directories.
- Synced `update-release.yml` with the active release pipeline fixes for Teacup-wrapped outputs, release-id normalization, upload endpoint validation, and accurate success or failure summaries.
## [1.0.2] - 2026-03-21 ## [1.0.2] - 2026-03-21
@@ -151,9 +190,11 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs). - Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
- README guidance focused on primary cross-repository reuse workflows. - README guidance focused on primary cross-repository reuse workflows.
[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.2...main [Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.2.0...main
[1.2.0]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.2...v1.1.0
[1.0.2]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.1...v1.0.2 [1.0.2]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.1...v1.0.2
[1.0.1]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.0...v1.0.1 [1.0.1]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.0...v1.0.1
[1.0.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.2.0...v1.0.0 [1.0.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.2.0...v1.0.0
[0.2.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.1.0...v0.2.0 [0.2.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.hrafn.xyz/aether/vociferate/compare/2060af6...v0.1.0 [0.1.0]: https://git.hrafn.xyz/aether/vociferate/compare/995e397...v0.1.0

View File

@@ -154,8 +154,8 @@ if err != nil {
**Workflows analyzed:** **Workflows analyzed:**
- [push-validation.yml](.gitea/workflows/push-validation.yml) - [push-validation.yml](.gitea/workflows/push-validation.yml)
- [prepare-release.yml](.gitea/workflows/prepare-release.yml) - [release.yml](.gitea/workflows/release.yml)
- [do-release.yml](.gitea/workflows/do-release.yml) - [update-release.yml](.gitea/workflows/update-release.yml)
#### What's Implemented #### What's Implemented
@@ -171,7 +171,7 @@ if err != nil {
- ✅ Coverage badge publication - ✅ Coverage badge publication
- ✅ Release tag recommendation on `main` branch - ✅ Release tag recommendation on `main` branch
**prepare-release.yml:** **release.yml:**
- ✅ Go setup and caching - ✅ Go setup and caching
- ✅ Tests run before release preparation - ✅ Tests run before release preparation
@@ -361,7 +361,7 @@ validate
**Effort Invested:** **Effort Invested:**
- CI/CD improvements: workflow hardening in `push-validation.yml` and `prepare-release.yml` - CI/CD improvements: workflow hardening in `push-validation.yml` and `release.yml`
- Code organization: injected service boundaries for filesystem, environment, and git access - Code organization: injected service boundaries for filesystem, environment, and git access
- Local automation: `justfile` validation parity for format, modules, tests, and security - Local automation: `justfile` validation parity for format, modules, tests, and security
- **Primary commits:** 7cb7b05, 383aad4, 5c903c9 - **Primary commits:** 7cb7b05, 383aad4, 5c903c9

View File

@@ -1,8 +1,8 @@
# vociferate # vociferate
[![Main Validation](https://git.hrafn.xyz/aether/vociferate/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push) [![Main Validation](https://git.hrafn.xyz/aether/vociferate/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
[![Prepare Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/prepare-release.yml/badge.svg?event=workflow_dispatch)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=prepare-release.yml) [![Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/release.yml/badge.svg?event=workflow_dispatch)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=release.yml)
[![Do Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/do-release.yml/badge.svg?event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=do-release.yml) [![Update Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/update-release.yml/badge.svg?event=workflow_dispatch)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=update-release.yml)
[![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage.html) [![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage.html)
`vociferate` is an `Æther` release orchestration tool written in Go for repositories that `vociferate` is an `Æther` release orchestration tool written in Go for repositories that
@@ -17,14 +17,14 @@ revision.
## Use In Other Repositories ## Use In Other Repositories
Vociferate ships composite actions covering release preparation, release publication, coverage badge publishing, and pull request decoration. Vociferate ships composite actions covering release preparation, release publication, coverage badge publishing, and pull request decoration.
Release tags now exist; pin all action and reusable-workflow references to the same released tag (for example, `@v1.0.2`) instead of `@main`. Release tags now exist; pin all action and reusable-workflow references to the same released tag (for example, `@v1.2.0`) instead of `@main`.
For agentic coding partners, see [`AGENTS.md`](AGENTS.md) for a direct integration playbook, selection matrix, and copy-paste workflow patterns. For agentic coding partners, see [`AGENTS.md`](AGENTS.md) for a direct integration playbook, selection matrix, and copy-paste workflow patterns.
### `prepare` — update files, commit, and push tag ### `prepare` — update files, commit, and push tag
```yaml ```yaml
name: Prepare Release name: Release
on: on:
workflow_dispatch: workflow_dispatch:
@@ -41,13 +41,13 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2 - uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0
with: with:
version: ${{ inputs.version }} version: ${{ inputs.version }}
publish: publish:
needs: prepare needs: prepare
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0
with: with:
tag: ${{ needs.prepare.outputs.version }} tag: ${{ needs.prepare.outputs.version }}
secrets: inherit secrets: inherit
@@ -61,20 +61,21 @@ For repositories that embed the version inside source code, pass `version-file`
and `version-pattern`: and `version-pattern`:
```yaml ```yaml
- uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2 - uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0
with: with:
token: ${{ secrets.RELEASE_PAT }}
version-file: internal/myapp/version/version.go version-file: internal/myapp/version/version.go
version-pattern: 'const Version = "([^"]+)"' version-pattern: 'const Version = "([^"]+)"'
git-add-files: CHANGELOG.md internal/myapp/version/version.go git-add-files: CHANGELOG.md internal/myapp/version/version.go
``` ```
`prepare` uses `github.token` internally for authenticated fetch/push operations, `prepare` requires a PAT input for authenticated commit/push/tag operations.
so no token input is required. Pass `token: ${{ secrets.RELEASE_PAT }}` when invoking the action.
### `publish` — create release with changelog notes ### `publish` — create release with changelog notes
```yaml ```yaml
name: Do Release name: Update Release
on: on:
workflow_dispatch: workflow_dispatch:
@@ -85,7 +86,7 @@ on:
jobs: jobs:
release: release:
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0
with: with:
tag: ${{ inputs.tag }} tag: ${{ inputs.tag }}
secrets: inherit secrets: inherit
@@ -95,22 +96,21 @@ Reads the matching section from `CHANGELOG.md` and creates or updates the
Gitea/GitHub release with those notes. The `version` input is optional — when Gitea/GitHub release with those notes. The `version` input is optional — when
omitted it is derived from the current tag ref automatically. omitted it is derived from the current tag ref automatically.
The reusable `Do Release` workflow now runs preflight checks before publish to The reusable `Update Release` workflow now runs preflight checks before publish to
fail fast when the release token is missing or lacks API access. On fail fast when the release token is missing or lacks API access. Set
self-hosted Gitea, set `secrets.GITEA_TOKEN`; on GitHub, `secrets.GITHUB_TOKEN` `secrets.RELEASE_PAT` and use it for prepare/publish release operations.
is used automatically.
The `publish` action outputs `release-id` so you can upload additional release The `publish` action outputs `release-id` so you can upload additional release
assets after it runs: assets after it runs:
```yaml ```yaml
- id: publish - id: publish
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.2.0
- name: Upload my binary - name: Upload my binary
run: | run: |
curl --fail-with-body -X POST \ curl --fail-with-body -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Authorization: token ${{ secrets.RELEASE_PAT }}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
"${{ github.api_url }}/repos/${{ github.repository }}/releases/${{ steps.publish.outputs.release-id }}/assets?name=myapp" \ "${{ github.api_url }}/repos/${{ github.repository }}/releases/${{ steps.publish.outputs.release-id }}/assets?name=myapp" \
--data-binary "@dist/myapp" --data-binary "@dist/myapp"
@@ -125,7 +125,7 @@ Run your coverage tests first, then call the action to generate `coverage.html`,
run: go test -covermode=atomic -coverprofile=coverage.out ./... run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: coverage - id: coverage
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0
with: with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
@@ -150,14 +150,14 @@ with a clear message when token permissions are insufficient.
run: go test -covermode=atomic -coverprofile=coverage.out ./... run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: coverage - id: coverage
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0
with: with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
- name: Decorate pull request - name: Decorate pull request
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0
with: with:
coverage-percentage: ${{ steps.coverage.outputs.total }} coverage-percentage: ${{ steps.coverage.outputs.total }}
badge-url: ${{ steps.coverage.outputs.badge-url }} badge-url: ${{ steps.coverage.outputs.badge-url }}
@@ -170,7 +170,7 @@ Enable changelog validation to enforce that code changes include `Unreleased` ch
```yaml ```yaml
- name: Decorate pull request with changelog gate - name: Decorate pull request with changelog gate
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0
with: with:
coverage-percentage: ${{ steps.coverage.outputs.total }} coverage-percentage: ${{ steps.coverage.outputs.total }}
badge-url: ${{ steps.coverage.outputs.badge-url }} badge-url: ${{ steps.coverage.outputs.badge-url }}
@@ -196,7 +196,7 @@ Decision outputs enable downstream workflow logic:
- name: Decorate PR and check gate - name: Decorate PR and check gate
id: decorate id: decorate
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0
with: with:
coverage-percentage: ${{ steps.coverage.outputs.total }} coverage-percentage: ${{ steps.coverage.outputs.total }}
badge-url: ${{ steps.coverage.outputs.badge-url }} badge-url: ${{ steps.coverage.outputs.badge-url }}

85
coverage-gate/README.md Normal file
View File

@@ -0,0 +1,85 @@
# coveragegate
Standalone coverage-threshold enforcement tool for this repository.
This tool is a quality gate. It is not part of Cue runtime orchestration.
## What it does
- Reads a Go coverage profile (for example `_build/coverage.out`).
- Loads package coverage policy from JSON.
- Discovers packages under a source root using `go list ./...`.
- Evaluates per-package statement coverage against policy thresholds.
- Prints a package table and returns a non-zero exit code when any package fails.
## Repository integration
Primary repository flow:
1. `just test-coverage` runs tests in `src/` and writes `_build/coverage.out`.
2. `scripts/check-core-coverage.sh` runs this tool from `tools/coveragegate/`.
3. The script currently passes:
- `--profile $ROOT_DIR/_build/coverage.out`
- `--policy $ROOT_DIR/docs/coverage-thresholds.json`
- `--src-root $ROOT_DIR/src`
## Usage
From repository root:
```bash
cd tools/coveragegate
go run . \
--profile ../../_build/coverage.out \
--policy ../../docs/coverage-thresholds.json \
--src-root ../../src
```
Or use the repository wrapper:
```bash
bash scripts/check-core-coverage.sh
```
## Flags
- `--profile`: Path to Go coverage profile.
- `--policy`: Path to JSON policy file.
- `--src-root`: Directory where packages are discovered with `go list ./...`.
## Exit codes
- `0`: All in-scope packages meet threshold.
- `1`: Policy/profile/load failure or one or more packages below threshold.
- `2`: Invalid CLI arguments.
## Policy model (current)
The tool expects a JSON object with at least:
- `minimum_statement_coverage` (number)
- `critical_packages` (array)
Each critical package may include:
- `package` (string)
- `minimum_statement_coverage` (number)
- `include` (boolean)
- `exclusions` (array of strings)
Behavior notes:
- If a package has no policy override, the global minimum is used.
- Generated/composition files are excluded by built-in rules.
- Packages with no statements are treated as passing.
## Development
Run tests:
```bash
cd tools/coveragegate
go test ./...
```
Keep code `gofmt` and `go vet` clean.

91
coverage-gate/action.yml Normal file
View File

@@ -0,0 +1,91 @@
name: vociferate/coverage-gate
description: >
Enforce per-package code coverage thresholds against Go coverage profiles.
Supports JSON policy files with per-package overrides and global minimums.
inputs:
profile:
description: Path to Go coverage profile file (output from `go test -coverprofile=...`).
required: false
default: coverage.out
policy:
description: Path to JSON file defining coverage thresholds and per-package overrides.
required: false
default: docs/coverage-thresholds.json
src-root:
description: Source root directory for package discovery (passed to `go list ./...`).
required: false
default: .
summary-file:
description: Optional file path to append markdown summary of coverage results.
required: false
default: ''
outputs:
passed:
description: 'Boolean: true if all packages meet threshold, false if any failed.'
value: ${{ steps.gate.outputs.passed }}
total-coverage:
description: Repository-wide statement coverage percentage.
value: ${{ steps.gate.outputs.total_coverage }}
packages-checked:
description: Number of packages evaluated against policy.
value: ${{ steps.gate.outputs.packages_checked }}
packages-failed:
description: Number of packages below threshold.
value: ${{ steps.gate.outputs.packages_failed }}
runs:
using: composite
steps:
- id: gate
shell: bash
working-directory: ${{ github.action_path }}
env:
PROFILE: ${{ inputs.profile }}
POLICY: ${{ inputs.policy }}
SRC_ROOT: ${{ inputs.src-root }}
SUMMARY_FILE: ${{ inputs.summary-file }}
run: |
set -euo pipefail
# Run coverage gate and capture output
EXIT_CODE=0
OUTPUT=$(go run . \
--profile "$PROFILE" \
--policy "$POLICY" \
--src-root "$SRC_ROOT" \
) || EXIT_CODE=$?
echo "$OUTPUT"
# Parse summary from output (tool prints JSON stats on last line)
SUMMARY_LINE=$(echo "$OUTPUT" | tail -1)
# Determine pass/fail
if [[ $EXIT_CODE -eq 0 ]]; then
echo "passed=true" >> "$GITHUB_OUTPUT"
else
echo "passed=false" >> "$GITHUB_OUTPUT"
fi
# Extract metrics (tool outputs: packages_checked, packages_failed, total_coverage on summary line)
if echo "$SUMMARY_LINE" | jq . &>/dev/null; then
echo "total_coverage=$(echo "$SUMMARY_LINE" | jq -r '.total_coverage')" >> "$GITHUB_OUTPUT"
echo "packages_checked=$(echo "$SUMMARY_LINE" | jq -r '.packages_checked')" >> "$GITHUB_OUTPUT"
echo "packages_failed=$(echo "$SUMMARY_LINE" | jq -r '.packages_failed')" >> "$GITHUB_OUTPUT"
# Append to summary file if provided
if [[ -n "$SUMMARY_FILE" ]]; then
{
echo "## Coverage Gate Results"
echo
echo "- **Passed:** $([ "$EXIT_CODE" -eq 0 ] && echo '✓ Yes' || echo '✗ No')"
echo "- **Total Coverage:** $(echo "$SUMMARY_LINE" | jq -r '.total_coverage')%"
echo "- **Packages Checked:** $(echo "$SUMMARY_LINE" | jq -r '.packages_checked')"
echo "- **Packages Failed:** $(echo "$SUMMARY_LINE" | jq -r '.packages_failed')"
} >> "$SUMMARY_FILE"
fi
fi
exit $EXIT_CODE

3
coverage-gate/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.hrafn.xyz/aether/vociferate/coverage-gate
go 1.26.1

View File

@@ -0,0 +1,26 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestRun_ExitCodeOnInvalidProfile(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
policyPath := filepath.Join(tmp, "policy.json")
if err := os.WriteFile(policyPath, []byte(`{"minimum_statement_coverage":80,"critical_packages":[]}`), 0600); err != nil {
t.Fatalf("write policy: %v", err)
}
exit := run(
[]string{"--profile", filepath.Join(tmp, "missing.out"), "--policy", policyPath, "--src-root", "."},
os.Stdout,
os.Stderr,
func(_ string) ([]string, error) { return []string{"git.hrafn.xyz/aether/cue/service/llm"}, nil },
)
if exit != 1 {
t.Fatalf("expected exit 1 for missing profile, got %d", exit)
}
}

118
coverage-gate/main.go Normal file
View File

@@ -0,0 +1,118 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strings"
)
type discoverPackagesFunc func(srcRoot string) ([]string, error)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, discoverPackages))
}
func run(args []string, stdout io.Writer, stderr io.Writer, discover discoverPackagesFunc) int {
fs := flag.NewFlagSet("coveragegate", flag.ContinueOnError)
fs.SetOutput(stderr)
profilePath := fs.String("profile", "../_build/coverage.out", "path to go coverprofile")
policyPath := fs.String("policy", "../specs/003-testing-time/contracts/coverage-thresholds.json", "path to coverage policy json")
srcRoot := fs.String("src-root", ".", "path to src workspace root")
if err := fs.Parse(args); err != nil {
return 2
}
policy, err := LoadPolicy(*policyPath)
if err != nil {
fmt.Fprintf(stderr, "coverage gate: %v\n", err)
return 1
}
aggByPkg, err := ParseCoverProfile(*profilePath, policy)
if err != nil {
fmt.Fprintf(stderr, "coverage gate: %v\n", err)
return 1
}
pkgs, err := discover(*srcRoot)
if err != nil {
fmt.Fprintf(stderr, "coverage gate: %v\n", err)
return 1
}
results := EvaluateCoverage(pkgs, aggByPkg, policy)
if len(results) == 0 {
fmt.Fprintln(stderr, "coverage gate: no in-scope packages found")
return 1
}
fmt.Fprintln(stdout, "Package coverage results:")
fmt.Fprintln(stdout, "PACKAGE\tCOVERAGE\tTHRESHOLD\tSTATUS")
failed := false
totalCoverage := 0.0
for _, r := range results {
status := "PASS"
if !r.Pass {
status = "FAIL"
failed = true
}
fmt.Fprintf(stdout, "%s\t%.2f%%\t%.2f%%\t%s\n", r.Package, r.Percent, r.Threshold, status)
totalCoverage += r.Percent
}
if len(results) > 0 {
totalCoverage /= float64(len(results))
}
packagesFailed := 0
for _, r := range results {
if !r.Pass {
packagesFailed++
}
}
// Output JSON metrics for CI consumption
metrics := map[string]interface{}{
"passed": !failed,
"total_coverage": fmt.Sprintf("%.2f", totalCoverage),
"packages_checked": len(results),
"packages_failed": packagesFailed,
}
metricsJSON, _ := json.Marshal(metrics)
fmt.Fprintln(stdout, string(metricsJSON))
if failed {
fmt.Fprintln(stderr, "coverage gate: one or more packages are below threshold")
return 1
}
fmt.Fprintln(stdout, "coverage gate: all in-scope packages meet threshold")
return 0
}
func discoverPackages(srcRoot string) ([]string, error) {
cmd := exec.Command("go", "list", "./...")
cmd.Dir = srcRoot
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("discover packages with go list: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
pkgs := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pkgs = append(pkgs, line)
}
sort.Strings(pkgs)
return pkgs, nil
}

View File

@@ -0,0 +1,85 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRun_FailsWhenBelowThreshold(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
policyPath := filepath.Join(tmp, "policy.json")
profilePath := filepath.Join(tmp, "coverage.out")
policy := `{
"minimum_statement_coverage": 80,
"critical_packages": []
}`
if err := os.WriteFile(policyPath, []byte(policy), 0600); err != nil {
t.Fatalf("write policy: %v", err)
}
profile := "mode: set\n" +
"git.hrafn.xyz/aether/cue/service/llm/validator.go:1.1,2.1 10 0\n"
if err := os.WriteFile(profilePath, []byte(profile), 0600); err != nil {
t.Fatalf("write profile: %v", err)
}
var out bytes.Buffer
var errOut bytes.Buffer
exit := run(
[]string{"--profile", profilePath, "--policy", policyPath, "--src-root", "."},
&out,
&errOut,
func(_ string) ([]string, error) {
return []string{"git.hrafn.xyz/aether/cue/service/llm"}, nil
},
)
if exit != 1 {
t.Fatalf("expected exit 1, got %d\nstdout: %s\nstderr: %s", exit, out.String(), errOut.String())
}
if !strings.Contains(errOut.String(), "below threshold") {
t.Fatalf("expected threshold error, got: %s", errOut.String())
}
}
func TestRun_PassesWhenAllMeetThreshold(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
policyPath := filepath.Join(tmp, "policy.json")
profilePath := filepath.Join(tmp, "coverage.out")
policy := `{
"minimum_statement_coverage": 80,
"critical_packages": []
}`
if err := os.WriteFile(policyPath, []byte(policy), 0600); err != nil {
t.Fatalf("write policy: %v", err)
}
profile := "mode: set\n" +
"git.hrafn.xyz/aether/cue/service/llm/validator.go:1.1,2.1 10 1\n"
if err := os.WriteFile(profilePath, []byte(profile), 0600); err != nil {
t.Fatalf("write profile: %v", err)
}
var out bytes.Buffer
var errOut bytes.Buffer
exit := run(
[]string{"--profile", profilePath, "--policy", policyPath, "--src-root", "."},
&out,
&errOut,
func(_ string) ([]string, error) {
return []string{"git.hrafn.xyz/aether/cue/service/llm"}, nil
},
)
if exit != 0 {
t.Fatalf("expected exit 0, got %d\nstdout: %s\nstderr: %s", exit, out.String(), errOut.String())
}
if !strings.Contains(out.String(), "all in-scope packages meet threshold") {
t.Fatalf("expected success summary, got: %s", out.String())
}
}

235
coverage-gate/parse.go Normal file
View File

@@ -0,0 +1,235 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// Policy describes coverage threshold configuration.
type Policy struct {
MinimumStatementCoverage float64 `json:"minimum_statement_coverage"`
CriticalPackages []PackagePolicy `json:"critical_packages"`
}
// PackagePolicy overrides defaults for a specific package.
type PackagePolicy struct {
Package string `json:"package"`
MinimumStatementCoverage float64 `json:"minimum_statement_coverage"`
Include bool `json:"include"`
Exclusions []string `json:"exclusions"`
}
// Coverage aggregates covered and total statements for a package.
type Coverage struct {
Covered int64
Total int64
}
// PackageResult is the policy-evaluated coverage result for one package.
type PackageResult struct {
Package string
Covered int64
Total int64
Percent float64
Threshold float64
Pass bool
}
// LoadPolicy reads policy JSON from disk.
func LoadPolicy(path string) (Policy, error) {
f, err := openValidatedReadOnlyFile(path, ".json", "policy")
if err != nil {
return Policy{}, err
}
defer f.Close()
var p Policy
if err := json.NewDecoder(f).Decode(&p); err != nil {
return Policy{}, fmt.Errorf("decode policy: %w", err)
}
if p.MinimumStatementCoverage <= 0 {
p.MinimumStatementCoverage = 80.0
}
return p, nil
}
// ParseCoverProfile parses a Go coverprofile and aggregates package coverage.
func ParseCoverProfile(profilePath string, policy Policy) (map[string]Coverage, error) {
f, err := openValidatedReadOnlyFile(profilePath, "", "coverage profile")
if err != nil {
return nil, err
}
defer f.Close()
coverage := make(map[string]Coverage)
s := bufio.NewScanner(f)
lineNo := 0
for s.Scan() {
lineNo++
line := strings.TrimSpace(s.Text())
if lineNo == 1 {
if !strings.HasPrefix(line, "mode:") {
return nil, fmt.Errorf("invalid coverage profile header")
}
continue
}
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) != 3 {
return nil, fmt.Errorf("invalid coverage line %d", lineNo)
}
fileAndRange := parts[0]
numStmts, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid statements count at line %d: %w", lineNo, err)
}
execCount, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid execution count at line %d: %w", lineNo, err)
}
idx := strings.Index(fileAndRange, ":")
if idx < 0 {
return nil, fmt.Errorf("invalid file segment at line %d", lineNo)
}
filePath := fileAndRange[:idx]
pkg := filepath.ToSlash(filepath.Dir(filePath))
if isExcludedFile(pkg, filePath, policy) {
continue
}
agg := coverage[pkg]
agg.Total += numStmts
if execCount > 0 {
agg.Covered += numStmts
}
coverage[pkg] = agg
}
if err := s.Err(); err != nil {
return nil, fmt.Errorf("scan coverage profile: %w", err)
}
return coverage, nil
}
func openValidatedReadOnlyFile(path string, requiredExt string, label string) (*os.File, error) {
cleaned := filepath.Clean(strings.TrimSpace(path))
if cleaned == "" || cleaned == "." {
return nil, fmt.Errorf("invalid %s path", label)
}
if requiredExt != "" {
ext := strings.ToLower(filepath.Ext(cleaned))
if ext != strings.ToLower(requiredExt) {
return nil, fmt.Errorf("invalid %s file extension: got %q, want %q", label, ext, requiredExt)
}
}
absPath, err := filepath.Abs(cleaned)
if err != nil {
return nil, fmt.Errorf("resolve %s path: %w", label, err)
}
info, err := os.Stat(absPath)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", label, err)
}
if info.IsDir() {
return nil, fmt.Errorf("%s path must be a file, got directory", label)
}
// #nosec G304 -- path is explicitly cleaned, normalized, and pre-validated as an existing file.
f, err := os.Open(absPath)
if err != nil {
return nil, fmt.Errorf("open %s: %w", label, err)
}
return f, nil
}
// EvaluateCoverage evaluates package coverage against policy thresholds.
func EvaluateCoverage(packages []string, byPackage map[string]Coverage, policy Policy) []PackageResult {
results := make([]PackageResult, 0, len(packages))
for _, pkg := range packages {
if !isPackageIncluded(pkg, policy) {
continue
}
agg := byPackage[pkg]
percent := 100.0
if agg.Total > 0 {
percent = float64(agg.Covered) * 100.0 / float64(agg.Total)
}
threshold := thresholdForPackage(pkg, policy)
pass := agg.Total == 0 || percent >= threshold
results = append(results, PackageResult{
Package: pkg,
Covered: agg.Covered,
Total: agg.Total,
Percent: percent,
Threshold: threshold,
Pass: pass,
})
}
sort.Slice(results, func(i, j int) bool {
return results[i].Package < results[j].Package
})
return results
}
func thresholdForPackage(pkg string, policy Policy) float64 {
for _, entry := range policy.CriticalPackages {
if entry.Package == pkg && entry.MinimumStatementCoverage > 0 {
return entry.MinimumStatementCoverage
}
}
if policy.MinimumStatementCoverage > 0 {
return policy.MinimumStatementCoverage
}
return 80.0
}
func isPackageIncluded(pkg string, policy Policy) bool {
for _, entry := range policy.CriticalPackages {
if entry.Package == pkg {
return entry.Include
}
}
return true
}
func isExcludedFile(pkg string, filePath string, policy Policy) bool {
base := filepath.Base(filePath)
// Exclude known generated artifacts and thin composition wiring.
if strings.HasSuffix(base, "_gen.go") ||
base == "generated.go" ||
base == "models_gen.go" ||
base == "schema.resolvers.go" ||
base == "main.go" {
return true
}
for _, entry := range policy.CriticalPackages {
if entry.Package != pkg {
continue
}
for _, ex := range entry.Exclusions {
if ex == "" {
continue
}
if strings.HasSuffix(filePath, ex) || strings.Contains(filePath, "/"+ex) {
return true
}
}
}
return false
}

119
coverage-gate/parse_test.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseCoverProfile_AppliesExclusions(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
profile := filepath.Join(tmp, "coverage.out")
content := "mode: set\n" +
"git.hrafn.xyz/aether/cue/api/graph/generated.go:1.1,2.1 2 1\n" +
"git.hrafn.xyz/aether/cue/api/graph/resolver.go:1.1,2.1 2 1\n" +
"git.hrafn.xyz/aether/cue/service/llm/validator.go:1.1,2.1 2 0\n"
if err := os.WriteFile(profile, []byte(content), 0600); err != nil {
t.Fatalf("write profile: %v", err)
}
policy := Policy{
MinimumStatementCoverage: 80,
CriticalPackages: []PackagePolicy{
{Package: "git.hrafn.xyz/aether/cue/api/graph", Include: true, Exclusions: []string{"generated.go"}},
},
}
got, err := ParseCoverProfile(profile, policy)
if err != nil {
t.Fatalf("ParseCoverProfile() error = %v", err)
}
api := got["git.hrafn.xyz/aether/cue/api/graph"]
if api.Total != 2 || api.Covered != 2 {
t.Fatalf("api coverage mismatch: got %+v", api)
}
llm := got["git.hrafn.xyz/aether/cue/service/llm"]
if llm.Total != 2 || llm.Covered != 0 {
t.Fatalf("llm coverage mismatch: got %+v", llm)
}
}
func TestEvaluateCoverage_UsesPolicyThresholds(t *testing.T) {
t.Parallel()
pkgs := []string{
"git.hrafn.xyz/aether/cue/service/llm",
"git.hrafn.xyz/aether/cue/service/orchestrator",
}
byPkg := map[string]Coverage{
"git.hrafn.xyz/aether/cue/service/llm": {Covered: 8, Total: 10},
"git.hrafn.xyz/aether/cue/service/orchestrator": {Covered: 3, Total: 10},
}
policy := Policy{
MinimumStatementCoverage: 80,
CriticalPackages: []PackagePolicy{
{Package: "git.hrafn.xyz/aether/cue/service/orchestrator", MinimumStatementCoverage: 30, Include: true},
},
}
results := EvaluateCoverage(pkgs, byPkg, policy)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if !results[0].Pass {
t.Fatalf("expected llm to pass at default threshold: %+v", results[0])
}
if !results[1].Pass {
t.Fatalf("expected orchestrator to pass at overridden threshold: %+v", results[1])
}
}
func TestEvaluateCoverage_NoStatementsPasses(t *testing.T) {
t.Parallel()
pkg := "git.hrafn.xyz/aether/cue/repository/vector"
results := EvaluateCoverage(
[]string{pkg},
map[string]Coverage{pkg: {Covered: 0, Total: 0}},
Policy{MinimumStatementCoverage: 80},
)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if !results[0].Pass {
t.Fatalf("expected pass for no-statement package, got %+v", results[0])
}
}
func TestLoadPolicy_RejectsNonJSONPath(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
policyPath := filepath.Join(tmp, "policy.yaml")
if err := os.WriteFile(policyPath, []byte("minimum_statement_coverage: 80\n"), 0600); err != nil {
t.Fatalf("write policy file: %v", err)
}
_, err := LoadPolicy(policyPath)
if err == nil {
t.Fatal("expected LoadPolicy to fail for non-json extension")
}
if !strings.Contains(err.Error(), "invalid policy file extension") {
t.Fatalf("expected extension error, got: %v", err)
}
}
func TestParseCoverProfile_RejectsDirectoryPath(t *testing.T) {
t.Parallel()
_, err := ParseCoverProfile(t.TempDir(), Policy{MinimumStatementCoverage: 80})
if err == nil {
t.Fatal("expected ParseCoverProfile to fail for directory path")
}
if !strings.Contains(err.Error(), "coverage profile path must be a file") {
t.Fatalf("expected directory path error, got: %v", err)
}
}

View File

@@ -42,6 +42,11 @@ inputs:
custom version-file. custom version-file.
required: false required: false
default: 'CHANGELOG.md release-version' default: 'CHANGELOG.md release-version'
token:
description: >
Personal access token used to authenticate commit, push, and tag
operations. Required to ensure downstream workflows trigger on tag push.
required: true
outputs: outputs:
version: version:
@@ -114,7 +119,7 @@ runs:
- name: Commit and push release - name: Commit and push release
shell: bash shell: bash
env: env:
TOKEN: ${{ github.token }} TOKEN: ${{ inputs.token }}
GIT_USER_NAME: ${{ inputs.git-user-name }} GIT_USER_NAME: ${{ inputs.git-user-name }}
GIT_USER_EMAIL: ${{ inputs.git-user-email }} GIT_USER_EMAIL: ${{ inputs.git-user-email }}
GIT_ADD_FILES: ${{ inputs.git-add-files }} GIT_ADD_FILES: ${{ inputs.git-add-files }}
@@ -124,6 +129,11 @@ runs:
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -z "${TOKEN:-}" ]]; then
echo "A release PAT is required. Provide inputs.token (for example secrets.RELEASE_PAT)." >&2
exit 1
fi
case "$GITHUB_SERVER_URL" in case "$GITHUB_SERVER_URL" in
https://*) https://*)
authed_remote="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" authed_remote="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"

View File

@@ -7,10 +7,9 @@ description: >
inputs: inputs:
token: token:
description: > description: >
Token used to authenticate release API calls. Defaults to the Personal access token used to authenticate release API calls.
workflow token. Required to support release updates across workflow boundaries.
required: false required: true
default: ''
version: version:
description: > description: >
Semantic version to publish (with or without leading v). When omitted, Semantic version to publish (with or without leading v). When omitted,
@@ -91,41 +90,66 @@ runs:
id: create-release id: create-release
shell: bash shell: bash
env: env:
TOKEN: ${{ inputs.token != '' && inputs.token || github.token }} TOKEN: ${{ inputs.token }}
TAG_NAME: ${{ steps.resolve-version.outputs.tag }} TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
RELEASE_NOTES_FILE: ${{ steps.write-notes.outputs.notes_file }} RELEASE_NOTES_FILE: ${{ steps.write-notes.outputs.notes_file }}
GITHUB_API_URL: ${{ github.api_url }} GITHUB_API_URL: ${{ github.api_url }}
GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}
run: | run: |
set -euo pipefail set -euo pipefail
parse_release_id() {
local json_file="$1"
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import json, sys; payload = json.load(open(sys.argv[1], encoding="utf-8")); value = payload.get("id"); print(value if isinstance(value, int) else "")' "$json_file"
return
fi
# Fallback for environments without python3.
sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$json_file" | head -n 1
}
raw_token="$(printf '%s' "${TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ "$raw_token" =~ ^%\!t\(string=(.*)\)$ ]]; then
raw_token="${BASH_REMATCH[1]}"
fi
api_token="$(printf '%s' "$raw_token" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -z "$api_token" ]]; then
echo "inputs.token is required (set to secrets.RELEASE_PAT)." >&2
exit 1
fi
release_notes="$(cat "$RELEASE_NOTES_FILE")" release_notes="$(cat "$RELEASE_NOTES_FILE")"
escaped_release_notes="$(printf '%s' "$release_notes" | sed 's/\\/\\\\/g; s/"/\\"/g; :a;N;$!ba;s/\n/\\n/g')" 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_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases"
release_by_tag_api="${release_api}/tags/${TAG_NAME}" release_by_tag_api="${release_api}/tags/${TAG_NAME}"
status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \ status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${api_token}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${release_by_tag_api}")" "${release_by_tag_api}")"
if [[ "$status_code" == "200" ]]; then 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)" existing_release_id="$(parse_release_id release-existing.json)"
if [[ -z "$existing_release_id" ]]; then if [[ -z "$existing_release_id" ]]; then
echo "Failed to parse existing release id for ${TAG_NAME}" >&2 echo "Failed to parse existing release id for ${TAG_NAME}" >&2
cat release-existing.json >&2 cat release-existing.json >&2
exit 1 exit 1
fi fi
curl --fail-with-body \ if ! curl --fail-with-body \
-X PATCH \ -X PATCH \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${api_token}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${release_api}/${existing_release_id}" \ "${release_api}/${existing_release_id}" \
--data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \ --data "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json --output release.json; then
cat release.json >&2 || true
exit 1
fi
echo "id=$existing_release_id" >> "$GITHUB_OUTPUT" echo "id=$existing_release_id" >> "$GITHUB_OUTPUT"
elif [[ "$status_code" != "404" ]]; then elif [[ "$status_code" != "404" ]]; then
@@ -133,15 +157,18 @@ runs:
cat release-existing.json >&2 cat release-existing.json >&2
exit 1 exit 1
else else
curl --fail-with-body \ if ! curl --fail-with-body \
-X POST \ -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${api_token}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${release_api}" \ "${release_api}" \
--data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \ --data "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json --output release.json; then
cat release.json >&2 || true
exit 1
fi
release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release.json | head -n 1)" release_id="$(parse_release_id release.json)"
if [[ -z "$release_id" ]]; then if [[ -z "$release_id" ]]; then
echo "Failed to parse release id from API response" >&2 echo "Failed to parse release id from API response" >&2
cat release.json >&2 cat release.json >&2

View File

@@ -1 +1 @@
1.0.2 1.2.0