goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
# Contributing to ta4j
ta4j has been around for years and serves a large, diverse user base. Contributions are very welcome, but longterm maintainability takes precedence over quick wins. Please keep the sections below in mind before filing an issue or opening a PR.
## Principles
1. **Public APIs are contracts.** Moving classes between packages, renaming methods, or otherwise breaking binary/source compatibility forces every downstream user into a refactor. We only accept breaking changes when the value dramatically outweighs the disruption, and even then they must ship with deprecation shims and migration notes.
2. **Opinionated implementations belong outside the core.** ta4j aims to be widely applicable. Highly subjective “feature bundles” (e.g., metric dashboards, bespoke reporting formats, hard-coded broker behaviors) are better published as separate modules or example projects. Keep contributions focused on reusable primitives.
3. **Additive code beats churn.** New indicators, rules, serialization helpers, and documentation are great. Mechanical refactors (“just moved files around”) or stylistic changes with no behavioral impact rarely get merged.
4. **Tests tell the story.** Every change—bug fix or feature—needs focused tests demonstrating the behavior and guarding against regressions.
- **Run this before opening or updating a PR:** `mvn -B verify`
This matches the main CI path and keeps SpotBugs and JaCoCo advisory in the full contributor flow.
- **Use focused local quality loops when iterating:** `mvn -pl ta4j-core -am spotbugs:check` and `mvn -pl ta4j-core -am test jacoco:report jacoco:check`
These are intentionally strict for the module you are changing, so you can tighten one tool at a time before rerunning the full `mvn -B verify`.
- **Fix formatting and license headers when needed:** `mvn -B license:format formatter:format`
First-time contributors almost always hit this; run the formatter command locally before your final `mvn -B verify`.
## Contribution checklist
1. **Use Java 25+ and Maven 3.9+.** The build enforces these versions during Maven validation.
2. **Start with an issue** for anything non-trivial. Use it to confirm fit with the [Roadmap](https://ta4j.github.io/ta4j-wiki/Roadmap-and-Tasks.html) and to align on scope. [Search existing issues](https://github.com/ta4j/ta4j/issues?q=is%3Aissue) before opening a new one.
3. **Fork & branch** from `master`.
```bash
git clone https://github.com/<you>/ta4j.git
cd ta4j
git checkout -b feature/your-topic
```
4. **Implement + test.** Run the full build before pushing:
```bash
mvn -B clean license:format formatter:format test install
```
CI will fail if your changes are not formatted or lack the project license header. First-time contributors almost always hit this; run the command locally first.
Update `CHANGELOG.md` when you add, fix, or change behavior.
5. **Open the PR** against `ta4j/master`. Draft PRs are encouraged for early feedback. Prefer [well-formed commit messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
**Optional: Enable workflow linting hook** - If you're modifying GitHub Actions workflows, enable the pre-push hook to catch syntax errors early:
```bash
git config core.hooksPath .githooks
```
Then install `actionlint` (e.g., `brew install actionlint`). The hook will automatically lint any modified files under `.github/workflows/` before pushing.
## Contribution priorities
1. Items on the [Roadmap](https://ta4j.github.io/ta4j-wiki/Roadmap-and-Tasks.html).
2. Additive indicators/criteria/rules that do not change existing behavior.
3. Test coverage or documentation improvements.
4. Bug fixes (smaller, localized fixes are easier to land; large refactors should be discussed first).
5. API changes: only with clear justification, deprecation shims, and migration docs.
## Coding expectations
- Favor clarity over cleverness; write the code youd want to debug a year from now.
- Keep PRs scoped. If you find unrelated issues, file them or send separate PRs.
- Every new public class/method needs Javadoc with `@since <version>`.
- Use primitives for indicator parameters (e.g., `int timeFrame`). Convert to `Num` inside using `series.numFactory()`.
- Do not cache `Num` instances globally—always obtain them from the relevant factory.
## Indicator contributions
Open an issue to discuss the new indicator first. Every indicator must ship with matching tests:
- `src/main/java/org/ta4j/core/indicators/.../NewIndicator.java`
- `src/test/java/org/ta4j/core/indicators/.../NewIndicatorTest.java`
## Tagged test workflows
Regular PR and push CI skips test tags configured by `ta4j.excludedTestTags`.
Run tagged suites manually from GitHub Actions, or locally with:
- `xvfb-run mvn -B test -Dgroups=integration -Dta4j.excludedTestTags=analysis-demo,elliott-macro-cycle-replay`
- `xvfb-run mvn -B test -Dgroups=benchmark -Dta4j.excludedTestTags= -Dta4j.runBenchmarks=true`
- `xvfb-run mvn -B test -Dgroups=analysis-demo -Dta4j.excludedTestTags=elliott-macro-cycle-replay -Dta4j.analysisDemoInstrument=coinbase:BTC-USD -Dta4j.analysisDemoOutputDir=target/analysis-demos/elliott-wave`
- `xvfb-run mvn -B test -Dgroups=elliott-macro-cycle-replay -Dta4j.excludedTestTags= -Dtest=ElliottWaveMacroCycleDetectorTest`
These examples match the Linux GitHub Actions runners. On macOS, use XQuartz or
run the Maven command without `xvfb-run` when your local display can satisfy
UI-dependent tests. On Windows, use WSL2, a CI runner, or an equivalent X server.
The dedicated workflows are:
- `Run Integration Tagged Tests` (`.github/workflows/test-tag-integration.yml`)
- `Run Benchmark Tagged Tests` (`.github/workflows/test-tag-benchmark.yml`)
- `Run Analysis Demo Tagged Tests` (`.github/workflows/test-tag-analysis-demo.yml`)
- `Run Elliott Macro Cycle Replay Tagged Tests` (`.github/workflows/test-tag-elliott-macro-cycle-replay.yml`)
Scheduled runs are opt-in per tag. Set `TA4J_TAGGED_TEST_<TAG>_SCHEDULE_ENABLED=true`
and `TA4J_TAGGED_TEST_<TAG>_SCHEDULE_SLOT=daily`, `weekly`, or `monthly`.
Unset variables leave scheduled runs disabled, while manual workflow dispatches run
regardless of the schedule variables. The `elliott-macro-cycle-replay` workflow
is manual-only and requires a self-hosted runner labeled `ta4j-macro-cycle-replay`.
The `analysis-demo` tag is for examples that produce analysis reports and must
be the only JUnit tag on each tagged test or class.
Its workflow defaults to `coinbase:BTC-USD`, accepts provider-qualified manual
inputs such as `coinbase:ETH-USD` or `coinbase:ETH/USD`, and uploads generated
JSON, charts, and cached provider responses from `target/analysis-demos/**`.
Version 1 supports Coinbase instruments only. For scheduled analysis-demo runs,
`weekly` is the intended slot; use
`TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_ENABLED=true` with
`TA4J_TAGGED_TEST_ANALYSIS_DEMO_SCHEDULE_SLOT=weekly`, and set
`TA4J_ANALYSIS_DEMO_INSTRUMENT` to override the scheduled instrument.
## API lifecycle and @since policy
- Add `@since <version>` to every newly introduced class and API member, using the introducing release version without `-SNAPSHOT` (for example `0.22.4`, not `0.22.4-SNAPSHOT`).
- This gives us a reliable introduction point for deprecation tracking and lifecycle automation.
- New API is considered volatile for the next 5 minor releases after it is introduced.
- Example: API added in `0.22.4` may still change incompatibly, or be removed, through `0.27.4` (inclusive).
- Treat this window as experimental/beta and avoid production-critical dependency unless you explicitly accept migration risk.
## Branching model
Enhancements, new features and fixes should be pushed to a [fork](https://help.github.com/articles/fork-a-repo/) of the master branch. Once completed they will be merged with the master branch during a [pull request](https://help.github.com/articles/about-pull-requests/). GitHub actions are configured to run the tests, validate the licence header and source code format. After the PR has been merged a new SNAPSHOT will be deployed.
This development process is similar to [github flow](https://docs.github.com/en/get-started/quickstart/github-flow).
* **Only the content of the master branch is going to become a release.**
* **There is no release branch nor a mandatory develop branch**
### Release Process
For maintainers, the release process is fully automated using GitHub Actions workflows. The process includes:
- **Automated release scheduling**: AI-powered scheduler analyzes changes and determines version bumps (patch/minor/major)
- **Two-phase release workflow**: `prepare-release.yml` prepares release commits and PRs, `publish-release.yml` handles tagging and deployment
- **Release health monitoring**: Automated checks for tag reachability, version drift, and stale release PRs
- **GitHub Release automation**: Automatic creation of GitHub Releases with artifacts and release notes
For detailed information about the release process, see [RELEASE_PROCESS.md](../RELEASE_PROCESS.md) in the main repository.
## Quick tips
- Use `series.getBeginIndex()` instead of `0` when iterating a `BarSeries`.
- Remember the difference between `DecimalNum.min(...)` and `DecimalNum.minus(...)`.
- When in doubt, ask. Its easier (and faster) to course-correct early than to rework a large PR later.
+31
View File
@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
**I have checked existing issues and wiki**
- [ ] I could not find similar [issues](https://github.com/ta4j/ta4j/issues?utf8=%E2%9C%93&q=)
- [ ] I could not find a solution in the [wiki](https://ta4j.github.io/ta4j-wiki/) or [faq section](https://ta4j.github.io/ta4j-wiki/FAQ.html)
**Describe the bug**
A clear and concise description of what the bug is and what version you are using. If possible provide a **[minimal working example](https://stackoverflow.com/help/mcve)**!
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here. Please ensure you call out the version you are on.
+24
View File
@@ -0,0 +1,24 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**I have checked existing issues and wiki**
- [ ] I could not find similar [issues](https://github.com/ta4j/ta4j/issues?utf8=%E2%9C%93&q=)
- [ ] I could not find a solution in the [wiki](https://ta4j.github.io/ta4j-wiki/) or [faq section](https://ta4j.github.io/ta4j-wiki/FAQ.html)
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen. If possible provide a **[minimal working example](https://stackoverflow.com/help/mcve)**!
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+14
View File
@@ -0,0 +1,14 @@
---
name: General question
about: A general question about how to use ta4j
title: ''
labels: question
assignees: ''
---
**I have checked existing issues and wiki**
- [ ] I could not find similar [issues](https://github.com/ta4j/ta4j/issues?utf8=%E2%9C%93&q=)
- [ ] I could not find a solution in the [wiki](https://ta4j.github.io/ta4j-wiki/) or [faq section](https://ta4j.github.io/ta4j-wiki/FAQ.html)
If possible provide a **[minimal working example](https://stackoverflow.com/help/mcve)**!
+10
View File
@@ -0,0 +1,10 @@
Fixes #.
Changes proposed in this pull request:
-
-
-
- [ ] I have run `mvn -B clean license:format formatter:format test install` to format and test my changes per the [contributing guidelines](.github/CONTRIBUTING.md)
- [ ] I have added an entry with applicable ticket number(s) to the appropriate unreleased section of `CHANGELOG.md`
+24
View File
@@ -0,0 +1,24 @@
self-hosted-runner:
# Labels of self-hosted runner in array of strings.
labels:
- ta4j-macro-cycle-replay
# Configuration variables in array of strings defined in your repository or
# organization. `null` means disabling configuration variables check.
# Empty array means no configuration variable is allowed.
config-variables: null
# Configuration for file paths. The keys are glob patterns to match to file
# paths relative to the repository root. The values are the configurations for
# the file paths. Note that the path separator is always '/'.
# The following configurations are available.
#
# "ignore" is an array of regular expression patterns. Matched error messages
# are ignored. This is similar to the "-ignore" command line option.
paths:
.github/workflows/**/*.yml:
ignore:
- 'shellcheck reported issue in this script: SC[0-9]+:(info|warning|style):'
.github/workflows/**/*.yaml:
ignore:
- 'shellcheck reported issue in this script: SC[0-9]+:(info|warning|style):'
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+179
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
+11
View File
@@ -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
View File
@@ -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
+107
View File
@@ -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
+54
View File
@@ -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
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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