Compare commits
24 Commits
925c99bb9e
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45bb09af27 | ||
|
|
995e397bff | ||
|
|
8bf7184479 | ||
|
|
41918cd5de | ||
|
|
0cec30c9bb | ||
|
|
24dd65da67 | ||
|
|
1ab56b0536 | ||
|
|
6919061240 | ||
|
|
7b739e04c8 | ||
|
|
98ea91f2df | ||
|
|
532f6a98d8 | ||
|
|
a3e2b4e44e | ||
|
|
f82dace4b2 | ||
|
|
81dced6ada | ||
|
|
62693935d0 | ||
|
|
c0b5ec385c | ||
|
|
84f6fbcfc8 | ||
|
|
4a2d234ba3 | ||
|
|
4841b04076 | ||
|
|
993768ae9b | ||
|
|
624b9d154c | ||
|
|
53a097784e | ||
|
|
511110f466 | ||
|
|
d5170b6874 |
@@ -1,159 +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: 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 }}
|
|
||||||
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
|
|
||||||
@@ -30,7 +30,7 @@ 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: false
|
check-latest: false
|
||||||
cache: true
|
cache: true
|
||||||
cache-dependency-path: go.sum
|
cache-dependency-path: go.sum
|
||||||
@@ -124,7 +124,7 @@ 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: false
|
check-latest: false
|
||||||
cache: true
|
cache: true
|
||||||
cache-dependency-path: go.sum
|
cache-dependency-path: go.sum
|
||||||
|
|||||||
466
.gitea/workflows/release.yml
Normal file
466
.gitea/workflows/release.yml
Normal 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
|
||||||
@@ -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
|
||||||
@@ -59,7 +59,6 @@ jobs:
|
|||||||
id: resolve-version
|
id: resolve-version
|
||||||
env:
|
env:
|
||||||
INPUT_TAG: ${{ inputs.tag }}
|
INPUT_TAG: ${{ inputs.tag }}
|
||||||
CALLER_TAG: ${{ needs.prepare.outputs.tag }}
|
|
||||||
DETECTED_TAG: ${{ steps.detect-tag.outputs.detected_tag }}
|
DETECTED_TAG: ${{ steps.detect-tag.outputs.detected_tag }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -79,17 +78,11 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
input_tag="$(normalize_candidate "${INPUT_TAG}")"
|
input_tag="$(normalize_candidate "${INPUT_TAG}")"
|
||||||
caller_tag="$(normalize_candidate "${CALLER_TAG}")"
|
|
||||||
detected_tag="$(normalize_candidate "${DETECTED_TAG}")"
|
detected_tag="$(normalize_candidate "${DETECTED_TAG}")"
|
||||||
|
|
||||||
# Try explicit input first.
|
# Try explicit input first.
|
||||||
requested_tag="$input_tag"
|
requested_tag="$input_tag"
|
||||||
|
|
||||||
# Fall back to caller workflow output when workflow_call input forwarding is unreliable.
|
|
||||||
if [[ -z "$requested_tag" && -n "$caller_tag" ]]; then
|
|
||||||
requested_tag="$caller_tag"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fall back to detected tag if neither input nor caller tag is available.
|
# 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"
|
||||||
@@ -107,7 +100,6 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "Error: Could not resolve release version" >&2
|
echo "Error: Could not resolve release version" >&2
|
||||||
echo " - inputs.tag(raw): '$INPUT_TAG'" >&2
|
echo " - inputs.tag(raw): '$INPUT_TAG'" >&2
|
||||||
echo " - needs.prepare.outputs.tag(raw): '$CALLER_TAG'" >&2
|
|
||||||
echo " - detected_tag(raw): '${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
|
||||||
@@ -125,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: false
|
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 }}
|
||||||
@@ -137,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
|
||||||
|
|
||||||
@@ -163,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
|
||||||
@@ -172,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
|
||||||
@@ -180,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
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -194,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")"
|
||||||
@@ -223,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 "## Release Published"
|
||||||
echo
|
echo
|
||||||
echo "- Tag: ${TAG_NAME}"
|
echo "- Tag: ${TAG_NAME}"
|
||||||
echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}."
|
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 "- 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"
|
} >> "$SUMMARY_FILE"
|
||||||
|
else
|
||||||
- name: Summary
|
{
|
||||||
if: ${{ always() }}
|
echo "## Release Failed"
|
||||||
run: |
|
echo
|
||||||
set -euo pipefail
|
echo "- Tag: ${TAG_NAME:-unknown}"
|
||||||
|
echo "- Create or update release step did not complete successfully."
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
echo 'Summary'
|
echo 'Summary'
|
||||||
echo
|
echo
|
||||||
@@ -268,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
|
||||||
@@ -284,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 }}
|
||||||
65
AGENTS.md
65
AGENTS.md
@@ -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:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
version: ${{ inputs.version }}
|
||||||
- id: publish
|
secrets: inherit
|
||||||
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2
|
```
|
||||||
|
|
||||||
|
### 2. Update Existing Release Tag
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0
|
||||||
with:
|
with:
|
||||||
version: v1.2.3
|
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 }}
|
||||||
|
|||||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -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,6 +61,9 @@ 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
|
||||||
|
|
||||||
@@ -40,6 +74,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
|
|||||||
- 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 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 `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.
|
||||||
@@ -48,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
|
||||||
|
|
||||||
@@ -154,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -1,8 +1,8 @@
|
|||||||
# vociferate
|
# vociferate
|
||||||
|
|
||||||
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
|
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
|
||||||
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=prepare-release.yml)
|
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=release.yml)
|
||||||
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=do-release.yml)
|
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=update-release.yml)
|
||||||
[](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage.html)
|
[](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
85
coverage-gate/README.md
Normal 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
91
coverage-gate/action.yml
Normal 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
3
coverage-gate/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module git.hrafn.xyz/aether/vociferate/coverage-gate
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
26
coverage-gate/integration_test.go
Normal file
26
coverage-gate/integration_test.go
Normal 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
118
coverage-gate/main.go
Normal 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
|
||||||
|
}
|
||||||
85
coverage-gate/main_test.go
Normal file
85
coverage-gate/main_test.go
Normal 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
235
coverage-gate/parse.go
Normal 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
119
coverage-gate/parse_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.0.2
|
1.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user