Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Edit(version.gradle.kts)",
"Bash(./gradlew:*)",
"Bash(./config/gradlew:*)",
"Bash(git status:*)",
Expand Down Expand Up @@ -67,6 +68,10 @@
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.agents/scripts/secret-scan-gate.sh"
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.agents/scripts/pre-pr-gate.sh"
Expand Down
65 changes: 58 additions & 7 deletions .github/workflows/build-on-ubuntu.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
name: Ubuntu CI

on: push
# Triggers:
# * push to a default or release-line branch — those ending in `master` or
# `main`, the same set `increment-guard.yml` guards as PR bases (e.g.
# `master`, `2.x-jdk8-master`). These post-merge runs are the only source
# of base-branch coverage, since `Publish` runs `publish -x test` and
# uploads none; `target: auto` in `.codecov.yml` compares each pull request
# against its base baseline.
# * pull_request — gates a change on its merge result, not the branch tip.
on:
push:
branches:
- '**master'
- '**main'
pull_request:

jobs:
build:
name: Build on Ubuntu
runs-on: ubuntu-latest
concurrency: # Avoid canceling in-progress runs for the same branch.
group: ubuntu-ci-${{ github.ref }}
cancel-in-progress: false

steps:
- uses: actions/checkout@v6
Expand All @@ -19,13 +35,24 @@ jobs:

- uses: gradle/actions/setup-gradle@v6

# Mirrors the pagefile step in build-on-windows.yml. The Linux runner
# ships with effectively no swap, so a memory peak becomes an instant
# OOM kill; this gives the kernel somewhere to fall back to.
- name: Add swap space
uses: pierotofy/set-swap-space@v1.0
with:
swap-size-gb: 8

- name: Build project, run tests
shell: bash
run: ./gradlew build --stacktrace

# `build` does not run Dokka — its tasks are gated to the publishing
# graph — so `dokkaGenerate` is appended to surface documentation
# warnings on each push, before merge, instead of only in the post-merge
# `Publish` job. `failOnWarning` is enabled in the Dokka setup.
- name: Build project, run tests, and check documentation
shell: bash
run: ./gradlew build dokkaGenerate --stacktrace
# warnings before merge, instead of only in the post-merge `Publish`
# job. `failOnWarning` is enabled in the Dokka setup.
- name: Check documentation
run: ./gradlew dokkaGenerate --stacktrace

# See: https://github.com/marketplace/actions/junit-report-action
- name: Publish Test Report
Expand All @@ -35,8 +62,32 @@ jobs:
report_paths: '**/build/test-results/**/TEST-*.xml'
require_tests: true # will fail workflow if test reports not found

# Probe whether the upload token is available without exposing its value
# to the build/test steps. Scoping `CODECOV_TOKEN` to this trivial step
# (and to the upload step's `with.token`) keeps PR-authored code in
# `./gradlew build` from ever seeing the secret. The `secrets` context is
# not available in a step `if:`, so the upload gates on this output.
- name: Detect Codecov token
id: codecov
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: |
if [ -n "$CODECOV_TOKEN" ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "available=false" >> "$GITHUB_OUTPUT"
fi

# On `push` (master) always upload — these runs are the only source of
# the `target: auto` baseline, so an absent token there is a
# misconfiguration that should fail loudly rather than silently stop
# refreshing coverage. On `pull_request`, skip when the token is absent:
# forked and Dependabot PRs run without secrets, and `fail_ci_if_error`
# would otherwise redden a healthy PR (coverage gating is meaningless
# there anyway).
- name: Upload code coverage report
uses: codecov/codecov-action@v4
if: steps.codecov.outputs.available == 'true' || github.event_name == 'push'
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
Expand Down
70 changes: 65 additions & 5 deletions .github/workflows/increment-guard.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Ensures that the current lib version is not yet published by executing the Gradle
# `checkVersionIncrement` task.
# Guards the project version by executing the Gradle `checkVersionIncrement` task,
# which verifies that the version is both (a) strictly greater than the base branch
# version in `version.gradle.kts` and (b) not already published. The result is
# published as the `Version Guard` commit status — the context required by branch
# protection and re-published by `revalidate-versions.yml` on the heads of other open
# PRs when the base branch advances (so a stale duplicate bump turns red before merge).
#
# The check runs only for pull requests targeting a default (`master`/`main`) or
# a release-line (e.g. `2.x-jdk8-master`) branch. It is the responsibility of a branch
Expand All @@ -15,26 +19,82 @@ name: Version Guard

on:
pull_request:
# Beyond the default activity types (`opened`, `synchronize`, `reopened`), two more are
# needed because they change what the guard must compare against without a new head SHA,
# which would otherwise leave a stale-green `Version Guard` status mergeable:
# * `ready_for_review` — a draft that went stale while in draft becomes ready; and
# * `edited` — the base branch is retargeted (e.g. a release line -> `master`), so the
# strict comparison must be recomputed against the new base.
types: [opened, synchronize, reopened, ready_for_review, edited]

jobs:
check:
name: Check version increment
runs-on: ubuntu-latest
# Default and release-line branches, e.g. `master`, `main`, `2.x-jdk8-master`.
if: endsWith(github.base_ref, 'master') || endsWith(github.base_ref, 'main')
# Default and release-line branches, e.g. `master`, `main`, `2.x-jdk8-master`. For an
# `edited` event, run only when the base actually changed (a retarget carries
# `changes.base.ref.from`); title/body edits carry no `changes.base` and are skipped, so
# the guard is not rebuilt needlessly.
if: >-
(endsWith(github.base_ref, 'master') || endsWith(github.base_ref, 'main'))
&& (github.event.action != 'edited' || github.event.changes.base.ref.from != '')

# `statuses: write` lets the job publish the `Version Guard` commit status that
# branch protection requires.
permissions:
contents: read
statuses: write

steps:
- uses: actions/checkout@v6
with:
submodules: 'true'

# `checkVersionIncrement` reads `origin/<base>:version.gradle.kts`. The pull request
# checkout does not include the base branch, so fetch its tip into the expected ref.
- name: Fetch the base branch
shell: bash
run: |
git fetch --no-tags --depth=1 \
origin "+refs/heads/${GITHUB_BASE_REF}:refs/remotes/origin/${GITHUB_BASE_REF}"

- uses: actions/setup-java@v5
with:
java-version: 17
distribution: zulu

- uses: gradle/actions/setup-gradle@v6

- name: Check version is not yet published
- name: Check version increment
id: guard
shell: bash
# `VERSION_GUARD` enables the strict base-branch comparison in `checkVersionIncrement`.
# Only this workflow fetches the base ref (the step above), so the comparison is gated
# to it: other CI builds pull the task in via `publishToMavenLocal` on a shallow
# checkout and must not attempt to read `origin/<base>`.
env:
VERSION_GUARD: "true"
run: ./gradlew checkVersionIncrement --stacktrace

# Publish the verdict as the `Version Guard` commit status on the PR head. Posting it
# on every run (success or failure) is what lets a later re-bump clear a failure that
# `revalidate-versions.yml` set when the base branch advanced.
#
# Skipped for fork PRs: `GITHUB_TOKEN` is read-only for them, so the status cannot be
# posted (and a fork head SHA is not in this repo). The Spine agent workflow pushes PR
# branches to the same repository; fork contributions are handled by a maintainer, who
# owns the version bump.
- name: Report the Version Guard status
if: always() && github.event.pull_request.head.repo.fork == false
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
state=failure
if [ "${{ steps.guard.outcome }}" = "success" ]; then
state=success
fi
gh api -X POST "repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \
-f state="${state}" \
-f context="Version Guard" \
-f description="Version increment check"
15 changes: 15 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,18 @@ jobs:
REPO_SLUG: ${{ github.repository }} # e.g. SpineEventEngine/core-jvm
GOOGLE_APPLICATION_CREDENTIALS: ./maven-publisher.json
NPM_TOKEN: ${{ secrets.NPM_SECRET }}

# A failed publication on `master` is most often a version collision: a stale
# duplicate bump merged before `revalidate-versions.yml` could turn it red (the
# narrow auto-merge race). The artifact is safe — the registry rejects the
# overwrite — but the fix needs a human/agent, so make the failure loud and
# actionable instead of a quiet red run.
- name: Report a failed publication
if: failure()
shell: bash
run: |
echo "::error title=Publish failed::Publishing to Maven failed on the base branch. If this is a version collision, the version is already published (immutable). Bump 'version.gradle.kts' on the base branch (e.g. via a small PR) and re-run this workflow."
echo "Publish failed. If the cause is a version collision:"
echo " 1. Bump 'version.gradle.kts' on the base branch to the next free version."
echo " 2. Re-run this 'Publish' workflow."
echo "Stale duplicate bumps are normally caught before merge by 'revalidate-versions.yml'."
57 changes: 57 additions & 0 deletions .github/workflows/revalidate-versions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Re-judges every other open pull request when the base branch advances.
#
# Publishing runs on every push to a release base branch, so once one pull request merges
# and bumps the version, any other open PR that bumped to the same (or a lower) value is
# now stale: its publish would collide. GitHub does not re-run a PR's checks when its base
# advances, so this workflow does it actively — for each other open PR whose
# `version.gradle.kts` version is `<=` the new base version, it posts a failing
# `Version Guard` commit status on the PR head, blocking the merge until the author
# re-bumps. The status self-clears: the re-bump push runs `increment-guard.yml`, which
# posts a fresh `success` on the new head.
#
# This narrows, but does not close, the race against auto-merge. A PR that is already
# mergeable can merge in the seconds before this fan-out marks it stale; that late merge
# produces a publish collision which the immutable Maven registry rejects (a loud,
# recoverable red Publish), never an overwrite. The deterministic guarantee is the
# registry's immutability, not this signal.

name: Revalidate Versions

on:
push:
# Matches the PR guard's `endsWith(base_ref, 'master'|'main')` and the same scope as
# `build-on-ubuntu.yml`. `**` (unlike `*`) also crosses `/`, so slash-named release
# lines such as `release/2.x-master` are covered. Keeping these definitions identical
# ensures every branch guarded on the PR side is also revalidated here.
branches:
- '**master'
- '**main'

permissions:
contents: read
statuses: write
pull-requests: read

concurrency:
# Only the newest tip of a given base matters; a later push to the same ref cancels an
# in-flight fan-out for it. Different bases run independently.
group: revalidate-versions-${{ github.ref }}
cancel-in-progress: true

jobs:
revalidate:
name: Revalidate open PRs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: 'true'

- name: Revalidate open PRs against the new base version
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BASE_REF: ${{ github.ref_name }}
# Invoked via `bash` so it does not depend on the script's committed executable bit.
run: bash ./config/scripts/revalidate-versions.sh
70 changes: 70 additions & 0 deletions .github/workflows/secret-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Secret scan

# Defense-in-depth behind the local `secret-scan` pre-commit hook and the
# `.gitignore` secret patterns: if a credential is committed despite those, this
# fails the pull request before it can merge. Distributed to every Spine repo by
# `./config/pull`.

on:
pull_request:
push:
branches:
- master
- main

permissions:
contents: read

jobs:
gitleaks:
name: gitleaks
runs-on: ubuntu-latest
env:
# Pinned gitleaks version — bump through the usual dependency-update process.
GITLEAKS_VERSION: "8.21.2"
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Full history so a pull request's commit range can be scanned.
fetch-depth: 0

- name: Install gitleaks
# Run gitleaks as the runner user against the checkout it owns — no
# container, so no "dubious ownership" git error and no GitHub Action
# org-licence requirement.
run: |
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xzf - gitleaks
./gitleaks version

- name: Scan
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
run: |
if [ "$EVENT" = pull_request ]; then
# Scan the PR's own commit RANGE: a secret added in one commit and
# deleted in a later commit of the same PR is still caught (a
# working-tree scan would miss it, yet merging keeps the secret-bearing
# commit reachable), while already-rotated secrets in older history
# outside base..head are not re-flagged.
./gitleaks git --log-opts="$BASE..$HEAD" --redact --verbose --exit-code=1 .
else
# Push to a default branch: scan the pushed commit RANGE (before..after)
# so an add-then-remove batch is caught here too, not only on PRs — the
# leaked commit would otherwise stay reachable on the default branch. A
# branch's first push reports an all-zero `before` (no range); fall back
# to a working-tree scan then.
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then
./gitleaks git --log-opts="$BEFORE..$AFTER" --redact --verbose --exit-code=1 .
else
# Branch's first push (all-zero `before`): no range to diff against, so
# scan the whole history reachable from the pushed tip as the initial
# import — an add-then-remove within those commits is still caught.
./gitleaks git --log-opts="$AFTER" --redact --verbose --exit-code=1 .
fi
fi
Loading
Loading