Files
goldenChart/ta4j-master/.github/workflows/release-health.yml
T
2026-05-23 15:11:48 +09:00

629 lines
24 KiB
YAML

name: Release Health Check
concurrency:
group: release-health-${{ github.repository }}
on:
push:
branches:
- master
schedule:
- cron: "0 9 * * *"
workflow_run:
workflows: ["Publish Release to Maven Central", "Publish Snapshot to Maven Central"]
types:
- completed
workflow_dispatch:
inputs:
staleDays:
description: "Days before a release PR is considered stale."
required: false
default: "7"
type: string
dryRun:
description: "Run release health in dry-run mode (audit only; no discussion mutation)."
required: false
default: true
type: boolean
permissions:
contents: read
pull-requests: read
discussions: write
jobs:
health:
name: Release health checks
runs-on: ubuntu-latest
if: |
github.event_name != 'workflow_run' ||
(
github.event.workflow_run.head_repository.full_name == github.repository
)
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Normalize dry run input
id: dry_run
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
raw="${{ github.event.inputs.dryRun }}"
if [ -z "$raw" ]; then
raw=true
fi
normalized=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')
if [ "$normalized" = "false" ] || [ "$normalized" = "0" ] || [ "$normalized" = "no" ]; then
dry_run=false
else
dry_run=true
fi
else
raw=""
dry_run=false
fi
echo "audit:dry_run_input_raw='${raw}'"
echo "audit:dry_run_normalized=$dry_run"
echo "dryRun=$dry_run" >> $GITHUB_OUTPUT
- name: Fetch tags
run: git fetch --tags
- name: Resolve default branch
id: default_branch
run: |
set -euo pipefail
echo "::group::Resolve default branch"
branch="${{ github.event.repository.default_branch }}"
if [ -z "$branch" ]; then
branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || true)
fi
if [ -z "$branch" ]; then
branch=$(git remote show origin 2>/dev/null | awk '/HEAD branch/ {print $NF}' || true)
fi
if [ -z "$branch" ]; then
echo "Default branch not provided by event or git." >&2
exit 1
fi
echo "branch=$branch" >> $GITHUB_OUTPUT
echo "audit:default_branch=$branch"
echo "::endgroup::"
- name: Collect tag and version state
id: state
env:
DEFAULT_BRANCH: ${{ steps.default_branch.outputs.branch }}
run: |
set -euo pipefail
echo "::group::Collect release tag and version state"
git fetch origin "$DEFAULT_BRANCH" --tags
target_ref="origin/${DEFAULT_BRANCH}"
tag_state_file=$(mktemp)
scripts/resolve-release-tags.sh "$target_ref" > "$tag_state_file"
latest_tag="none"
while IFS='=' read -r key value; do
case "$key" in
latest_tag) latest_tag="$value" ;;
esac
done < "$tag_state_file"
cat "$tag_state_file" >> "$GITHUB_OUTPUT"
cp "$tag_state_file" tag-resolution.txt
rm -f "$tag_state_file"
pom_version=$(python3 - <<'PY'
import sys
import xml.etree.ElementTree as ET
def find_version(path):
tree = ET.parse(path)
root = tree.getroot()
def find_first(elem):
if elem is None:
return None
for child in elem:
if child.tag.endswith("version"):
text = (child.text or "").strip()
if text:
return text
return None
return find_first(root) or find_first(root.find("./{*}parent"))
ver = find_version("pom.xml")
if not ver:
sys.exit(1)
print(ver)
PY
) || { echo "Could not read pom.xml version" >&2; exit 1; }
pom_snapshot=false
pom_base="$pom_version"
if [[ "$pom_version" == *-SNAPSHOT ]]; then
pom_snapshot=true
pom_base="${pom_version%-SNAPSHOT}"
fi
snapshot_ok="n/a"
if [ "$latest_tag" != "none" ]; then
latest_base="${latest_tag#v}"
if [ "$pom_snapshot" != "true" ]; then
snapshot_ok=false
else
highest=$(printf '%s\n' "$latest_base" "$pom_base" | sort -V | tail -n1)
if [ "$highest" = "$pom_base" ] && [ "$pom_base" != "$latest_base" ]; then
snapshot_ok=true
else
snapshot_ok=false
fi
fi
fi
notes_present="n/a"
if [ "$latest_tag" != "none" ]; then
if git cat-file -e "${latest_tag}:release/${latest_tag}.md" 2>/dev/null; then
notes_present=true
elif git cat-file -e "${latest_tag}:release/${latest_tag#v}.md" 2>/dev/null; then
notes_present=true
else
notes_present=false
fi
fi
echo "pom_version=$pom_version" >> $GITHUB_OUTPUT
echo "pom_base=$pom_base" >> $GITHUB_OUTPUT
echo "pom_snapshot=$pom_snapshot" >> $GITHUB_OUTPUT
echo "snapshot_ok=$snapshot_ok" >> $GITHUB_OUTPUT
echo "release_notes_present=$notes_present" >> $GITHUB_OUTPUT
echo "::endgroup::"
- name: Resolve snapshot publication state
id: snapshot_publication
env:
POM_VERSION: ${{ steps.state.outputs.pom_version }}
run: |
set -euo pipefail
python3 scripts/release/release_helpers.py snapshot-publication \
--version "${POM_VERSION}" \
--output snapshot-publication.json \
--github-output "$GITHUB_OUTPUT"
- name: Resolve snapshot publication policy
id: snapshot_policy
env:
HEALTH_EVENT_NAME: ${{ github.event_name }}
HEALTH_TRIGGER_WORKFLOW: ${{ github.event.workflow_run.name }}
run: |
set -euo pipefail
python3 scripts/release/release_helpers.py snapshot-publication-policy \
--event-name "${HEALTH_EVENT_NAME}" \
--workflow-name "${HEALTH_TRIGGER_WORKFLOW}" \
--output snapshot-publication-policy.json \
--github-output "$GITHUB_OUTPUT"
- name: Check for stale release PRs
id: stale_prs
uses: actions/github-script@v9
env:
INPUT_STALE_DAYS: ${{ github.event.inputs.staleDays }}
VAR_STALE_DAYS: ${{ vars.RELEASE_PR_STALE_DAYS }}
with:
github-token: ${{ github.token }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const inputDays = process.env.INPUT_STALE_DAYS;
const varDays = process.env.VAR_STALE_DAYS;
const staleDays = Number(inputDays || varDays || "7");
if (!Number.isFinite(staleDays) || staleDays <= 0) {
throw new Error(`Invalid stale day threshold: ${inputDays || varDays}`);
}
const cutoff = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000);
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100
});
const stale = prs.filter((pr) => {
const labels = pr.labels || [];
const hasReleaseLabel = labels.some((label) => label.name === "release");
if (!hasReleaseLabel) {
return false;
}
return new Date(pr.created_at) < cutoff;
});
const lines = stale.map((pr) => {
const ageDays = Math.floor((Date.now() - new Date(pr.created_at)) / (24 * 60 * 60 * 1000));
return `#${pr.number} ${pr.title} (${ageDays} days old)`;
});
core.setOutput("stale_days", staleDays.toString());
core.setOutput("stale_count", stale.length.toString());
core.setOutput("stale_list", lines.join("\n") || "(none)");
- name: Build health summary
id: summary
if: always()
env:
DEFAULT_BRANCH: ${{ steps.default_branch.outputs.branch }}
LATEST_TAG: ${{ steps.state.outputs.latest_tag }}
LAST_REACHABLE_TAG: ${{ steps.state.outputs.last_reachable_tag }}
LAST_FIRST_PARENT_TAG: ${{ steps.state.outputs.last_first_parent_tag }}
LATEST_TAG_REACHABLE: ${{ steps.state.outputs.latest_tag_reachable }}
POM_VERSION: ${{ steps.state.outputs.pom_version }}
POM_BASE: ${{ steps.state.outputs.pom_base }}
POM_SNAPSHOT: ${{ steps.state.outputs.pom_snapshot }}
SNAPSHOT_OK: ${{ steps.state.outputs.snapshot_ok }}
SNAPSHOT_PUBLICATION: ${{ steps.snapshot_publication.outputs.snapshot_publication }}
SNAPSHOT_PUBLICATION_LATEST: ${{ steps.snapshot_publication.outputs.snapshot_publication_latest }}
SNAPSHOT_PUBLICATION_LAST_UPDATED: ${{ steps.snapshot_publication.outputs.snapshot_publication_last_updated }}
SNAPSHOT_PUBLICATION_SOURCE: ${{ steps.snapshot_publication.outputs.snapshot_publication_source }}
SNAPSHOT_PUBLICATION_ENFORCED: ${{ steps.snapshot_policy.outputs.snapshot_publication_enforced }}
SNAPSHOT_PUBLICATION_PENDING_REASON: ${{ steps.snapshot_policy.outputs.snapshot_publication_pending_reason }}
NOTES_PRESENT: ${{ steps.state.outputs.release_notes_present }}
STALE_DAYS: ${{ steps.stale_prs.outputs.stale_days }}
STALE_COUNT: ${{ steps.stale_prs.outputs.stale_count }}
STALE_LIST: ${{ steps.stale_prs.outputs.stale_list }}
RELEASE_NOTIFY_USER: ${{ vars.RELEASE_NOTIFY_USER }}
HEALTH_EVENT_NAME: ${{ github.event_name }}
HEALTH_TRIGGER_WORKFLOW: ${{ github.event.workflow_run.name }}
DRY_RUN: ${{ steps.dry_run.outputs.dryRun }}
run: |
set -euo pipefail
if [ -z "${DEFAULT_BRANCH:-}" ]; then
DEFAULT_BRANCH="(unknown)"
fi
if [ -z "${LATEST_TAG:-}" ]; then
LATEST_TAG="(unknown)"
fi
if [ -z "${LAST_REACHABLE_TAG:-}" ]; then
LAST_REACHABLE_TAG="(unknown)"
fi
if [ -z "${LAST_FIRST_PARENT_TAG:-}" ]; then
LAST_FIRST_PARENT_TAG="(unknown)"
fi
if [ -z "${LATEST_TAG_REACHABLE:-}" ]; then
LATEST_TAG_REACHABLE="(unknown)"
fi
if [ -z "${POM_VERSION:-}" ]; then
POM_VERSION="(unknown)"
fi
if [ -z "${POM_BASE:-}" ]; then
POM_BASE="(unknown)"
fi
if [ -z "${POM_SNAPSHOT:-}" ]; then
POM_SNAPSHOT="(unknown)"
fi
if [ -z "${SNAPSHOT_OK:-}" ]; then
SNAPSHOT_OK="(unknown)"
fi
if [ -z "${SNAPSHOT_PUBLICATION:-}" ]; then
SNAPSHOT_PUBLICATION="unknown"
fi
if [ -z "${SNAPSHOT_PUBLICATION_LATEST:-}" ]; then
SNAPSHOT_PUBLICATION_LATEST="(unknown)"
fi
if [ -z "${SNAPSHOT_PUBLICATION_LAST_UPDATED:-}" ]; then
SNAPSHOT_PUBLICATION_LAST_UPDATED="(unknown)"
fi
if [ -z "${SNAPSHOT_PUBLICATION_SOURCE:-}" ]; then
SNAPSHOT_PUBLICATION_SOURCE="(unknown)"
fi
if [ -z "${SNAPSHOT_PUBLICATION_ENFORCED:-}" ]; then
SNAPSHOT_PUBLICATION_ENFORCED="false"
fi
if [ -z "${SNAPSHOT_PUBLICATION_PENDING_REASON:-}" ]; then
SNAPSHOT_PUBLICATION_PENDING_REASON="(none)"
fi
if [ -z "${NOTES_PRESENT:-}" ]; then
NOTES_PRESENT="(unknown)"
fi
if [ -z "${STALE_DAYS:-}" ]; then
STALE_DAYS="(unknown)"
fi
if [ -z "${STALE_COUNT:-}" ]; then
STALE_COUNT="0"
fi
if [ -z "${STALE_LIST:-}" ]; then
STALE_LIST="(none)"
fi
if [ -z "${HEALTH_EVENT_NAME:-}" ]; then
HEALTH_EVENT_NAME="(unknown)"
fi
if [ -z "${HEALTH_TRIGGER_WORKFLOW:-}" ]; then
HEALTH_TRIGGER_WORKFLOW="(none)"
fi
if [ -z "${DRY_RUN:-}" ]; then
DRY_RUN="false"
fi
effective_snapshot_publication="${SNAPSHOT_PUBLICATION}"
snapshot_publication_note="(none)"
if [ "${SNAPSHOT_PUBLICATION_ENFORCED}" != "true" ] && [[ "${SNAPSHOT_PUBLICATION}" == "false" || "${SNAPSHOT_PUBLICATION}" == "unknown" ]]; then
effective_snapshot_publication="pending"
snapshot_publication_note="${SNAPSHOT_PUBLICATION_PENDING_REASON}"
fi
drift=false
reasons=()
if [ "$LATEST_TAG_REACHABLE" = "false" ]; then
drift=true
reasons+=("latest tag not reachable from ${DEFAULT_BRANCH}")
fi
if [ "$SNAPSHOT_OK" = "false" ]; then
drift=true
reasons+=("pom.xml snapshot version not ahead of latest tag")
fi
if [ "$effective_snapshot_publication" = "false" ]; then
drift=true
reasons+=("current snapshot version not published to Maven snapshot repository")
fi
if [ "$effective_snapshot_publication" = "unknown" ]; then
drift=true
reasons+=("snapshot publication state could not be verified")
fi
if [ "$NOTES_PRESENT" = "false" ]; then
drift=true
reasons+=("missing release notes for latest tag")
fi
if [ "${STALE_COUNT:-0}" -gt 0 ]; then
drift=true
reasons+=("stale release PRs detected")
fi
if [ "${#reasons[@]}" -eq 0 ]; then
reason_text="(none)"
else
reason_text=$(printf '%s\n' "${reasons[@]}")
fi
if [ "$drift" = "true" ]; then
status="FAIL"
else
status="OK"
fi
run_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
timestamp=$(date '+%-I:%M:%S %p on %-m.%-d.%Y')
notify_user="${RELEASE_NOTIFY_USER:-TheCookieLab}"
if [ "$DRY_RUN" = "true" ]; then
marker_run="dry-run"
mutation_plan="would post the release-health summary to the Release Scheduler discussion"
rerun_guidance="rerun release-health.yml with dryRun=false, or let schedule/push/workflow-run triggers publish the health post"
else
marker_run="real"
mutation_plan="posts the latest release-health summary to the Release Scheduler discussion"
rerun_guidance="not needed for this non-dry-run health publication"
fi
cat > notification-body.txt <<EOF
<!-- ta4j:post-type=release-health;run=${marker_run} -->
@${notify_user}
**Release Health Check ${status} at ${timestamp}**
- Repository: ${GITHUB_REPOSITORY}
- Run: ${run_url}
- Default branch: ${DEFAULT_BRANCH}
- Event: ${HEALTH_EVENT_NAME}
- Upstream workflow: ${HEALTH_TRIGGER_WORKFLOW}
- Dry run: ${DRY_RUN}
Tag Health:
- latest tag: ${LATEST_TAG}
- last reachable tag: ${LAST_REACHABLE_TAG}
- last first-parent tag: ${LAST_FIRST_PARENT_TAG}
- latest tag reachable: ${LATEST_TAG_REACHABLE}
- release notes present: ${NOTES_PRESENT}
Version Health:
- pom.xml version: ${POM_VERSION}
- pom.xml base: ${POM_BASE}
- pom.xml snapshot: ${POM_SNAPSHOT}
- snapshot ahead of latest tag: ${SNAPSHOT_OK}
- snapshot publication enforced: ${SNAPSHOT_PUBLICATION_ENFORCED}
- snapshot published: ${effective_snapshot_publication}
- snapshot publication note: ${snapshot_publication_note}
- snapshot metadata latest: ${SNAPSHOT_PUBLICATION_LATEST}
- snapshot metadata last updated: ${SNAPSHOT_PUBLICATION_LAST_UPDATED}
Stale Release PRs (>${STALE_DAYS} days):
- count: ${STALE_COUNT}
${STALE_LIST}
Drift reasons:
${reason_text}
Dry-Run / Mutation Plan:
- mutation plan: ${mutation_plan}
- rerun guidance: ${rerun_guidance}
EOF
echo "drift_found=$drift" >> $GITHUB_OUTPUT
cat > release-health-audit.json <<EOF
{
"status": "${status}",
"drift": "${drift}",
"dryRun": "${DRY_RUN}",
"defaultBranch": "${DEFAULT_BRANCH}",
"latestTag": "${LATEST_TAG}",
"lastReachableTag": "${LAST_REACHABLE_TAG}",
"lastFirstParentTag": "${LAST_FIRST_PARENT_TAG}",
"latestTagReachable": "${LATEST_TAG_REACHABLE}",
"pomVersion": "${POM_VERSION}",
"pomBase": "${POM_BASE}",
"pomSnapshot": "${POM_SNAPSHOT}",
"snapshotOk": "${SNAPSHOT_OK}",
"snapshotPublicationEnforced": "${SNAPSHOT_PUBLICATION_ENFORCED}",
"snapshotPublished": "${effective_snapshot_publication}",
"snapshotPublicationNote": "${snapshot_publication_note}",
"snapshotMetadataLatest": "${SNAPSHOT_PUBLICATION_LATEST}",
"snapshotMetadataLastUpdated": "${SNAPSHOT_PUBLICATION_LAST_UPDATED}",
"snapshotMetadataSource": "${SNAPSHOT_PUBLICATION_SOURCE}",
"releaseNotesPresent": "${NOTES_PRESENT}",
"staleReleasePrCount": "${STALE_COUNT}",
"mutationPlan": "${mutation_plan}",
"rerunGuidance": "${rerun_guidance}",
"runUrl": "${run_url}"
}
EOF
{
echo "## Release Health"
echo ""
echo "- status: ${status}"
echo "- drift: ${drift}"
echo "- dry run: ${DRY_RUN}"
echo "- latest tag: ${LATEST_TAG}"
echo "- last reachable tag: ${LAST_REACHABLE_TAG}"
echo "- latest tag reachable: ${LATEST_TAG_REACHABLE}"
echo "- pom version: ${POM_VERSION}"
echo "- snapshot ahead of latest tag: ${SNAPSHOT_OK}"
echo "- snapshot publication enforced: ${SNAPSHOT_PUBLICATION_ENFORCED}"
echo "- snapshot published: ${effective_snapshot_publication}"
echo "- stale release PR count: ${STALE_COUNT}"
echo "- mutation plan: ${mutation_plan}"
echo "- rerun guidance: ${rerun_guidance}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release health audit artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: release-health-audit-${{ github.run_id }}
path: |
tag-resolution.txt
snapshot-publication.json
snapshot-publication-policy.json
release-health-audit.json
notification-body.txt
if-no-files-found: ignore
retention-days: 14
- name: Post to Release Scheduler discussion
if: always() && steps.dry_run.outputs.dryRun != 'true'
uses: actions/github-script@v9
env:
RELEASE_SCHEDULER_DISCUSSION_NUMBER: ${{ vars.RELEASE_SCHEDULER_DISCUSSION_NUMBER }}
RELEASE_NOTIFY_USER: ${{ vars.RELEASE_NOTIFY_USER }}
with:
github-token: ${{ github.token }}
script: |
const fs = require("fs");
const owner = context.repo.owner;
const repo = context.repo.repo;
const notifyUser = process.env.RELEASE_NOTIFY_USER || "TheCookieLab";
const runUrl = process.env.GITHUB_RUN_ID
? `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
: "(unknown)";
let body;
if (fs.existsSync("notification-body.txt")) {
body = fs.readFileSync("notification-body.txt", "utf8");
} else {
core.warning("notification-body.txt not found; posting fallback message.");
body = `<!-- ta4j:post-type=release-health;run=real -->\n@${notifyUser}\n\n**Release Health Check summary unavailable**\n- Repository: ${process.env.GITHUB_REPOSITORY || `${owner}/${repo}`}\n- Run: ${runUrl}\n\nThe summary step did not produce notification-body.txt. Check earlier steps for failures.`;
}
const rawNumber = process.env.RELEASE_SCHEDULER_DISCUSSION_NUMBER;
const number = rawNumber ? Number(rawNumber) : 1414;
if (!Number.isFinite(number)) {
throw new Error(`Invalid Release Scheduler discussion number: ${rawNumber}`);
}
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
discussion(number: $number) {
id
}
}
}
`;
const queryResult = await github.graphql(query, { owner, repo, number });
const discussionId = queryResult?.repository?.discussion?.id;
if (!discussionId) {
throw new Error(`Discussion ${number} not found in ${owner}/${repo}`);
}
const commentQuery = `
query($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
discussion(number: $number) {
comments(first: 100, after: $cursor) {
nodes {
id
body
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`;
const deleteMutation = `
mutation($id: ID!) {
deleteDiscussionComment(input: { id: $id }) {
clientMutationId
}
}
`;
const healthCheckMarker = "<!-- ta4j:post-type=release-health;run=real -->";
let cursor = null;
let deletedCount = 0;
let scannedCount = 0;
do {
const commentResult = await github.graphql(commentQuery, { owner, repo, number, cursor });
const page = commentResult?.repository?.discussion?.comments;
const nodes = page?.nodes || [];
scannedCount += nodes.length;
const deletions = nodes.filter((comment) => {
if (!comment?.body) {
return false;
}
return comment.body.includes(healthCheckMarker);
});
for (const comment of deletions) {
await github.graphql(deleteMutation, { id: comment.id });
deletedCount += 1;
}
cursor = page?.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null;
} while (cursor);
core.info(`Scanned ${scannedCount} discussion comments; removed ${deletedCount} prior health check posts.`);
const mutation = `
mutation($discussionId: ID!, $body: String!) {
addDiscussionComment(input: { discussionId: $discussionId, body: $body }) {
comment {
url
}
}
}
`;
await github.graphql(mutation, { discussionId, body });
- name: Fail if drift detected
if: steps.summary.outputs.drift_found == 'true'
run: |
echo "::error::Release health check failed due to detected drift."
exit 1