goldenChat base source add
This commit is contained in:
+27
@@ -0,0 +1,27 @@
|
||||
name: Lint GitHub Actions Workflows
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run actionlint
|
||||
uses: rhysd/actionlint@v1.7.12
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
name: Check License Headers
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Check License Headers
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Maven packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
|
||||
- name: Check license headers
|
||||
run: xvfb-run mvn -B license:check
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
name: Create GitHub Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to publish (e.g. 0.22.0)"
|
||||
required: true
|
||||
type: string
|
||||
dryRun:
|
||||
description: "Run in dry-run mode to preview release creation without publishing."
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: github-release-${{ github.repository }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
create_release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TA4J_REPO_TOKEN: ${{ secrets.GH_TA4J_REPO_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Determine checkout ref
|
||||
id: checkout_ref
|
||||
run: |
|
||||
INPUT_TAG="${{ github.event.inputs.tag }}"
|
||||
if [[ -n "$INPUT_TAG" ]]; then
|
||||
# For workflow_dispatch, construct tag ref from input
|
||||
if [[ "$INPUT_TAG" == refs/tags/* ]]; then
|
||||
INPUT_TAG="${INPUT_TAG#refs/tags/}"
|
||||
fi
|
||||
CHECKOUT_REF="refs/tags/${INPUT_TAG}"
|
||||
echo "tag=${INPUT_TAG}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# For tag push events, use the ref directly
|
||||
CHECKOUT_REF="${GITHUB_REF}"
|
||||
fi
|
||||
echo "ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify GH_TA4J_REPO_TOKEN present
|
||||
run: |
|
||||
if [[ -z "${GH_TA4J_REPO_TOKEN:-}" ]]; then
|
||||
echo "::error::GH_TA4J_REPO_TOKEN is required to create GitHub Releases."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.checkout_ref.outputs.ref }}
|
||||
token: ${{ env.GH_TA4J_REPO_TOKEN }}
|
||||
|
||||
- name: Checkout workflow support files
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.sha }}
|
||||
path: workflow-support
|
||||
token: ${{ env.GH_TA4J_REPO_TOKEN }}
|
||||
|
||||
- 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
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Determine Tag + Validate Release Notes File
|
||||
# ------------------------------------------------------------
|
||||
- name: Determine Version
|
||||
id: version
|
||||
run: |
|
||||
echo "::group::Resolve GitHub Release tag and notes"
|
||||
INPUT_TAG="${{ steps.checkout_ref.outputs.tag }}"
|
||||
if [[ -n "$INPUT_TAG" ]]; then
|
||||
TAG="$INPUT_TAG"
|
||||
else
|
||||
if [[ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]]; then
|
||||
echo "Error: 'tag' input is required for workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
TAG="${GITHUB_REF##*/}"
|
||||
fi
|
||||
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::GitHub Release tags must be bare SemVer major.minor.patch values. Got: $TAG"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
NOTES_FILE="release/${TAG}.md"
|
||||
if [[ ! -f "$NOTES_FILE" ]]; then
|
||||
echo "Release notes file $NOTES_FILE not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Preflight release permissions (dry run)
|
||||
if: steps.dry_run.outputs.dryRun == 'true'
|
||||
id: token_preflight
|
||||
env:
|
||||
GH_TA4J_REPO_TOKEN: ${{ env.GH_TA4J_REPO_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
owner = os.environ.get("GITHUB_REPOSITORY_OWNER")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "").split("/", 1)[-1]
|
||||
if not owner or not repo:
|
||||
print("::error::Unable to resolve repository context for permission preflight.")
|
||||
sys.exit(1)
|
||||
|
||||
def viewer_permission(token: str) -> str:
|
||||
query = {
|
||||
"query": "query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){viewerPermission}}",
|
||||
"variables": {"owner": owner, "repo": repo},
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
"https://api.github.com/graphql",
|
||||
data=json.dumps(query).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "ta4j-release-preflight",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.load(resp)
|
||||
if "errors" in data:
|
||||
raise RuntimeError(data["errors"])
|
||||
return data["data"]["repository"]["viewerPermission"]
|
||||
|
||||
def has_write(permission: str) -> bool:
|
||||
return permission in ("WRITE", "MAINTAIN", "ADMIN")
|
||||
|
||||
def check_token(token, label):
|
||||
if not token:
|
||||
print(f"{label}:missing")
|
||||
return None
|
||||
try:
|
||||
permission = viewer_permission(token)
|
||||
print(f"{label}:{permission}")
|
||||
return permission
|
||||
except Exception as exc:
|
||||
print(f"::warning::{label} permission check failed: {exc}")
|
||||
return None
|
||||
|
||||
pat = os.environ.get("GH_TA4J_REPO_TOKEN", "")
|
||||
|
||||
pat_perm = check_token(pat, "GH_TA4J_REPO_TOKEN")
|
||||
|
||||
if not (pat_perm and has_write(pat_perm)):
|
||||
print("::error::GH_TA4J_REPO_TOKEN lacks write permission to create a release.")
|
||||
sys.exit(1)
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
|
||||
fh.write(f"repo_token_permission={pat_perm}\n")
|
||||
PY
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Prepare Release Notes Body
|
||||
# ------------------------------------------------------------
|
||||
# ------------------------------------------------------------
|
||||
# Build artifacts: core, examples, ALL modules
|
||||
# Produces:
|
||||
# - *.jar
|
||||
# - *-javadoc.jar
|
||||
# - *-sources.jar
|
||||
# ------------------------------------------------------------
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
|
||||
- name: Build all module artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Build GitHub Release artifacts"
|
||||
max_attempts=3
|
||||
attempt=1
|
||||
|
||||
while true; do
|
||||
echo "audit:release_artifact_build attempt=${attempt}/${max_attempts}"
|
||||
if mvn -B -DskipTests -Dgpg.skip=true -Pproduction-release package 2>&1 | tee release-artifact-build.log; then
|
||||
break
|
||||
fi
|
||||
|
||||
if ((attempt >= max_attempts)); then
|
||||
echo "::error::Release artifact build failed after ${max_attempts} attempts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
backoff_seconds=$((attempt * 15))
|
||||
echo "::warning::Release artifact build failed on attempt ${attempt}. Retrying in ${backoff_seconds}s."
|
||||
sleep "${backoff_seconds}"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Validate release artifacts
|
||||
id: artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Validate exact GitHub Release artifact manifest"
|
||||
python3 workflow-support/scripts/release/release_helpers.py artifact-manifest \
|
||||
--version "${{ steps.version.outputs.tag }}" \
|
||||
--output artifact-manifest.txt \
|
||||
--github-output "$GITHUB_OUTPUT" \
|
||||
--strict
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Dry-run summary
|
||||
if: steps.dry_run.outputs.dryRun == 'true'
|
||||
run: |
|
||||
echo "DRY RUN ENABLED: build completed; release publish skipped."
|
||||
echo "Tag: ${{ steps.version.outputs.tag }}"
|
||||
echo "Release notes: ${{ steps.version.outputs.notes_file }}"
|
||||
echo "GH_TA4J_REPO_TOKEN permission: ${{ steps.token_preflight.outputs.repo_token_permission }}"
|
||||
echo "Artifacts:"
|
||||
files="${{ steps.artifacts.outputs.files }}"
|
||||
if [[ -z "${files}" ]]; then
|
||||
echo " (none found)"
|
||||
else
|
||||
while IFS= read -r file; do
|
||||
echo " - ${file#./}"
|
||||
done <<< "${files}"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Upload full artifact set to GitHub Release
|
||||
# ------------------------------------------------------------
|
||||
- name: Create GitHub Release
|
||||
if: steps.dry_run.outputs.dryRun != 'true'
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
body_path: ${{ steps.version.outputs.notes_file }}
|
||||
files: ${{ steps.artifacts.outputs.files }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ env.GH_TA4J_REPO_TOKEN }}
|
||||
|
||||
- name: GitHub Release summary
|
||||
if: always()
|
||||
env:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.dryRun }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
NOTES_FILE: ${{ steps.version.outputs.notes_file }}
|
||||
run: |
|
||||
if [ "${DRY_RUN:-false}" = "true" ]; then
|
||||
mutation_plan="would create/update the GitHub Release and upload artifacts for ${TAG:-unknown}"
|
||||
rerun_guidance="rerun github-release.yml with dryRun=false for this tag, or let the tag push path run"
|
||||
else
|
||||
mutation_plan="completed GitHub Release publication"
|
||||
rerun_guidance="not needed for this non-dry-run GitHub Release run"
|
||||
fi
|
||||
cat > github-release-audit.json <<EOF
|
||||
{
|
||||
"dryRun": "${DRY_RUN:-}",
|
||||
"tag": "${TAG:-}",
|
||||
"notesFile": "${NOTES_FILE:-}",
|
||||
"artifactManifest": "artifact-manifest.txt",
|
||||
"mutationPlan": "${mutation_plan}",
|
||||
"rerunGuidance": "${rerun_guidance}",
|
||||
"runUrl": "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
}
|
||||
EOF
|
||||
{
|
||||
echo "## GitHub Release"
|
||||
echo ""
|
||||
echo "- dry run: ${DRY_RUN:-unknown}"
|
||||
echo "- tag: ${TAG:-unknown}"
|
||||
echo "- notes file: ${NOTES_FILE:-unknown}"
|
||||
echo "- artifact manifest: artifact-manifest.txt"
|
||||
echo "- mutation plan: ${mutation_plan}"
|
||||
echo "- rerun guidance: ${rerun_guidance}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload GitHub Release audit artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: github-release-audit-${{ github.run_id }}
|
||||
path: |
|
||||
github-release-audit.json
|
||||
artifact-manifest.txt
|
||||
release-artifact-build.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
+1022
File diff suppressed because it is too large
Load Diff
+1079
File diff suppressed because it is too large
Load Diff
+179
@@ -0,0 +1,179 @@
|
||||
name: Release Merge Freeze
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
- edited
|
||||
- labeled
|
||||
- unlabeled
|
||||
- closed
|
||||
|
||||
env:
|
||||
RELEASE_FREEZE_AUTHORS: ${{ vars.RELEASE_OWNER || 'TheCookieLab' }}
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
enforce-release-freeze:
|
||||
name: Enforce release freeze on master merges
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Block non-release merges while release PR is open
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const baseBranch = pr?.base?.ref || context.payload.repository?.default_branch;
|
||||
const noticeMarker = "<!-- ta4j:release-freeze-notice -->";
|
||||
const releaseLabel = "release";
|
||||
const action = context.payload.action;
|
||||
const trustedAuthors = new Set(
|
||||
(process.env.RELEASE_FREEZE_AUTHORS || "TheCookieLab")
|
||||
.split(",")
|
||||
.map((author) => (author || "").trim().toLowerCase())
|
||||
.filter((author) => author.length > 0),
|
||||
);
|
||||
|
||||
if (!pr || !baseBranch) {
|
||||
core.info(`Skipping release freeze check: missing pull request context or base branch.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLabel = (labels, name) =>
|
||||
(labels || []).some((label) => label.name === name);
|
||||
|
||||
const isReleaseBranch = (candidate) =>
|
||||
(candidate?.head?.ref || "").startsWith("release/");
|
||||
|
||||
const isTrustedReleaseAuthor = (candidate) =>
|
||||
trustedAuthors.has((candidate?.user?.login || "").toLowerCase());
|
||||
|
||||
const isTrustedReleasePr = (candidate) =>
|
||||
hasLabel(candidate.labels, releaseLabel) &&
|
||||
isTrustedReleaseAuthor(candidate) &&
|
||||
isReleaseBranch(candidate);
|
||||
|
||||
const buildReleaseSummary = (releasePr) =>
|
||||
releasePr.map((candidate) => `- [#${candidate.number}](${candidate.html_url})`).join("\n");
|
||||
|
||||
const findNoticeComment = async (issueNumber) => {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
return comments.find((comment) =>
|
||||
comment.user?.type === "Bot" && comment.body && comment.body.includes(noticeMarker)
|
||||
);
|
||||
};
|
||||
|
||||
const removeNoticeComment = async (issueNumber) => {
|
||||
const notice = await findNoticeComment(issueNumber);
|
||||
if (!notice) {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: notice.id
|
||||
});
|
||||
};
|
||||
|
||||
const upsertNoticeComment = async (issueNumber, releasePrLinks) => {
|
||||
const existing = await findNoticeComment(issueNumber);
|
||||
const body = [
|
||||
`${noticeMarker}`,
|
||||
"⚠️ Release Freeze is active.",
|
||||
"",
|
||||
`One or more trusted release PRs (label \`release\`, branch \`release/*\`) are currently open against \`${baseBranch}\`, so non-release merges are blocked until these PRs merge or close:`,
|
||||
releasePrLinks,
|
||||
"",
|
||||
"Please merge/close the active release PRs first."
|
||||
].join("\n");
|
||||
|
||||
if (existing && existing.body === body) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body
|
||||
});
|
||||
};
|
||||
|
||||
if (!pr || pr.base.ref !== baseBranch) {
|
||||
core.info(`Skipping release freeze check for PR #${pr?.number}. Base is ${pr?.base?.ref ?? "unknown"}, expected ${baseBranch}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isReleasePr = isTrustedReleasePr(pr);
|
||||
|
||||
const openPrs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
base: baseBranch
|
||||
});
|
||||
|
||||
const releasePrs = openPrs.filter((candidate) => isTrustedReleasePr(candidate));
|
||||
|
||||
const openNonReleasePrs = openPrs.filter((candidate) =>
|
||||
!hasLabel(candidate.labels, releaseLabel)
|
||||
);
|
||||
const releasePrLinks = buildReleaseSummary(releasePrs);
|
||||
|
||||
for (const candidate of openNonReleasePrs) {
|
||||
try {
|
||||
if (releasePrs.length > 0) {
|
||||
await upsertNoticeComment(candidate.number, releasePrLinks);
|
||||
} else {
|
||||
await removeNoticeComment(candidate.number);
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to sync release-freeze notice for PR #${candidate.number}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (releasePrs.length === 0) {
|
||||
core.info("No open release PRs detected; merge allowed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReleasePr) {
|
||||
core.info(`PR #${pr.number} is a trusted release PR; freeze check is informational only.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "closed") {
|
||||
core.info(`Skipping merge block for closed PR #${pr.number}; freeze notice cleanup already handled.`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setFailed(`Release freeze is active on ${baseBranch}. Merge blocked while release PRs are open: ${releasePrLinks}. Close or merge the release PR(s) before merging this PR.`);
|
||||
+628
@@ -0,0 +1,628 @@
|
||||
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
|
||||
+998
@@ -0,0 +1,998 @@
|
||||
# Flow:
|
||||
# - analyze (checkout, diff, changelog, AI decision, compute version) -> job outputs
|
||||
# - approval (environment-protected) -> reserved for future major-release re-enable
|
||||
# - publish (patch/minor) -> depends on analyze
|
||||
# - publish_major -> reserved for future major-release re-enable
|
||||
#
|
||||
#
|
||||
# NOTE: major bumps are currently disabled and downgraded to minor.
|
||||
# The major-release approval path is kept for future re-enable.
|
||||
|
||||
name: AI Semantic Release Scheduler
|
||||
|
||||
concurrency:
|
||||
group: semantic-release-ai-${{ github.repository }}
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 */14 * *" # every 14 days
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dryRun:
|
||||
description: "Simulate only; trigger prepare-release.yml with dryRun=true"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
discussions: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze changes & ask AI
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'schedule' || vars.RELEASE_SCHEDULER_ENABLED == 'true' }}
|
||||
outputs:
|
||||
should_release: ${{ steps.parsed.outputs.should_release }}
|
||||
bump: ${{ steps.parsed.outputs.bump }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
reason: ${{ steps.parsed.outputs.reason }}
|
||||
warning: ${{ steps.parsed.outputs.warning }}
|
||||
dryRun: ${{ steps.run_meta.outputs.dryRun }}
|
||||
binary_lines: ${{ steps.diff.outputs.lines }}
|
||||
binary_changes: ${{ steps.diff.outputs.binary_changes }}
|
||||
changelog_len: ${{ steps.changelog.outputs.length }}
|
||||
gate_proceed: ${{ steps.gate.outputs.proceed }}
|
||||
token_present: ${{ steps.model_token.outputs.present }}
|
||||
token_gate: ${{ steps.token_gate.outputs.proceed }}
|
||||
model: ${{ steps.model_catalog.outputs.model_id }}
|
||||
model_max_input_tokens: ${{ steps.model_catalog.outputs.model_max_input_tokens }}
|
||||
model_max_output_tokens: ${{ steps.model_catalog.outputs.model_max_output_tokens }}
|
||||
dossier_chars: ${{ steps.build_request.outputs.dossier_chars }}
|
||||
last_tag: ${{ steps.lasttag.outputs.last_reachable_tag }}
|
||||
last_first_parent_tag: ${{ steps.lasttag.outputs.last_first_parent_tag }}
|
||||
pom_version: ${{ steps.pom_version.outputs.version }}
|
||||
pom_base: ${{ steps.pom_version.outputs.base }}
|
||||
steps:
|
||||
# -----------------------
|
||||
# Setup and Discovery
|
||||
# -----------------------
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch tags
|
||||
run: git fetch --tags
|
||||
|
||||
- name: Resolve release tag baselines
|
||||
id: lasttag
|
||||
run: |
|
||||
default_branch="${{ github.event.repository.default_branch }}"
|
||||
target_ref="origin/${default_branch}"
|
||||
git fetch origin "$default_branch" --tags
|
||||
|
||||
tag_state_file=$(mktemp)
|
||||
scripts/resolve-release-tags.sh "$target_ref" > "$tag_state_file"
|
||||
|
||||
latest_tag="none"
|
||||
last_reachable_tag="none"
|
||||
last_first_parent_tag="none"
|
||||
latest_tag_reachable="n/a"
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
latest_tag) latest_tag="$value" ;;
|
||||
last_reachable_tag) last_reachable_tag="$value" ;;
|
||||
last_first_parent_tag) last_first_parent_tag="$value" ;;
|
||||
latest_tag_reachable) latest_tag_reachable="$value" ;;
|
||||
esac
|
||||
done < "$tag_state_file"
|
||||
|
||||
cat "$tag_state_file" >> "$GITHUB_OUTPUT"
|
||||
rm -f "$tag_state_file"
|
||||
|
||||
echo "audit:last_tag=$last_reachable_tag default_branch=$default_branch first_parent_tag=$last_first_parent_tag prefer_no_v_tags=true"
|
||||
echo "audit:latest_tag=$latest_tag latest_tag_reachable=$latest_tag_reachable"
|
||||
|
||||
- name: Capture event and normalize dry run flag
|
||||
id: run_meta
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
DRY_RUN_INPUT: ${{ github.event.inputs.dryRun }}
|
||||
run: |
|
||||
event="${EVENT_NAME:-}"
|
||||
if [ "$event" = "workflow_dispatch" ]; then
|
||||
raw="${DRY_RUN_INPUT:-true}"
|
||||
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_INPUT:-}"
|
||||
dry_run=false
|
||||
fi
|
||||
|
||||
echo "audit:event_name=$event"
|
||||
echo "audit:dry_run_input_raw='${raw}'"
|
||||
echo "audit:dry_run_normalized=$dry_run"
|
||||
echo "dryRun=$dry_run" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Read version from pom.xml
|
||||
id: pom_version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(python3 -c $'import xml.etree.ElementTree as ET\nimport sys\n\ndef find_version(path):\n tree = ET.parse(path)\n root = tree.getroot()\n def find_first(elem):\n if elem is None:\n return None\n for child in elem:\n if child.tag.endswith(\"version\"):\n text = (child.text or \"\").strip()\n if text:\n return text\n return None\n return find_first(root) or find_first(root.find(\"./{*}parent\"))\n\nver = find_version(\"pom.xml\")\nif not ver:\n sys.exit(1)\nprint(ver)\n') || { echo \"Could not read <version> from pom.xml\" >&2; exit 1; }
|
||||
|
||||
base="$version"
|
||||
is_snapshot=false
|
||||
if [[ "$version" == *-SNAPSHOT ]]; then
|
||||
base="${version%-SNAPSHOT}"
|
||||
is_snapshot=true
|
||||
fi
|
||||
|
||||
echo "audit:pom_version=$version base=$base snapshot=$is_snapshot"
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
echo "base=$base" >> $GITHUB_OUTPUT
|
||||
echo "snapshot=$is_snapshot" >> $GITHUB_OUTPUT
|
||||
|
||||
# -----------------------
|
||||
# Change Detection
|
||||
# -----------------------
|
||||
- name: Collect binary-impacting changes since last release
|
||||
id: diff
|
||||
env:
|
||||
LAST_TAG: ${{ steps.lasttag.outputs.last_reachable_tag }}
|
||||
run: |
|
||||
if [ "$LAST_TAG" = "none" ]; then
|
||||
changed_files=$(git ls-tree -r --name-only HEAD)
|
||||
else
|
||||
changed_files=$(git diff --name-only "$LAST_TAG"..HEAD)
|
||||
fi
|
||||
|
||||
binary_files=$(printf '%s\n' "$changed_files" | grep -E '(^|/)pom\.xml$|(^|/)src/main/' || true)
|
||||
binary_files=$(printf '%s\n' "$binary_files" | sort -u)
|
||||
|
||||
# sanitize: mask URLs, common GH tokens, and long alnum tokens (avoid redacting long Java identifiers)
|
||||
sanitized=$(printf '%s\n' "$binary_files" | perl -pe 's#https?://\S+#[REDACTED_URL]#g; s/(gh[oprsu]_[A-Za-z0-9]{20,})/[REDACTED_TOKEN]/g; s/((?=[A-Za-z0-9]{32,})(?=.*[0-9])[A-Za-z0-9]+)/[REDACTED_SECRET]/g')
|
||||
|
||||
if [ -z "$sanitized" ]; then
|
||||
count=0
|
||||
else
|
||||
count=$(printf '%s\n' "$sanitized" | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
summary="(none)"
|
||||
if [ "$count" -gt 0 ]; then
|
||||
summary=$(printf '%s\n' "$sanitized" | awk -F/ '
|
||||
NF>=3 && $2 == "src" && $3 == "main" {key=$1"/"$2"/"$3; counts[key]++; next}
|
||||
NF>=2 {key=$1"/"$2; counts[key]++; next}
|
||||
{counts[$1]++}
|
||||
END {for (k in counts) printf "%s: %d files\n", k, counts[k]}
|
||||
' | sort)
|
||||
fi
|
||||
|
||||
sample=""
|
||||
if [ "$count" -gt 0 ] && [ "$count" -le 40 ]; then
|
||||
sample="$sanitized"
|
||||
fi
|
||||
|
||||
binary_prompt="$summary"
|
||||
if [ -n "$sample" ]; then
|
||||
binary_prompt="${binary_prompt}\nSample paths:\n${sample}"
|
||||
fi
|
||||
binary_prompt=$(printf '%s' "$binary_prompt" | cut -c1-1200)
|
||||
binary_prompt_b64=$(printf '%s' "$binary_prompt" | base64 -w0)
|
||||
binary_prompt_len=$(printf '%s' "$binary_prompt" | wc -c | tr -d ' ')
|
||||
|
||||
# truncate to 2000 chars for discussion output
|
||||
sanitized_truncated=$(printf '%s' "$sanitized" | cut -c1-2000)
|
||||
|
||||
echo "audit:binary_change_lines=$count sanitized=true truncated=2000 summary_prompt_chars=$binary_prompt_len"
|
||||
diff_b64=$(printf '%s' "$sanitized_truncated" | base64 -w0)
|
||||
echo "binary_prompt_b64=$binary_prompt_b64" >> $GITHUB_OUTPUT
|
||||
echo "binary_prompt_len=$binary_prompt_len" >> $GITHUB_OUTPUT
|
||||
echo "diff_b64=$diff_b64" >> $GITHUB_OUTPUT
|
||||
echo "lines=$count" >> $GITHUB_OUTPUT
|
||||
delim="BIN_CHANGES_$(date +%s%N)"
|
||||
{
|
||||
echo "binary_changes<<$delim"
|
||||
printf '%s\n' "$sanitized_truncated"
|
||||
echo "$delim"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if CHANGELOG exists
|
||||
id: changelog_exists
|
||||
run: |
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "present=false" >> $GITHUB_OUTPUT
|
||||
echo "audit:changelog_present=false"
|
||||
else
|
||||
echo "present=true" >> $GITHUB_OUTPUT
|
||||
echo "audit:changelog_present=true"
|
||||
fi
|
||||
|
||||
- name: Extract Unreleased section from CHANGELOG
|
||||
id: changelog
|
||||
run: |
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "audit:changelog_present=false"
|
||||
echo "changelog_b64=" >> $GITHUB_OUTPUT
|
||||
echo "length=0" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
unreleased=$(awk '
|
||||
BEGIN{inSection=0}
|
||||
/^## *\[?Unreleased\]?/ {inSection=1; next}
|
||||
/^## / && inSection {exit}
|
||||
inSection {print}
|
||||
' CHANGELOG.md)
|
||||
|
||||
filtered=$(printf '%s\n' "$unreleased" | grep -E '^[[:space:]]*(#{2,4}|[-*])' || true)
|
||||
if [ -n "$filtered" ]; then
|
||||
unreleased="$filtered"
|
||||
fi
|
||||
|
||||
# truncate to keep payload bounded and token friendly
|
||||
unreleased=$(printf '%s' "$unreleased" | cut -c1-2000)
|
||||
|
||||
changelog_b64=$(printf '%s' "$unreleased" | base64 -w0)
|
||||
changelog_len=$(printf '%s' "$unreleased" | wc -c | tr -d ' ')
|
||||
echo "audit:changelog_present=true"
|
||||
echo "changelog_b64=$changelog_b64" >> $GITHUB_OUTPUT
|
||||
echo "length=$changelog_len" >> $GITHUB_OUTPUT
|
||||
|
||||
# -----------------------
|
||||
# Gate Checks
|
||||
# -----------------------
|
||||
- name: Check if changes detected
|
||||
id: gate
|
||||
run: |
|
||||
binary_lines=${{ steps.diff.outputs.lines }}
|
||||
changelog_len=${{ steps.changelog.outputs.length }}
|
||||
binary_lines=${binary_lines:-0}
|
||||
changelog_len=${changelog_len:-0}
|
||||
|
||||
if [ "$binary_lines" -eq 0 ]; then
|
||||
echo "proceed=false" >> $GITHUB_OUTPUT
|
||||
echo "audit:short_circuit=true reason=\"no binary-impacting changes\""
|
||||
else
|
||||
echo "proceed=true" >> $GITHUB_OUTPUT
|
||||
echo "audit:short_circuit=false"
|
||||
fi
|
||||
|
||||
- name: No release - no binary-impacting changes
|
||||
if: steps.gate.outputs.proceed != 'true'
|
||||
run: echo "No release recommended (no binary-impacting changes)."
|
||||
|
||||
- name: Check for GH_MODELS_TOKEN
|
||||
id: model_token
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
env:
|
||||
GH_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }}
|
||||
run: |
|
||||
if [ -z "${GH_MODELS_TOKEN:-}" ]; then
|
||||
echo "present=false" >> $GITHUB_OUTPUT
|
||||
echo "audit:models_token_present=false"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::add-mask::$GH_MODELS_TOKEN"
|
||||
echo "GH_MODELS_TOKEN=$GH_MODELS_TOKEN" >> $GITHUB_ENV
|
||||
echo "present=true" >> $GITHUB_OUTPUT
|
||||
echo "audit:models_token_present=true"
|
||||
|
||||
- name: Check token gate
|
||||
id: token_gate
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
run: |
|
||||
present="${{ steps.model_token.outputs.present }}"
|
||||
present=${present:-false}
|
||||
|
||||
if [ "$present" = "true" ]; then
|
||||
echo "proceed=true" >> $GITHUB_OUTPUT
|
||||
echo "audit:short_circuit_token=false"
|
||||
else
|
||||
echo "proceed=false" >> $GITHUB_OUTPUT
|
||||
echo "audit:short_circuit_token=true reason=\"missing GH_MODELS_TOKEN\""
|
||||
fi
|
||||
|
||||
- name: No release - missing GH_MODELS_TOKEN
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed != 'true'
|
||||
run: echo "GH_MODELS_TOKEN not provided; skipping release."
|
||||
|
||||
# -----------------------
|
||||
# AI Analysis
|
||||
# -----------------------
|
||||
- name: Preflight GitHub Models catalog
|
||||
id: model_catalog
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
|
||||
env:
|
||||
RELEASE_AI_MODEL: ${{ vars.RELEASE_AI_MODEL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
model="${RELEASE_AI_MODEL:-openai/gpt-4.1}"
|
||||
echo "::group::GitHub Models catalog preflight"
|
||||
python3 scripts/release/release_helpers.py catalog-preflight \
|
||||
--model "$model" \
|
||||
--output release-ai-model.json
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Build release dossier
|
||||
id: dossier
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Build release dossier"
|
||||
python3 scripts/release/release_helpers.py build-dossier \
|
||||
--last-tag "${{ steps.lasttag.outputs.last_reachable_tag }}" \
|
||||
--current-version "${{ steps.pom_version.outputs.version }}" \
|
||||
--pom-base "${{ steps.pom_version.outputs.base }}" \
|
||||
--max-diff-chars 600000 \
|
||||
--output release-dossier.md \
|
||||
--audit-output release-audit.json
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Build and validate AI request JSON
|
||||
id: build_request
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Build AI request"
|
||||
python3 scripts/release/release_helpers.py build-ai-request \
|
||||
--model "${{ steps.model_catalog.outputs.model_id }}" \
|
||||
--dossier release-dossier.md \
|
||||
--semver-rules .github/workflows/semver-rules-override.txt \
|
||||
--output request.json
|
||||
if ! jq -e '.model and .messages and (.messages | length > 0)' request.json > /dev/null; then
|
||||
echo "audit:request_json_valid=false" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "audit:request_json_valid=true"
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Call AI API with retry
|
||||
id: ai_call
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
|
||||
env:
|
||||
GH_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
echo "::group::Call GitHub Models inference"
|
||||
echo "audit:request_json_size_bytes=$(wc -c < request.json | tr -d ' ')"
|
||||
echo "audit:dossier_chars=${{ steps.build_request.outputs.dossier_chars }}"
|
||||
echo "audit:model=${{ steps.model_catalog.outputs.model_id }}"
|
||||
echo "audit:model_max_input_tokens=${{ steps.model_catalog.outputs.model_max_input_tokens }}"
|
||||
echo "audit:model_max_output_tokens=${{ steps.model_catalog.outputs.model_max_output_tokens }}"
|
||||
echo "audit:external_call target=models.github.ai payload=request.json sanitized=true"
|
||||
|
||||
attempts=3
|
||||
backoff=2
|
||||
response_status=0
|
||||
: > response.json
|
||||
|
||||
for attempt in $(seq 1 "$attempts"); do
|
||||
if response_status=$(curl -s -o response.json -w '%{http_code}' \
|
||||
-X POST --max-time 30 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $GH_MODELS_TOKEN" \
|
||||
https://models.github.ai/inference/chat/completions \
|
||||
--data-binary @request.json); then
|
||||
:
|
||||
else
|
||||
response_status="000"
|
||||
fi
|
||||
|
||||
if [ "$response_status" -eq 200 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
echo "audit:ai_request_retry attempt=$attempt status=$response_status backoff=${backoff}s" >&2
|
||||
if [ "$attempt" -lt "$attempts" ]; then
|
||||
sleep "$backoff"
|
||||
backoff=$((backoff * 2))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "response_status=$response_status" >> $GITHUB_OUTPUT
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Handle AI API failure
|
||||
id: ai_failure
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true' && steps.ai_call.outputs.response_status != '200'
|
||||
env:
|
||||
RESPONSE_STATUS: ${{ steps.ai_call.outputs.response_status }}
|
||||
ATTEMPTS: 3
|
||||
run: |
|
||||
err_msg=$(jq -r '.error.message // empty' response.json 2>/dev/null || true)
|
||||
err_msg=${err_msg:-"AI call failed (http ${RESPONSE_STATUS})"}
|
||||
echo "audit:ai_request_failed attempts=$ATTEMPTS status=$RESPONSE_STATUS" >&2
|
||||
echo "response (truncated to 500 chars):"
|
||||
head -c 500 response.json || true
|
||||
fallback=$(jq -n --arg msg "$err_msg" --arg status "$RESPONSE_STATUS" \
|
||||
'{should_release:false,bump:"patch",warning:$msg,reason:$msg}')
|
||||
printf '%s\n' "$fallback" > ai-content.txt
|
||||
delim="AI_RESP_$(date +%s%N)"
|
||||
{
|
||||
echo "content<<$delim"
|
||||
printf '%s\n' "$fallback"
|
||||
echo "$delim"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract AI response content
|
||||
id: ai
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true' && steps.ai_call.outputs.response_status == '200'
|
||||
run: |
|
||||
resp_size=$(wc -c < response.json | tr -d ' ')
|
||||
echo "audit:ai_response_bytes=$resp_size saved_to=response.json"
|
||||
|
||||
if ! jq -e '.choices and .choices[0].message and (.choices[0].message.content != null)' response.json > /dev/null; then
|
||||
echo "audit:ai_response_missing_content=true" >&2
|
||||
delim="AI_RESP_$(date +%s%N)"
|
||||
{
|
||||
echo "content<<$delim"
|
||||
echo ""
|
||||
echo "$delim"
|
||||
} >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
content=$(jq -r '.choices[0].message.content // ""' response.json)
|
||||
printf '%s\n' "$content" > ai-content.txt
|
||||
delim="AI_RESP_$(date +%s%N)"
|
||||
{
|
||||
echo "content<<$delim"
|
||||
printf '%s\n' "$content"
|
||||
echo "$delim"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Parse AI JSON
|
||||
id: parsed
|
||||
if: always()
|
||||
env:
|
||||
GATE_PROCEED: ${{ steps.gate.outputs.proceed }}
|
||||
MODEL_TOKEN_PRESENT: ${{ steps.model_token.outputs.present }}
|
||||
run: |
|
||||
gate_proceed="${GATE_PROCEED:-false}"
|
||||
|
||||
if [ "$gate_proceed" != "true" ]; then
|
||||
echo '{"should_release":false,"bump":"patch","confidence":1.0,"warning":"No binary changes","reason":"No binary-impacting changes detected"}' > ai-content.txt
|
||||
else
|
||||
model_token_present="${MODEL_TOKEN_PRESENT:-false}"
|
||||
if [ "$model_token_present" != "true" ]; then
|
||||
echo "AI call skipped because GH_MODELS_TOKEN is missing." >&2
|
||||
echo '{"should_release":false,"bump":"patch","confidence":1.0,"warning":"GH_MODELS_TOKEN missing","reason":"GH_MODELS_TOKEN secret missing; cannot call AI"}' > ai-content.txt
|
||||
elif [ ! -s ai-content.txt ]; then
|
||||
echo '{"should_release":false,"bump":"patch","confidence":0.0,"warning":"Missing AI response","reason":"AI response content missing"}' > ai-content.txt
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "::group::Parse AI release decision"
|
||||
python3 scripts/release/release_helpers.py parse-decision \
|
||||
--raw-file ai-content.txt \
|
||||
--output release-decision.json \
|
||||
--github-output "$GITHUB_OUTPUT"
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Stop if AI says no release
|
||||
if: steps.parsed.outputs.should_release == 'false'
|
||||
env:
|
||||
REASON: ${{ steps.parsed.outputs.reason }}
|
||||
run: |
|
||||
echo "No release recommended."
|
||||
if [ -n "${REASON:-}" ]; then
|
||||
echo "Reason: ${REASON}"
|
||||
fi
|
||||
|
||||
# -----------------------
|
||||
# Version Computation
|
||||
# -----------------------
|
||||
- name: Compute new version
|
||||
id: version
|
||||
if: steps.parsed.outputs.should_release == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
last="${{ steps.lasttag.outputs.last_reachable_tag }}"
|
||||
current="${{ steps.pom_version.outputs.version }}"
|
||||
base="${{ steps.pom_version.outputs.base }}"
|
||||
bump="${{ steps.parsed.outputs.bump }}"
|
||||
|
||||
if [ -z "$current" ] || [ -z "$base" ]; then
|
||||
echo "Failed to read version from pom.xml" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Normalize base version (allow major.minor or major.minor.patch)
|
||||
base_norm="$base"
|
||||
if [[ "$base" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
|
||||
base_norm="${base}.0"
|
||||
elif [[ "$base" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
: # already normalized
|
||||
else
|
||||
echo "POM version '$current' (base '$base') is not in a supported SemVer format (major.minor or major.minor.patch)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bump_base="$base"
|
||||
# Prevent regressions relative to the last reachable default-branch release tag
|
||||
if [ "$last" != "none" ] && echo "$last" | grep -Eq '^v?[0-9]+\.[0-9]+(\.[0-9]+)?([-+].*)?$'; then
|
||||
last_norm=$(printf '%s\n' "$last" | sed -E 's/^v?([0-9]+)\.([0-9]+)(\.[0-9]+)?([-+].*)?$/\1.\2\3/')
|
||||
last_norm=${last_norm:-}
|
||||
last_norm=${last_norm%.}
|
||||
if echo "$last_norm" | grep -Eq '^[0-9]+\.[0-9]+$'; then
|
||||
last_norm="${last_norm}.0"
|
||||
fi
|
||||
|
||||
if [ -n "$last_norm" ]; then
|
||||
highest=$(printf '%s\n' "$last_norm" "$base_norm" | sort -V | tail -n1)
|
||||
if [ "$highest" != "$base_norm" ]; then
|
||||
echo "POM base version '$base' is behind the last reachable default-branch tag '$last_norm'; refusing to compute a lower release." >&2
|
||||
exit 1
|
||||
fi
|
||||
bump_base="$last_norm"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Use the selected bump base (prefer the last reachable tag when present) for incrementing
|
||||
if [[ "$bump_base" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
|
||||
major="${BASH_REMATCH[1]}"
|
||||
minor="${BASH_REMATCH[2]}"
|
||||
patch=0
|
||||
elif [[ "$bump_base" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
major="${BASH_REMATCH[1]}"
|
||||
minor="${BASH_REMATCH[2]}"
|
||||
patch="${BASH_REMATCH[3]}"
|
||||
else
|
||||
echo "Bump base '$bump_base' is not in a supported SemVer format (major.minor or major.minor.patch)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$bump" in
|
||||
major) major=$((major+1)); minor=0; patch=0 ;;
|
||||
minor) minor=$((minor+1)); patch=0 ;;
|
||||
patch|"") patch=$((patch+1)) ;;
|
||||
*) echo "Invalid bump value: $bump" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
new="${major}.${minor}.${patch}"
|
||||
new_norm="$new"
|
||||
if echo "$new_norm" | grep -Eq '^[0-9]+\.[0-9]+$'; then
|
||||
new_norm="${new_norm}.0"
|
||||
fi
|
||||
|
||||
# Ensure we never release a version lower than the POM base
|
||||
if [ "$(printf '%s\n' "$new_norm" "$base_norm" | sort -V | tail -n1)" != "$new_norm" ]; then
|
||||
echo "Computed version '$new' would be lower than pom.xml base '$base'; raising to pom base." >&2
|
||||
new="$base"
|
||||
new_norm="$base_norm"
|
||||
fi
|
||||
|
||||
if git rev-parse "refs/tags/$new" >/dev/null 2>&1 || git rev-parse "refs/tags/v$new" >/dev/null 2>&1 || { [ "$new" != "$new_norm" ] && (git rev-parse "refs/tags/$new_norm" >/dev/null 2>&1 || git rev-parse "refs/tags/v$new_norm" >/dev/null 2>&1); }; then
|
||||
echo "Computed tag $new (or v$new) already exists. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$new" >> $GITHUB_OUTPUT
|
||||
|
||||
# -----------------------
|
||||
# Summary
|
||||
# -----------------------
|
||||
- name: Debug decision summary
|
||||
if: always()
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
DRY_RUN_INPUT: ${{ github.event.inputs.dryRun }}
|
||||
DRY_RUN: ${{ steps.run_meta.outputs.dryRun }}
|
||||
PROCEED: ${{ steps.gate.outputs.proceed }}
|
||||
TOKEN_PRESENT: ${{ steps.model_token.outputs.present }}
|
||||
TOKEN_GATE: ${{ steps.token_gate.outputs.proceed }}
|
||||
MODEL: ${{ steps.model_catalog.outputs.model_id }}
|
||||
MODEL_MAX_INPUT_TOKENS: ${{ steps.model_catalog.outputs.model_max_input_tokens }}
|
||||
MODEL_MAX_OUTPUT_TOKENS: ${{ steps.model_catalog.outputs.model_max_output_tokens }}
|
||||
DOSSIER_CHARS: ${{ steps.build_request.outputs.dossier_chars }}
|
||||
REQUEST_JSON_SIZE_BYTES: ${{ steps.build_request.outputs.request_json_size_bytes }}
|
||||
SHOULD_RELEASE: ${{ steps.parsed.outputs.should_release }}
|
||||
BUMP: ${{ steps.parsed.outputs.bump }}
|
||||
CONFIDENCE: ${{ steps.parsed.outputs.confidence }}
|
||||
REASON: ${{ steps.parsed.outputs.reason }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
POM_VERSION: ${{ steps.pom_version.outputs.version }}
|
||||
POM_BASE: ${{ steps.pom_version.outputs.base }}
|
||||
LAST_TAG: ${{ steps.lasttag.outputs.last_reachable_tag }}
|
||||
run: |
|
||||
if [ "${DRY_RUN:-false}" = "true" ]; then
|
||||
mutation_plan="would dispatch prepare-release.yml with dryRun=true; no discussion post is created"
|
||||
rerun_guidance="rerun release-scheduler.yml with dryRun=false, or wait for the scheduled release path"
|
||||
else
|
||||
mutation_plan="dispatches prepare-release.yml with dryRun=false when the scheduler decides to release"
|
||||
rerun_guidance="not needed for scheduled production runs"
|
||||
fi
|
||||
|
||||
cat > release-scheduler-mutation-plan.txt <<EOF
|
||||
dryRun=${DRY_RUN:-}
|
||||
computedVersion=${VERSION:-}
|
||||
bump=${BUMP:-}
|
||||
mutationPlan=${mutation_plan}
|
||||
rerunGuidance=${rerun_guidance}
|
||||
EOF
|
||||
|
||||
echo "decision:event_name=${EVENT_NAME:-}"
|
||||
echo "decision:dry_run_input='${DRY_RUN_INPUT:-}' normalized=${DRY_RUN:-}"
|
||||
echo "decision:gate_proceed=${PROCEED:-}"
|
||||
echo "decision:token_present=${TOKEN_PRESENT:-}"
|
||||
echo "decision:token_gate_proceed=${TOKEN_GATE:-}"
|
||||
echo "decision:model=${MODEL:-}"
|
||||
echo "decision:model_max_input_tokens=${MODEL_MAX_INPUT_TOKENS:-}"
|
||||
echo "decision:model_max_output_tokens=${MODEL_MAX_OUTPUT_TOKENS:-}"
|
||||
echo "decision:dossier_chars=${DOSSIER_CHARS:-}"
|
||||
echo "decision:request_json_size_bytes=${REQUEST_JSON_SIZE_BYTES:-}"
|
||||
echo "decision:should_release=${SHOULD_RELEASE:-}"
|
||||
echo "decision:bump=${BUMP:-}"
|
||||
echo "decision:confidence=${CONFIDENCE:-}"
|
||||
echo "decision:reason=${REASON:-}"
|
||||
echo "decision:computed_version=${VERSION:-}"
|
||||
echo "decision:pom_version=${POM_VERSION:-} pom_base=${POM_BASE:-}"
|
||||
echo "decision:last_reachable_default_branch_tag=${LAST_TAG:-}"
|
||||
echo "decision:last_first_parent_tag=${{ steps.lasttag.outputs.last_first_parent_tag }}"
|
||||
{
|
||||
echo "## Release Scheduler Decision"
|
||||
echo ""
|
||||
echo "- model: ${MODEL:-unknown}"
|
||||
echo "- model max input tokens: ${MODEL_MAX_INPUT_TOKENS:-unknown}"
|
||||
echo "- model max output tokens: ${MODEL_MAX_OUTPUT_TOKENS:-unknown}"
|
||||
echo "- dossier chars: ${DOSSIER_CHARS:-0}"
|
||||
echo "- request bytes: ${REQUEST_JSON_SIZE_BYTES:-0}"
|
||||
echo "- should release: ${SHOULD_RELEASE:-unknown}"
|
||||
echo "- bump: ${BUMP:-unknown}"
|
||||
echo "- confidence: ${CONFIDENCE:-unknown}"
|
||||
echo "- computed version: ${VERSION:-none}"
|
||||
echo "- last reachable tag: ${LAST_TAG:-unknown}"
|
||||
echo "- reason: ${REASON:-none}"
|
||||
echo "- mutation plan: ${mutation_plan}"
|
||||
echo "- rerun guidance: ${rerun_guidance}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload release scheduler audit artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-scheduler-audit-${{ github.run_id }}
|
||||
path: |
|
||||
release-ai-model.json
|
||||
release-dossier.md
|
||||
release-audit.json
|
||||
request.json
|
||||
response.json
|
||||
ai-content.txt
|
||||
release-decision.json
|
||||
release-scheduler-mutation-plan.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
# Reserved major-release approval path.
|
||||
# Major bumps are currently downgraded to minor in analyze/parsing and this job should not run.
|
||||
approval:
|
||||
name: Major release approval (human)
|
||||
needs: analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.analyze.outputs.should_release == 'true' && needs.analyze.outputs.bump == 'major' }}
|
||||
environment:
|
||||
name: major-release
|
||||
steps:
|
||||
- name: Wait for approval (environment protects this job)
|
||||
run: |
|
||||
echo "This job is blocked until an authorized reviewer approves or rejects in the GitHub UI."
|
||||
echo "Computed version to approve: ${{ needs.analyze.outputs.version }}"
|
||||
echo "Approvers must use the Environments UI or the Actions run page to approve."
|
||||
|
||||
# Publish job: runs automatically for non-major bumps, and after approval for major bumps.
|
||||
publish:
|
||||
name: Trigger publish workflow (patch/minor)
|
||||
needs: analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.analyze.outputs.should_release == 'true' && needs.analyze.outputs.bump != 'major'
|
||||
steps:
|
||||
- name: Trigger publish workflow
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const shouldRelease = "${{ needs.analyze.outputs.should_release }}";
|
||||
if (shouldRelease !== "true") {
|
||||
console.log("audit:skip_dispatch reason=should_release_false");
|
||||
return;
|
||||
}
|
||||
const version = "${{ needs.analyze.outputs.version }}";
|
||||
const dryRun = "${{ needs.analyze.outputs.dryRun }}";
|
||||
const ref = "${{ github.event.repository.default_branch }}";
|
||||
console.log(`audit:dispatch_workflow workflow=prepare-release.yml ref=${ref} version=${version} dryRun=${dryRun}`);
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: "prepare-release.yml",
|
||||
ref,
|
||||
inputs: {
|
||||
releaseVersion: version,
|
||||
dryRun
|
||||
}
|
||||
})
|
||||
|
||||
publish_major:
|
||||
name: Trigger publish workflow (major after approval)
|
||||
needs: [analyze, approval]
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.analyze.outputs.should_release == 'true' && needs.analyze.outputs.bump == 'major' && needs.approval.result == 'success'
|
||||
steps:
|
||||
- name: Trigger publish workflow
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const shouldRelease = "${{ needs.analyze.outputs.should_release }}";
|
||||
if (shouldRelease !== "true") {
|
||||
console.log("audit:skip_dispatch reason=should_release_false");
|
||||
return;
|
||||
}
|
||||
const version = "${{ needs.analyze.outputs.version }}";
|
||||
const dryRun = "${{ needs.analyze.outputs.dryRun }}";
|
||||
const ref = "${{ github.event.repository.default_branch }}";
|
||||
console.log(`audit:dispatch_workflow workflow=prepare-release.yml ref=${ref} version=${version} dryRun=${dryRun}`);
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: "prepare-release.yml",
|
||||
ref,
|
||||
inputs: {
|
||||
releaseVersion: version,
|
||||
dryRun
|
||||
}
|
||||
})
|
||||
|
||||
# Dry-run convenience job to show the recommendation alongside the dry-run prepare dispatch.
|
||||
dry_run_output:
|
||||
name: Dry-run output
|
||||
runs-on: ubuntu-latest
|
||||
needs: analyze
|
||||
if: ${{ needs.analyze.outputs.dryRun == 'true' && needs.analyze.outputs.should_release == 'true' }}
|
||||
steps:
|
||||
- name: Show dry-run results
|
||||
run: |
|
||||
echo "======================="
|
||||
echo " DRY RUN MODE "
|
||||
echo "======================="
|
||||
echo "AI recommends release:"
|
||||
echo " bump: ${{ needs.analyze.outputs.bump }}"
|
||||
echo " version: ${{ needs.analyze.outputs.version }}"
|
||||
echo ""
|
||||
echo "(Dry run enabled - propagating dryRun=true to prepare-release.yml)"
|
||||
{
|
||||
echo "## Release Scheduler Dry Run"
|
||||
echo ""
|
||||
echo "- recommended bump: ${{ needs.analyze.outputs.bump }}"
|
||||
echo "- computed version: ${{ needs.analyze.outputs.version }}"
|
||||
echo "- mutation plan: would dispatch prepare-release.yml with dryRun=true"
|
||||
echo "- rerun to mutate: rerun release-scheduler.yml with dryRun=false, or wait for the scheduled release path"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
notify:
|
||||
name: Notify release-scheduler outcome
|
||||
runs-on: ubuntu-latest
|
||||
needs: [analyze, approval, publish, publish_major, dry_run_output]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Build discussion comment
|
||||
id: notify_summary
|
||||
if: always()
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
ANALYZE_RESULT: ${{ needs.analyze.result }}
|
||||
APPROVAL_RESULT: ${{ needs.approval.result }}
|
||||
PUBLISH_RESULT: ${{ needs.publish.result }}
|
||||
PUBLISH_MAJOR_RESULT: ${{ needs.publish_major.result }}
|
||||
DRY_RUN_RESULT: ${{ needs.dry_run_output.result }}
|
||||
SHOULD_RELEASE: ${{ needs.analyze.outputs.should_release }}
|
||||
BUMP: ${{ needs.analyze.outputs.bump }}
|
||||
VERSION: ${{ needs.analyze.outputs.version }}
|
||||
REASON: ${{ needs.analyze.outputs.reason }}
|
||||
WARNING: ${{ needs.analyze.outputs.warning }}
|
||||
DRY_RUN: ${{ needs.analyze.outputs.dryRun }}
|
||||
BINARY_LINES: ${{ needs.analyze.outputs.binary_lines }}
|
||||
BINARY_CHANGES: ${{ needs.analyze.outputs.binary_changes }}
|
||||
CHANGELOG_LEN: ${{ needs.analyze.outputs.changelog_len }}
|
||||
GATE_PROCEED: ${{ needs.analyze.outputs.gate_proceed }}
|
||||
TOKEN_PRESENT: ${{ needs.analyze.outputs.token_present }}
|
||||
TOKEN_GATE: ${{ needs.analyze.outputs.token_gate }}
|
||||
MODEL: ${{ needs.analyze.outputs.model }}
|
||||
MODEL_MAX_INPUT_TOKENS: ${{ needs.analyze.outputs.model_max_input_tokens }}
|
||||
MODEL_MAX_OUTPUT_TOKENS: ${{ needs.analyze.outputs.model_max_output_tokens }}
|
||||
DOSSIER_CHARS: ${{ needs.analyze.outputs.dossier_chars }}
|
||||
LAST_TAG: ${{ needs.analyze.outputs.last_tag }}
|
||||
LAST_FIRST_PARENT_TAG: ${{ needs.analyze.outputs.last_first_parent_tag }}
|
||||
POM_VERSION: ${{ needs.analyze.outputs.pom_version }}
|
||||
POM_BASE: ${{ needs.analyze.outputs.pom_base }}
|
||||
RELEASE_NOTIFY_USER: ${{ vars.RELEASE_NOTIFY_USER }}
|
||||
run: |
|
||||
status="success"
|
||||
for result in "$ANALYZE_RESULT" "$APPROVAL_RESULT" "$PUBLISH_RESULT" "$PUBLISH_MAJOR_RESULT" "$DRY_RUN_RESULT"; do
|
||||
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
||||
status="failure"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
status_upper=$(printf '%s' "$status" | tr '[:lower:]' '[:upper:]')
|
||||
run_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
|
||||
gate_proceed="${GATE_PROCEED:-unknown}"
|
||||
binary_lines="${BINARY_LINES:-0}"
|
||||
changelog_len="${CHANGELOG_LEN:-0}"
|
||||
token_present="${TOKEN_PRESENT:-unknown}"
|
||||
token_gate="${TOKEN_GATE:-unknown}"
|
||||
model="${MODEL:-}"
|
||||
model_max_input_tokens="${MODEL_MAX_INPUT_TOKENS:-}"
|
||||
model_max_output_tokens="${MODEL_MAX_OUTPUT_TOKENS:-}"
|
||||
dossier_chars="${DOSSIER_CHARS:-}"
|
||||
should_release="${SHOULD_RELEASE:-unknown}"
|
||||
bump="${BUMP:-unknown}"
|
||||
version="${VERSION:-}"
|
||||
reason="${REASON:-}"
|
||||
warning="${WARNING:-}"
|
||||
dry_run="${DRY_RUN:-unknown}"
|
||||
last_tag="${LAST_TAG:-}"
|
||||
last_first_parent_tag="${LAST_FIRST_PARENT_TAG:-}"
|
||||
pom_version="${POM_VERSION:-}"
|
||||
pom_base="${POM_BASE:-}"
|
||||
binary_changes="${BINARY_CHANGES:-}"
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
mode_label="dry-run"
|
||||
marker_run="dry-run"
|
||||
else
|
||||
mode_label="production"
|
||||
marker_run="real"
|
||||
fi
|
||||
|
||||
if [ "$status" = "success" ]; then
|
||||
completion_text="completed successfully"
|
||||
else
|
||||
completion_text="completed with failures"
|
||||
fi
|
||||
|
||||
timestamp=$(date '+%-I:%M:%S %p on %-m.%-d.%Y')
|
||||
|
||||
if [ -z "$binary_changes" ]; then
|
||||
binary_changes="(none)"
|
||||
fi
|
||||
if [ -z "$version" ]; then
|
||||
version="(none)"
|
||||
fi
|
||||
if [ -z "$reason" ]; then
|
||||
reason="(none)"
|
||||
fi
|
||||
if [ -z "$warning" ]; then
|
||||
warning="(none)"
|
||||
fi
|
||||
if [ -z "$last_tag" ]; then
|
||||
last_tag="(none)"
|
||||
fi
|
||||
if [ -z "$last_first_parent_tag" ]; then
|
||||
last_first_parent_tag="(none)"
|
||||
fi
|
||||
if [ -z "$pom_version" ]; then
|
||||
pom_version="(unknown)"
|
||||
fi
|
||||
if [ -z "$pom_base" ]; then
|
||||
pom_base="(unknown)"
|
||||
fi
|
||||
if [ -z "$model" ]; then
|
||||
model="(none)"
|
||||
fi
|
||||
if [ -z "$model_max_input_tokens" ]; then
|
||||
model_max_input_tokens="(unknown)"
|
||||
fi
|
||||
if [ -z "$model_max_output_tokens" ]; then
|
||||
model_max_output_tokens="(unknown)"
|
||||
fi
|
||||
if [ -z "$dossier_chars" ]; then
|
||||
dossier_chars="0"
|
||||
fi
|
||||
|
||||
notify_user="${RELEASE_NOTIFY_USER:-TheCookieLab}"
|
||||
|
||||
cat > notification-body.txt <<EOF
|
||||
<!-- ta4j:post-type=release-scheduler;run=${marker_run};lastTag=${last_tag};firstParentTag=${last_first_parent_tag};pomVersion=${pom_version};pomBase=${pom_base} -->
|
||||
@${notify_user}
|
||||
|
||||
**Release Scheduler ${mode_label} ${completion_text} at ${timestamp}**
|
||||
- Repository: ${GITHUB_REPOSITORY}
|
||||
- Run: ${run_url}
|
||||
- Event: ${EVENT_NAME}
|
||||
- Status: ${status_upper}
|
||||
|
||||
Decision Summary:
|
||||
- gate proceed: ${gate_proceed}
|
||||
- binary change count: ${binary_lines}
|
||||
- changelog length: ${changelog_len}
|
||||
- models token present: ${token_present}
|
||||
- token gate proceed: ${token_gate}
|
||||
- model: ${model}
|
||||
- model max input tokens: ${model_max_input_tokens}
|
||||
- model max output tokens: ${model_max_output_tokens}
|
||||
- dossier chars: ${dossier_chars}
|
||||
- should release: ${should_release}
|
||||
- bump: ${bump}
|
||||
- computed version: ${version}
|
||||
- reason: ${reason}
|
||||
- warning: ${warning}
|
||||
- dryRun: ${dry_run}
|
||||
- last reachable tag: ${last_tag}
|
||||
- last first-parent tag: ${last_first_parent_tag}
|
||||
- pom version: ${pom_version}
|
||||
- pom base: ${pom_base}
|
||||
|
||||
Binary-impacting changes (paths):
|
||||
<details>
|
||||
<summary>Show paths</summary>
|
||||
|
||||
${binary_changes}
|
||||
|
||||
</details>
|
||||
|
||||
Job Results:
|
||||
- analyze: ${ANALYZE_RESULT}
|
||||
- approval: ${APPROVAL_RESULT}
|
||||
- publish: ${PUBLISH_RESULT}
|
||||
- publish_major: ${PUBLISH_MAJOR_RESULT}
|
||||
- dry_run_output: ${DRY_RUN_RESULT}
|
||||
EOF
|
||||
|
||||
- name: Post to Release Scheduler discussion
|
||||
if: always() && needs.analyze.result != 'skipped' && needs.analyze.outputs.dryRun != 'true'
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
RELEASE_SCHEDULER_DISCUSSION_NUMBER: ${{ vars.RELEASE_SCHEDULER_DISCUSSION_NUMBER }}
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const body = fs.readFileSync("notification-body.txt", "utf8");
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
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 discussion = queryResult?.repository?.discussion;
|
||||
const discussionId = discussion?.id;
|
||||
if (!discussionId) {
|
||||
throw new Error(`Discussion ${number} not found in ${owner}/${repo}`);
|
||||
}
|
||||
|
||||
const mutation = `
|
||||
mutation($discussionId: ID!, $body: String!) {
|
||||
addDiscussionComment(input: { discussionId: $discussionId, body: $body }) {
|
||||
comment {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
await github.graphql(mutation, { discussionId, body });
|
||||
@@ -0,0 +1,11 @@
|
||||
Decide release go/no-go from unreleased binary-impacting changes.
|
||||
If go, choose bump: patch|minor.
|
||||
|
||||
MINOR: backward-compatible, user-visible new features, and breaking-change cases.
|
||||
PATCH: backward-compatible bug fixes or internal improvements.
|
||||
|
||||
If binary change count is 0 => should_release=false.
|
||||
If binary change list empty or changelog-only => should_release=false.
|
||||
If unsure between MINOR and PATCH, prefer PATCH.
|
||||
|
||||
Pre-1.0.0: breaking changes can be MINOR.
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
name: Publish Snapshot to Maven Central
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sourceReleaseVersion:
|
||||
description: "Optional release version that requested this snapshot publication."
|
||||
required: false
|
||||
type: string
|
||||
sourceReleaseCommit:
|
||||
description: "Optional release commit SHA that requested this snapshot publication."
|
||||
required: false
|
||||
type: string
|
||||
dryRun:
|
||||
description: "Run snapshot workflow in dry-run mode (build/test only; no deploy)."
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: snapshot-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
snapshot:
|
||||
name: Publish Snapshot to Maven Central
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- 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: Read snapshot version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
snapshot_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; }
|
||||
echo "audit:snapshot_version=${snapshot_version}"
|
||||
echo "snapshotVersion=${snapshot_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Maven packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Set up JDK 25 (dry-run without publishing secrets)
|
||||
if: steps.dry_run.outputs.dryRun == 'true'
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
|
||||
- name: Set up JDK 25 with publishing secrets
|
||||
if: steps.dry_run.outputs.dryRun != 'true'
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
server-id: central
|
||||
server-username: ${{ secrets.MAVEN_CENTRAL_TOKEN_USER }}
|
||||
server-password: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASS }}
|
||||
gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
|
||||
- name: Write Maven settings
|
||||
if: steps.dry_run.outputs.dryRun != 'true'
|
||||
run: |
|
||||
echo "::group::Write Maven settings"
|
||||
mkdir -p ~/.m2
|
||||
printf '%s\n' \
|
||||
'<settings>' \
|
||||
' <servers>' \
|
||||
' <server>' \
|
||||
' <id>central</id>' \
|
||||
" <username>${{ secrets.MAVEN_CENTRAL_TOKEN_USER }}</username>" \
|
||||
" <password>${{ secrets.MAVEN_CENTRAL_TOKEN_PASS }}</password>" \
|
||||
' </server>' \
|
||||
' </servers>' \
|
||||
'</settings>' > ~/.m2/settings.xml
|
||||
echo "settings.xml created"
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Write Maven security settings
|
||||
if: steps.dry_run.outputs.dryRun != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
MAVEN_SECURITY_MASTER: ${{ secrets.MAVEN_MASTER_PASSPHRASE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${MAVEN_SECURITY_MASTER:-}" ]]; then
|
||||
echo "MAVEN_MASTER_PASSPHRASE not set; skipping settings-security.xml generation"
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p ~/.m2
|
||||
printf '%s\n' \
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' \
|
||||
'<settingsSecurity>' \
|
||||
" <master>${MAVEN_SECURITY_MASTER}</master>" \
|
||||
'</settingsSecurity>' > ~/.m2/settings-security.xml
|
||||
chmod 600 ~/.m2/settings-security.xml
|
||||
echo "settings-security.xml created"
|
||||
|
||||
- name: Configure GPG for signing
|
||||
if: steps.dry_run.outputs.dryRun != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.gnupg
|
||||
chmod 700 ~/.gnupg
|
||||
echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf
|
||||
chmod 600 ~/.gnupg/gpg.conf
|
||||
touch ~/.gnupg/gpg-agent.conf
|
||||
echo "allow-loopback-pinentry" >> ~/.gnupg/gpg-agent.conf
|
||||
chmod 600 ~/.gnupg/gpg-agent.conf
|
||||
gpgconf --kill gpg-agent 2>/dev/null || true
|
||||
gpgconf --launch gpg-agent 2>/dev/null || true
|
||||
echo "gpg.conf and gpg-agent.conf created"
|
||||
|
||||
- name: Dry-run mode notice
|
||||
if: steps.dry_run.outputs.dryRun == 'true'
|
||||
run: |
|
||||
echo "DRY RUN ENABLED: running snapshot build/test only; Maven Central deployment is skipped."
|
||||
|
||||
- name: Build and Test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Build and test snapshot candidate"
|
||||
mvn -B clean test
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Deploy Snapshot
|
||||
if: steps.dry_run.outputs.dryRun != 'true'
|
||||
env:
|
||||
MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Deploy snapshot to Maven Central"
|
||||
mvn -B deploy -Psign-snapshots 2>&1 | tee snapshot-deploy.log
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Snapshot publication summary
|
||||
if: always()
|
||||
env:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.dryRun }}
|
||||
SNAPSHOT_VERSION: ${{ steps.version.outputs.snapshotVersion }}
|
||||
SOURCE_RELEASE_VERSION: ${{ github.event.inputs.sourceReleaseVersion }}
|
||||
SOURCE_RELEASE_COMMIT: ${{ github.event.inputs.sourceReleaseCommit }}
|
||||
run: |
|
||||
if [ "${DRY_RUN:-false}" = "true" ]; then
|
||||
mutation_plan="would configure publishing secrets and deploy the snapshot to Maven Central"
|
||||
rerun_guidance="rerun snapshot.yml with dryRun=false, or let the master push/publish handoff path run"
|
||||
else
|
||||
mutation_plan="completed snapshot deployment"
|
||||
rerun_guidance="not needed for this non-dry-run snapshot run"
|
||||
fi
|
||||
cat > snapshot-audit.json <<EOF
|
||||
{
|
||||
"event": "${GITHUB_EVENT_NAME}",
|
||||
"dryRun": "${DRY_RUN:-}",
|
||||
"snapshotVersion": "${SNAPSHOT_VERSION:-}",
|
||||
"sourceReleaseVersion": "${SOURCE_RELEASE_VERSION:-}",
|
||||
"sourceReleaseCommit": "${SOURCE_RELEASE_COMMIT:-}",
|
||||
"mutationPlan": "${mutation_plan}",
|
||||
"rerunGuidance": "${rerun_guidance}",
|
||||
"runUrl": "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
}
|
||||
EOF
|
||||
{
|
||||
echo "## Snapshot Publication"
|
||||
echo ""
|
||||
echo "- event: ${GITHUB_EVENT_NAME}"
|
||||
echo "- dry run: ${DRY_RUN:-unknown}"
|
||||
echo "- snapshot version: ${SNAPSHOT_VERSION:-unknown}"
|
||||
echo "- source release version: ${SOURCE_RELEASE_VERSION:-none}"
|
||||
echo "- source release commit: ${SOURCE_RELEASE_COMMIT:-none}"
|
||||
echo "- mutation plan: ${mutation_plan}"
|
||||
echo "- rerun guidance: ${rerun_guidance}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload snapshot audit artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: snapshot-audit-${{ github.run_id }}
|
||||
path: |
|
||||
snapshot-audit.json
|
||||
snapshot-deploy.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Run Analysis Demo Tagged Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "7 7 * * *"
|
||||
- cron: "17 7 * * 1"
|
||||
- cron: "27 7 1 * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
instrument:
|
||||
description: "Provider-qualified instrument, for example coinbase:BTC-USD"
|
||||
required: false
|
||||
default: "coinbase:BTC-USD"
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test-analysis-demo:
|
||||
name: Build and Test (analysis-demo tag)
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
vars.TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_ENABLED == 'true' &&
|
||||
(
|
||||
(vars.TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_SLOT == 'daily' && github.event.schedule == '7 7 * * *') ||
|
||||
(vars.TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_SLOT == 'weekly' && github.event.schedule == '17 7 * * 1') ||
|
||||
(vars.TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_SLOT == 'monthly' && github.event.schedule == '27 7 1 * *')
|
||||
)
|
||||
)
|
||||
}}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Maven packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
|
||||
- name: Resolve analysis demo instrument
|
||||
env:
|
||||
DEFAULT_ANALYSIS_DEMO_INSTRUMENT: coinbase:BTC-USD
|
||||
DISPATCH_INSTRUMENT: ${{ github.event.inputs.instrument }}
|
||||
SCHEDULED_INSTRUMENT: ${{ vars.TA4J_ANALYSIS_DEMO_INSTRUMENT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
instrument="${DEFAULT_ANALYSIS_DEMO_INSTRUMENT}"
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
||||
if [ -n "${DISPATCH_INSTRUMENT:-}" ]; then
|
||||
instrument="${DISPATCH_INSTRUMENT}"
|
||||
fi
|
||||
elif [ -n "${SCHEDULED_INSTRUMENT:-}" ]; then
|
||||
instrument="${SCHEDULED_INSTRUMENT}"
|
||||
fi
|
||||
|
||||
provider="${instrument%%:*}"
|
||||
product="${instrument#*:}"
|
||||
if [ "${instrument}" = "${product}" ] || [ -z "${provider}" ] || [ -z "${product}" ]; then
|
||||
echo "::error::analysis-demo instrument must use provider-qualified format, for example coinbase:BTC-USD"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${provider,,}" != "coinbase" ]; then
|
||||
echo "::error::Unsupported analysis-demo provider '${provider}'. Supported providers: coinbase"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${product}" =~ ^[A-Za-z0-9]+([/-][A-Za-z0-9]+)+$ ]]; then
|
||||
echo "::error::Coinbase analysis-demo product id must use Coinbase product format, for example BTC-USD"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'TA4J_ANALYSIS_DEMO_INSTRUMENT=%s\n' "${instrument}" >> "${GITHUB_ENV}"
|
||||
echo "analysis-demo:instrument=${instrument}"
|
||||
|
||||
- name: Build and run analysis-demo tagged tests with Maven
|
||||
run: >
|
||||
xvfb-run mvn -B test
|
||||
-Dgroups=analysis-demo
|
||||
-Dta4j.excludedTestTags=elliott-macro-cycle-replay
|
||||
-Dta4j.analysisDemoInstrument="${TA4J_ANALYSIS_DEMO_INSTRUMENT}"
|
||||
-Dta4j.analysisDemoOutputDir=target/analysis-demos/elliott-wave
|
||||
|
||||
- name: Upload analysis demo artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: analysis-demo-elliott-wave
|
||||
path: target/analysis-demos/**
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Run Benchmark Tagged Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "57 4 * * *"
|
||||
- cron: "57 5 * * 1"
|
||||
- cron: "57 6 1 * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test-benchmark:
|
||||
name: Build and Test (benchmark tag)
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
vars.TA4J_TAGGED_TEST_BENCHMARK_SCHEDULE_ENABLED == 'true' &&
|
||||
(
|
||||
(vars.TA4J_TAGGED_TEST_BENCHMARK_SCHEDULE_SLOT == 'daily' && github.event.schedule == '57 4 * * *') ||
|
||||
(vars.TA4J_TAGGED_TEST_BENCHMARK_SCHEDULE_SLOT == 'weekly' && github.event.schedule == '57 5 * * 1') ||
|
||||
(vars.TA4J_TAGGED_TEST_BENCHMARK_SCHEDULE_SLOT == 'monthly' && github.event.schedule == '57 6 1 * *')
|
||||
)
|
||||
)
|
||||
}}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Maven packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
|
||||
- name: Build and run benchmark tagged tests with Maven
|
||||
run: xvfb-run mvn -B test -Dgroups=benchmark -Dta4j.excludedTestTags= -Dta4j.runBenchmarks=true
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Run Elliott Macro Cycle Replay Tagged Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test-elliott-macro-cycle-replay:
|
||||
name: Build and Test (elliott-macro-cycle-replay tag)
|
||||
runs-on: [self-hosted, ta4j-macro-cycle-replay]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Maven packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
|
||||
- name: Build and run Elliott macro-cycle replay tagged tests with Maven
|
||||
run: >
|
||||
xvfb-run mvn -B test
|
||||
-Dgroups=elliott-macro-cycle-replay
|
||||
-Dta4j.excludedTestTags=
|
||||
-Dtest=ElliottWaveMacroCycleDetectorTest
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Run Integration Tagged Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 4 * * *"
|
||||
- cron: "17 5 * * 1"
|
||||
- cron: "17 6 1 * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test-integration:
|
||||
name: Build and Test (integration tag)
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
vars.TA4J_TAGGED_TEST_INTEGRATION_SCHEDULE_ENABLED == 'true' &&
|
||||
(
|
||||
(vars.TA4J_TAGGED_TEST_INTEGRATION_SCHEDULE_SLOT == 'daily' && github.event.schedule == '17 4 * * *') ||
|
||||
(vars.TA4J_TAGGED_TEST_INTEGRATION_SCHEDULE_SLOT == 'weekly' && github.event.schedule == '17 5 * * 1') ||
|
||||
(vars.TA4J_TAGGED_TEST_INTEGRATION_SCHEDULE_SLOT == 'monthly' && github.event.schedule == '17 6 1 * *')
|
||||
)
|
||||
)
|
||||
}}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Maven packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
|
||||
- name: Build and run integration tagged tests with Maven
|
||||
run: xvfb-run mvn -B test -Dgroups=integration -Dta4j.excludedTestTags=analysis-demo,elliott-macro-cycle-replay
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
name: Run Verify
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build and Verify
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Maven packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
|
||||
- name: Build and run verify with Maven
|
||||
run: xvfb-run mvn -B verify
|
||||
|
||||
test-all-tags:
|
||||
name: Build and Verify (All Non-Demo Tags)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Maven packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
|
||||
- name: Build and run verify with demo tags excluded
|
||||
run: xvfb-run mvn -B verify -Dta4j.excludedTestTags=analysis-demo,elliott-macro-cycle-replay
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
name: Validate Source Code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Validate Source Code Formatting
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Cache Maven packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
cache: maven
|
||||
|
||||
- name: Validate source code formatting
|
||||
run: xvfb-run mvn -B formatter:validate
|
||||
Reference in New Issue
Block a user