From 2662d15f4ba47a4ab952cce2bcae01c03b02fbae Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Tue, 25 Aug 2026 18:07:59 +0200 Subject: [PATCH 01/28] new i18n messages for Sail Insight --- mobile/sailinsight/src/i18n/translations/de.json | 1 + mobile/sailinsight/src/i18n/translations/en.json | 1 + 2 files changed, 2 insertions(+) diff --git a/mobile/sailinsight/src/i18n/translations/de.json b/mobile/sailinsight/src/i18n/translations/de.json index 2feb55f7df0..7a74489bf42 100644 --- a/mobile/sailinsight/src/i18n/translations/de.json +++ b/mobile/sailinsight/src/i18n/translations/de.json @@ -287,6 +287,7 @@ "error_title": "Ein Fehler ist aufgetreten", "error_unknown" : "Ups, da ist was schief gegangen.\nBitte versuche es nochmal!", + "error_event_settings_not_saved" : "Das Event wurde angelegt, aber die Einstellungen konnten nicht gespeichert werden. Bitte erstelle es nicht noch einmal – öffne es stattdessen aus der Liste.", "error_with_code" : "Ups, da ist was schief gegangen.\nBitte versuche es nochmal! ({{code}})", "error_field_required": "Feld ist erforderlich.", "error_field_already_exists": "Existiert bereits.", diff --git a/mobile/sailinsight/src/i18n/translations/en.json b/mobile/sailinsight/src/i18n/translations/en.json index 580cd24c261..993f6778485 100644 --- a/mobile/sailinsight/src/i18n/translations/en.json +++ b/mobile/sailinsight/src/i18n/translations/en.json @@ -293,6 +293,7 @@ "error_title": "An error occurred", "error_unknown" : "Oops, something went wrong here.\nPlease try again!", + "error_event_settings_not_saved" : "The event was created, but its settings could not be saved. Please do not create it again – open it from the list instead.", "error_with_code" : "Oops, something went wrong here.\nPlease try again! ({{code}})", "error_field_required": "Field is required.", "error_field_already_exists": "Already exists.", From d4893c1c69456c22a3b2f38e7f8e9d727795ca12 Mon Sep 17 00:00:00 2001 From: Masha Kashirina Date: Wed, 26 Aug 2026 23:00:26 +0200 Subject: [PATCH 02/28] bug6279: logs and reverse index --- .../impl/CandidateChooserImpl.java | 118 +++++++++++++++--- .../test/JumpyTrackSmootheningTest.java | 12 ++ 2 files changed, 114 insertions(+), 16 deletions(-) diff --git a/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java b/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java index bb5a241ad8e..7a31c63b3d1 100755 --- a/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java +++ b/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java @@ -7,7 +7,6 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -132,6 +131,13 @@ public class CandidateChooserImpl implements CandidateChooser { * with the dynamic (re-)calculations triggered by fixes and other data popping in. */ private Map>> allEdges = new HashMap<>(); + + /** + * Reverse index of {@link #allEdges}: for each candidate, the set of edges whose {@link Edge#getEnd()} is that + * candidate. Kept in sync with {@link #allEdges} so that {@link #removeEdgesForCandidate} can remove all edges + * touching a candidate in O(in-degree + out-degree) instead of O(total graph size). + */ + private Map>> incomingEdges = new HashMap<>(); /** * The candidates found, keyed by the {@link Competitor} to whose track they belong. @@ -399,6 +405,7 @@ public class CandidateChooserImpl implements CandidateChooser { }); fixedPassings.put(c, fixedPasses); allEdges.put(c, new HashMap>()); + incomingEdges.put(c, new HashMap>()); fixedPasses.addAll(startAndEnd); addCandidates(c, startAndEnd); } @@ -444,9 +451,27 @@ public class CandidateChooserImpl implements CandidateChooser { } } } + final TimePoint updateStationarySequencesStartedAt = TimePoint.now(); + logger.info("DIAG START updateStationarySequences for "+c); + updateStationarySequences(c, newFixes, fixesReplacingExistingOnes); + logger.info("DIAG END updateStationarySequences for "+c+": "+ updateStationarySequencesStartedAt.until(TimePoint.now())); + final TimePoint removeCandidatesStartedAt = TimePoint.now(); + logger.info("DIAG START removeCandidates for "+c); removeCandidates(c, oldCans); + logger.info("DIAG END removeCandidates for "+c+": "+ + removeCandidatesStartedAt.until(TimePoint.now())); + + final TimePoint addCandidatesStartedAt = TimePoint.now(); + logger.info("DIAG START addCandidates for "+c); addCandidates(c, newCans); + logger.info("DIAG END addCandidates for "+c+": "+ + addCandidatesStartedAt.until(TimePoint.now())); + + final TimePoint findShortestPathStartedAt = TimePoint.now(); + logger.info("DIAG START findShortestPath for "+c); findShortestPath(c); + logger.info("DIAG END findShortestPath for "+c+": "+ + findShortestPathStartedAt.until(TimePoint.now())); } /** @@ -554,7 +579,8 @@ public class CandidateChooserImpl implements CandidateChooser { private void createNewEdges(Competitor c, Iterable newCandidates) { assert perCompetitorLocks.get(c).isWriteLocked(); final Boolean isGateStart = race.isGateStart(); - Map> edgesForCompetitor = allEdges.get(c); + final Map> edgesForCompetitor = allEdges.get(c); + final Map> incomingEdgesForCompetitor = incomingEdges.get(c); final Iterable competitorCandidates = getFilteredCandidates(c); for (Candidate newCan : newCandidates) { synchronized (competitorCandidates) { @@ -646,7 +672,7 @@ public class CandidateChooserImpl implements CandidateChooser { edge = new Edge(early, late, startTimingProbability * estimatedDistanceProbability, race.getRace().getCourse().getNumberOfWaypoints()); } - addEdge(edgesForCompetitor, edge); + addEdge(edgesForCompetitor, incomingEdgesForCompetitor, edge); } } } @@ -665,7 +691,7 @@ public class CandidateChooserImpl implements CandidateChooser { return early.getTimePoint() == null || late.getTimePoint() == null || early.getTimePoint().before(late.getTimePoint()); } - private void addEdge(Map> edgesForCompetitor, Edge e) { + private void addEdge(Map> edgesForCompetitor, Map> incomingEdgesForCompetitor, Edge e) { logger.finest(()->"Adding "+ e.toString()); Set edgeSet = edgesForCompetitor.get(e.getStart()); if (edgeSet == null) { @@ -673,6 +699,12 @@ public class CandidateChooserImpl implements CandidateChooser { edgesForCompetitor.put(e.getStart(), edgeSet); } edgeSet.add(e); // FIXME what about edges that should replace an edge between the same two candidates? Will those edges somehow be removed? + Set incomingEdgeSet = incomingEdgesForCompetitor.get(e.getEnd()); + if (incomingEdgeSet == null) { + incomingEdgeSet = new HashSet<>(); + incomingEdgesForCompetitor.put(e.getEnd(), incomingEdgeSet); + } + incomingEdgeSet.add(e); } /** @@ -995,22 +1027,67 @@ public class CandidateChooserImpl implements CandidateChooser { * path analysis is triggered yet. */ private void updateFilteredCandidatesAndAdjustGraph(Competitor c, Iterable newCandidates, Iterable removedCandidates) { - Pair, Iterable> filteredCandidatesAddedAndRemoved = updateFilteredCandidates(c, newCandidates, removedCandidates); + final TimePoint filteringStartedAt = TimePoint.now(); + logger.info("DIAG START updateFilteredCandidates for "+c); + Pair, Iterable> filteredCandidatesAddedAndRemoved = + updateFilteredCandidates(c, newCandidates, removedCandidates); + logger.info("DIAG END updateFilteredCandidates for "+c+": "+ + filteringStartedAt.until(TimePoint.now())); + + final TimePoint adjustGraphStartedAt = TimePoint.now(); + logger.info("DIAG START adjustGraph for "+c); adjustGraph(c, filteredCandidatesAddedAndRemoved); + logger.info("DIAG END adjustGraph for "+c+": "+ + adjustGraphStartedAt.until(TimePoint.now())); } /** * Adjusts the {@link #allEdges graph} based on the nodes added and removed. No {@link #findShortestPath(Competitor) * path analysis} is performed yet. */ - private void adjustGraph(Competitor c, + private void adjustGraph( + Competitor c, Pair, Iterable> filteredCandidatesAddedAndRemoved) { + final Map> competitorEdges = allEdges.get(c); - for (final Candidate candidateRemoved : filteredCandidatesAddedAndRemoved.getB()) { - logger.finest(()->"Removing all edges containing " + candidateRemoved + "of "+ c); - removeEdgesForCandidate(candidateRemoved, competitorEdges); + final Map> competitorIncomingEdges = incomingEdges.get(c); + + int removedCandidates = 0; + for (@SuppressWarnings("unused") final Candidate candidate : + filteredCandidatesAddedAndRemoved.getB()) { + removedCandidates++; } + + long edgesBeforeRemoval = 0; + for (final Set edgeSet : competitorEdges.values()) { + edgesBeforeRemoval += edgeSet.size(); + } + + logger.info("DIAG REMOVE WORKLOAD for " + c + + ": removedCandidates=" + removedCandidates + + ", edgeSets=" + competitorEdges.size() + + ", edgesBeforeRemoval=" + edgesBeforeRemoval); + + final TimePoint removeEdgesStartedAt = TimePoint.now(); + logger.info("DIAG START removeEdgesForCandidates for " + c); + + for (final Candidate candidateRemoved : + filteredCandidatesAddedAndRemoved.getB()) { + logger.finest(() -> "Removing all edges containing " + + candidateRemoved + "of " + c); + removeEdgesForCandidate(candidateRemoved, competitorEdges, competitorIncomingEdges); + } + + logger.info("DIAG END removeEdgesForCandidates for " + c + ": " + + removeEdgesStartedAt.until(TimePoint.now())); + + final TimePoint createNewEdgesStartedAt = TimePoint.now(); + logger.info("DIAG START createNewEdges for " + c); + createNewEdges(c, filteredCandidatesAddedAndRemoved.getA()); + + logger.info("DIAG END createNewEdges for " + c + ": " + + createNewEdgesStartedAt.until(TimePoint.now())); } /** @@ -1079,13 +1156,22 @@ public class CandidateChooserImpl implements CandidateChooser { } } - private void removeEdgesForCandidate(Candidate can, Map> edges) { - edges.remove(can); - for (Set set : edges.values()) { - for (Iterator i = set.iterator(); i.hasNext();) { - final Edge e = i.next(); - if (e.getStart() == can || e.getEnd() == can) { - i.remove(); + private void removeEdgesForCandidate(Candidate can, Map> edges, Map> incoming) { + final Set outgoing = edges.remove(can); + if (outgoing != null) { + for (final Edge e : outgoing) { + final Set inSet = incoming.get(e.getEnd()); + if (inSet != null) { + inSet.remove(e); + } + } + } + final Set incomingForCan = incoming.remove(can); + if (incomingForCan != null) { + for (final Edge e : incomingForCan) { + final Set outSet = edges.get(e.getStart()); + if (outSet != null) { + outSet.remove(e); } } } diff --git a/java/com.sap.sailing.server.trackfiles.test/src/com/sap/sailing/server/trackfiles/test/JumpyTrackSmootheningTest.java b/java/com.sap.sailing.server.trackfiles.test/src/com/sap/sailing/server/trackfiles/test/JumpyTrackSmootheningTest.java index 4d67c0faf31..57c0112feb3 100644 --- a/java/com.sap.sailing.server.trackfiles.test/src/com/sap/sailing/server/trackfiles/test/JumpyTrackSmootheningTest.java +++ b/java/com.sap.sailing.server.trackfiles.test/src/com/sap/sailing/server/trackfiles/test/JumpyTrackSmootheningTest.java @@ -173,6 +173,13 @@ public class JumpyTrackSmootheningTest { @Test public void testMarkPassingCalculatorForAdjusted() throws Exception { + final java.util.logging.FileHandler fileHandler = + new java.util.logging.FileHandler( + System.getProperty("user.home") + "/Desktop/jumpy-markpassing.log", + false); + fileHandler.setFormatter(new java.util.logging.SimpleFormatter()); + java.util.logging.Logger.getLogger("").addHandler(fileHandler); + try { final DynamicGPSFixTrack track = readTrack("GallagherZelenka.gpx.gz"); final Duration durationForAdjustedTrack; final Duration durationForOriginalTrack; @@ -201,6 +208,11 @@ public class JumpyTrackSmootheningTest { assertTrue(durationForAdjustedTrack.times(2).compareTo(durationForOriginalTrack) < 0, "Expected duration for mark passing analysis on adjusted track to be at least two times less than for original track: "+ durationForAdjustedTrack+" vs. "+durationForOriginalTrack); + } finally { + java.util.logging.Logger.getLogger("").removeHandler(fileHandler); + fileHandler.close(); + } + } private DynamicGPSFixTrack readTrack(String filename) throws Exception { From 0ed071ef8d41a5ec28ec08ab58784e48b440af57 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 00:08:00 +0200 Subject: [PATCH 03/28] bug48: run release build for PRs only after an approving review Add a pull_request_review (submitted) trigger and gate the changes/build jobs on an approving review, so a PR (possibly from an untrusted fork) never builds with secrets before a maintainer has vetted the exact code. Secrets are released via a conditional 'privileged-pr-build' environment referenced only on review runs; on push/workflow_dispatch the environment name is empty (no gate), keeping existing behavior unchanged. The environment holds no secrets, so repository-level values are used via environment>repository precedence and rotation stays in one place. Review runs check out the PR head SHA with persist-credentials: false so untrusted build code cannot scrape the token. build-gate now treats a skipped changes job (non-approving review) as pass rather than failure. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55319549f81..e2a05f13694 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,13 @@ on: - '**/*.md' - 'README.md' - 'docs/**' + # Run for pull requests only once an approving review has been submitted. + # The build for a PR (which may originate from an untrusted fork) therefore + # never executes before a maintainer has vetted the exact code. Secrets are + # released to the PR build via the "privileged-pr-build" environment gate + # (see the "environment" key on the build job below). + pull_request_review: + types: [submitted] workflow_dispatch: inputs: skip_tests: @@ -26,11 +33,19 @@ jobs: # The "changes" job will cause the "build" job to get skipped for trivial changes changes: permissions: {} + # Only run for push, manual dispatch, or a PR review that is an approval. + # A non-approving review (comment / changes-requested) must not trigger a build. + if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'approved' }} runs-on: ubuntu-latest outputs: should_run: ${{ steps.filter.outputs.code }} steps: - uses: actions/checkout@v4 + with: + # On an approving review, evaluate the filter against the reviewed PR + # head commit rather than the base branch. + ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - id: filter uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 with: @@ -45,6 +60,17 @@ jobs: build: needs: changes if: ${{ needs.changes.outputs.should_run == 'true' || github.event_name == 'workflow_dispatch' }} + # For a PR review-triggered run, reference the "privileged-pr-build" + # environment so its protection rules (a required-reviewer team) must pass + # before the job is sent to a runner and before any secrets are released. + # For push / workflow_dispatch the name evaluates to an empty string, which + # GitHub treats as "no environment" — those runs are NOT gated and use the + # repository-level secrets exactly as before. The environment deliberately + # holds no secrets of its own: because same-named secrets resolve + # environment > repository, leaving it empty means the repository-level + # values are used, so secret rotation stays in a single place. + environment: + name: ${{ github.event_name == 'pull_request_review' && 'privileged-pr-build' || '' }} permissions: contents: write checks: write @@ -94,6 +120,13 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: '500' + # On an approving review, build the reviewed PR head commit (which may + # come from an untrusted fork) rather than the base branch, and do not + # leave the GITHUB_TOKEN in .git/config where that PR code could read + # it. For push / workflow_dispatch these evaluate to the previous + # defaults, so existing runs are unchanged. + ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: ${{ github.event_name != 'pull_request_review' }} - name: Install JDK 8 uses: actions/setup-java@v4 with: @@ -243,6 +276,8 @@ jobs: echo "Build passed." elif [[ "${{ needs.build.result }}" == "skipped" && "${{ needs.changes.outputs.should_run }}" == "false" ]]; then echo "No relevant changes — skipping is OK." + elif [[ "${{ needs.changes.result }}" == "skipped" ]]; then + echo "Pipeline did not apply to this event (e.g. a non-approving review) — nothing to gate." else echo "Build failed or was cancelled." exit 1 From cfa5cf505c543a7c77d79847df2a54a92856290a Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 10:41:52 +0200 Subject: [PATCH 04/28] more instructions for Claude Code in CLAUDE.md --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a8bcaded3ff..4660a6d898e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,10 @@ Your WebSearch tool is broken, don't use it. Instead fetch https://duckduckgo.co # Claude Code Instructions for sailing-analytics +## Research before Answering + +Make sure to research the topic/question well and thoroughly instead of just answering quickly and superficially. + ## Java Coding Style Preferences ### Variable Declarations From 2f5a5c1bc1fee584e3dbdea40a9fb17756078c2e Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 10:52:58 +0200 Subject: [PATCH 05/28] bug48: apply the trivial-changes path filter to approved-PR builds too The changes job used dorny/paths-filter without an explicit base/ref, so on a pull_request_review event (which, unlike pull_request, is not auto-detected by the action) it diffed against the default branch with a shallow clone and treated every file as changed. A docs-only PR therefore ran a full build and hit the deployment-review gate needlessly. Diff the PR head (github.event.pull_request.head.sha) against the PR base (github.event.pull_request.base.sha) explicitly, fetch full history so the merge-base resolves, and grant pull-requests: read. Push and workflow_dispatch behavior is unchanged (base/ref fall back to empty, fetch-depth to 1). Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2a05f13694..658d952b1c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,10 @@ on: jobs: # The "changes" job will cause the "build" job to get skipped for trivial changes changes: - permissions: {} + # pull-requests: read lets dorny/paths-filter diff via the REST API on the + # PR path; the empty default would make it treat everything as changed. + permissions: + pull-requests: read # Only run for push, manual dispatch, or a PR review that is an approval. # A non-approving review (comment / changes-requested) must not trigger a build. if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'approved' }} @@ -43,12 +46,20 @@ jobs: - uses: actions/checkout@v4 with: # On an approving review, evaluate the filter against the reviewed PR - # head commit rather than the base branch. + # head commit rather than the base branch. fetch-depth: 0 gives the + # filter the history it needs to find the merge-base; without it the + # shallow clone has no common ancestor and every file counts as added. ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || github.sha }} + fetch-depth: ${{ github.event_name == 'pull_request_review' && '0' || '1' }} persist-credentials: false - id: filter uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 with: + # pull_request_review is not a pull_request event, so the action does + # not auto-detect the PR base. Diff the PR head against the PR base + # explicitly so a docs-only PR is correctly seen as no code change. + base: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.base.sha || '' }} + ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || '' }} filters: | code: - '!wiki/**' From 03dbc4383483ef9e9896583ea1b44fc230a501bc Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 11:08:50 +0200 Subject: [PATCH 06/28] bug48: fix path filter so docs-only PRs are correctly excluded The 'code' filter used only negated rules under the default 'some' quantifier. picomatch treats '!README.md' as "any path except README.md", so a changed README.md still matched a rule and set code=true, forcing a full build for docs-only PRs (as seen on PR #48). Add a positive '**' rule and set predicate-quantifier: 'every' (the pinned v3.0.3 does not support the v4-only 'some-with-excludes'), so code is true only when a changed file matches the '**' rule AND all '!' exclusions, i.e. it is a non-trivial file. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 658d952b1c7..20d50ce6b02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,8 +60,17 @@ jobs: # explicitly so a docs-only PR is correctly seen as no code change. base: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.base.sha || '' }} ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || '' }} + # With the default 'some' quantifier a negated rule still counts as a + # match (picomatch treats '!README.md' as "anything but README.md"), + # so a changed README.md would set code=true. The pinned v3.0.3 does + # not support 'some-with-excludes' (a v4 addition), so use 'every': + # code is true only when a changed file matches ALL rules, i.e. the + # positive '**' AND every '!' exclusion (a trivial file fails one of + # them and is thus excluded). + predicate-quantifier: 'every' filters: | code: + - '**' - '!wiki/**' - '!.github/workflows/*' - '!**/*.md' From a91ae444e2633fa8fb8bb62b8cf3c68287866a1b Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 11:12:23 +0200 Subject: [PATCH 07/28] bug48: upgrade dorny/paths-filter to v4.0.3 and use some-with-excludes v4 adds the 'some-with-excludes' predicate-quantifier, which is the natural fit here: code is true only when a changed file matches the positive '**' rule and none of the '!' exclusions. Replaces the v3.0.3 'every' workaround. The only v4 breaking change is the Node 20->24 runtime bump. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20d50ce6b02..bf8b9ebb414 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,21 +53,20 @@ jobs: fetch-depth: ${{ github.event_name == 'pull_request_review' && '0' || '1' }} persist-credentials: false - id: filter - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 with: # pull_request_review is not a pull_request event, so the action does # not auto-detect the PR base. Diff the PR head against the PR base # explicitly so a docs-only PR is correctly seen as no code change. base: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.base.sha || '' }} ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || '' }} - # With the default 'some' quantifier a negated rule still counts as a + # Under the default 'some' quantifier a negated rule still counts as a # match (picomatch treats '!README.md' as "anything but README.md"), - # so a changed README.md would set code=true. The pinned v3.0.3 does - # not support 'some-with-excludes' (a v4 addition), so use 'every': - # code is true only when a changed file matches ALL rules, i.e. the - # positive '**' AND every '!' exclusion (a trivial file fails one of - # them and is thus excluded). - predicate-quantifier: 'every' + # so a changed README.md would set code=true. 'some-with-excludes' + # (v4+) makes the '!' rules act as real exclusions: code is true only + # when a changed file matches the positive '**' rule AND none of the + # excludes. + predicate-quantifier: 'some-with-excludes' filters: | code: - '**' From 7d5b5093b5766e1e05c4dbf553d254ff23579d12 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 11:46:25 +0200 Subject: [PATCH 08/28] bug48: drop the approving-review environment gate upstream Upstream committers have more freedom handling PRs, so the pull_request_review trigger, the privileged-pr-build environment gate, and the associated head-SHA / persist-credentials / base-ref conditionals are not needed here. Remove them, keeping the plain push/workflow_dispatch build. The dorny/paths-filter correctness fix (v4.0.3 + some-with-excludes + a positive '**' rule) is retained: it fixes a pre-existing bug where docs-only changes were misclassified as code changes on the push path too. The downstream (SAP) branch keeps the environment-based approval approach via a subsequent 'merge -s ours'. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 48 +---------------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf8b9ebb414..1261f4f1ba5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,13 +7,6 @@ on: - '**/*.md' - 'README.md' - 'docs/**' - # Run for pull requests only once an approving review has been submitted. - # The build for a PR (which may originate from an untrusted fork) therefore - # never executes before a maintainer has vetted the exact code. Secrets are - # released to the PR build via the "privileged-pr-build" environment gate - # (see the "environment" key on the build job below). - pull_request_review: - types: [submitted] workflow_dispatch: inputs: skip_tests: @@ -32,34 +25,15 @@ on: jobs: # The "changes" job will cause the "build" job to get skipped for trivial changes changes: - # pull-requests: read lets dorny/paths-filter diff via the REST API on the - # PR path; the empty default would make it treat everything as changed. - permissions: - pull-requests: read - # Only run for push, manual dispatch, or a PR review that is an approval. - # A non-approving review (comment / changes-requested) must not trigger a build. - if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'approved' }} + permissions: {} runs-on: ubuntu-latest outputs: should_run: ${{ steps.filter.outputs.code }} steps: - uses: actions/checkout@v4 - with: - # On an approving review, evaluate the filter against the reviewed PR - # head commit rather than the base branch. fetch-depth: 0 gives the - # filter the history it needs to find the merge-base; without it the - # shallow clone has no common ancestor and every file counts as added. - ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || github.sha }} - fetch-depth: ${{ github.event_name == 'pull_request_review' && '0' || '1' }} - persist-credentials: false - id: filter uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 with: - # pull_request_review is not a pull_request event, so the action does - # not auto-detect the PR base. Diff the PR head against the PR base - # explicitly so a docs-only PR is correctly seen as no code change. - base: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.base.sha || '' }} - ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || '' }} # Under the default 'some' quantifier a negated rule still counts as a # match (picomatch treats '!README.md' as "anything but README.md"), # so a changed README.md would set code=true. 'some-with-excludes' @@ -79,17 +53,6 @@ jobs: build: needs: changes if: ${{ needs.changes.outputs.should_run == 'true' || github.event_name == 'workflow_dispatch' }} - # For a PR review-triggered run, reference the "privileged-pr-build" - # environment so its protection rules (a required-reviewer team) must pass - # before the job is sent to a runner and before any secrets are released. - # For push / workflow_dispatch the name evaluates to an empty string, which - # GitHub treats as "no environment" — those runs are NOT gated and use the - # repository-level secrets exactly as before. The environment deliberately - # holds no secrets of its own: because same-named secrets resolve - # environment > repository, leaving it empty means the repository-level - # values are used, so secret rotation stays in a single place. - environment: - name: ${{ github.event_name == 'pull_request_review' && 'privileged-pr-build' || '' }} permissions: contents: write checks: write @@ -139,13 +102,6 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: '500' - # On an approving review, build the reviewed PR head commit (which may - # come from an untrusted fork) rather than the base branch, and do not - # leave the GITHUB_TOKEN in .git/config where that PR code could read - # it. For push / workflow_dispatch these evaluate to the previous - # defaults, so existing runs are unchanged. - ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || github.sha }} - persist-credentials: ${{ github.event_name != 'pull_request_review' }} - name: Install JDK 8 uses: actions/setup-java@v4 with: @@ -295,8 +251,6 @@ jobs: echo "Build passed." elif [[ "${{ needs.build.result }}" == "skipped" && "${{ needs.changes.outputs.should_run }}" == "false" ]]; then echo "No relevant changes — skipping is OK." - elif [[ "${{ needs.changes.result }}" == "skipped" ]]; then - echo "Pipeline did not apply to this event (e.g. a non-approving review) — nothing to gate." else echo "Build failed or was cancelled." exit 1 From 6cdbd90f578643b15aad5d03a6ee9f39d090d893 Mon Sep 17 00:00:00 2001 From: Masha Kashirina Date: Thu, 27 Aug 2026 12:02:41 +0200 Subject: [PATCH 09/28] bug6279: logger disabled --- .../test/JumpyTrackSmootheningTest.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/java/com.sap.sailing.server.trackfiles.test/src/com/sap/sailing/server/trackfiles/test/JumpyTrackSmootheningTest.java b/java/com.sap.sailing.server.trackfiles.test/src/com/sap/sailing/server/trackfiles/test/JumpyTrackSmootheningTest.java index 57c0112feb3..9fdb8884080 100644 --- a/java/com.sap.sailing.server.trackfiles.test/src/com/sap/sailing/server/trackfiles/test/JumpyTrackSmootheningTest.java +++ b/java/com.sap.sailing.server.trackfiles.test/src/com/sap/sailing/server/trackfiles/test/JumpyTrackSmootheningTest.java @@ -173,12 +173,12 @@ public class JumpyTrackSmootheningTest { @Test public void testMarkPassingCalculatorForAdjusted() throws Exception { - final java.util.logging.FileHandler fileHandler = - new java.util.logging.FileHandler( - System.getProperty("user.home") + "/Desktop/jumpy-markpassing.log", - false); - fileHandler.setFormatter(new java.util.logging.SimpleFormatter()); - java.util.logging.Logger.getLogger("").addHandler(fileHandler); +// final java.util.logging.FileHandler fileHandler = +// new java.util.logging.FileHandler( +// System.getProperty("user.home") + "/Desktop/jumpy-markpassing.log", +// false); +// fileHandler.setFormatter(new java.util.logging.SimpleFormatter()); +// java.util.logging.Logger.getLogger("").addHandler(fileHandler); try { final DynamicGPSFixTrack track = readTrack("GallagherZelenka.gpx.gz"); final Duration durationForAdjustedTrack; @@ -209,8 +209,8 @@ public class JumpyTrackSmootheningTest { "Expected duration for mark passing analysis on adjusted track to be at least two times less than for original track: "+ durationForAdjustedTrack+" vs. "+durationForOriginalTrack); } finally { - java.util.logging.Logger.getLogger("").removeHandler(fileHandler); - fileHandler.close(); +// java.util.logging.Logger.getLogger("").removeHandler(fileHandler); +// fileHandler.close(); } } From fe95b7b77036d55191ca978c807736dea7abf065 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 14:40:20 +0200 Subject: [PATCH 10/28] updated dorny/test-reporter from 1.9 to 3.0 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1261f4f1ba5..d49b24f32ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -159,7 +159,7 @@ jobs: path: build.log retention-days: 90 - name: Collect Test Reports - uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1 + uses: dorny/test-reporter@a6ddd83ac95ff4586f5d3aceeb314d9a1841db95 # v3.0.0 if: always() with: name: Maven Tests From d31e2510e81e7a777057773865733ea3ae34da19 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 16:05:38 +0200 Subject: [PATCH 11/28] redact token from README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c8292625ac..a1e09db5b95 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ to connect to the server's OSGi console. Alternatively, use an "environment" definition that sets useful defaults, e.g., like this: ``` - docker run -P -it --rm -e "SERVER_NAME=test77" -e "USE_ENVIRONMENT=live-master-server" -e "REPLICATE_MASTER_BEARER_TOKEN=BRxGpF0nr68Z4m/f13/MgiYhdRB3xoDCYd+rLc17rTs=" ghcr.io/eclipse-sailing-analytics/sailing-analytics:latest \ + docker run -P -it --rm -e "SERVER_NAME=test77" -e "USE_ENVIRONMENT=live-master-server" -e "REPLICATE_MASTER_BEARER_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ghcr.io/eclipse-sailing-analytics/sailing-analytics:latest \ bash -c "rm env.sh; echo \" SERVER_NAME=test77 USE_ENVIRONMENT=live-master-server From 180b05e0656541634c50d46c1576c9c44cec06e4 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 18:01:26 +0200 Subject: [PATCH 12/28] Add two-stage reviewed-tag build flow (compile-only without secrets, full build with secrets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A maintainer reviews a fork PR's exact SHA, then pushes a tag at that SHA: '*-reviewed-for-compile' runs a compile-only build with NO secrets (cheap sanity check), '*-reviewed-for-build' runs the full build with tests and secrets. A tag push is a trusted push event in the base repo, so it receives secrets — the only way to run secret-bearing validation on fork-authored code. - push trigger: keep branches: ['**'] (required so the new tags: filter does not silently disable branch triggers) and add the two reviewed-tag families. - build job: a 'Determine build mode' step computes skip_tests (true for a -for-compile tag or a workflow_dispatch skip_tests=true) and exposes it as a job output. The five test-runtime secrets resolve to '' when skip_tests is true, so their values never enter the environment on a compile-only run; -t is passed on the same flag. Test Reports step is skipped when tests are. - build-gate (required check): green only for a genuine full build with tests, or a trivial-change skip; a compile-only success fails it. This also fixes a pre-existing bug where a skip_tests=true dispatch satisfied the gate. - compile-gate (new, advisory): green whenever the build compiled, regardless of tests, giving -for-compile runs a distinct check without touching the required build-gate. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 86 +++++++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d49b24f32ff..3e045be9a26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,26 @@ name: release on: push: + # NOTE: introducing a "tags:" filter makes GitHub treat any Git ref kind that + # is NOT filtered as "undefined", and the workflow then stops running for it. + # So branches MUST be listed explicitly here; without "branches: ['**']" the + # tag filter below would silently disable all branch-push builds. '**' (not + # '*') is required to match branch names containing slashes (e.g. releases/x). + branches: + - '**' + # Reviewed-fork-PR tags. A maintainer fetches a fork PR head, reviews the + # exact SHA, then pushes one of these tags at that SHA. Because a tag push is + # a push event in this (base) repo, the run executes in trusted context WITH + # secrets — the only way to run secret-bearing validation on fork-authored + # code. '*-reviewed-for-compile' runs a compile-only build with NO secrets + # (cheap sanity check); '*-reviewed-for-build' runs the full build with tests + # and secrets. Safety rests on the maintainer having read that exact SHA + # before pushing the tag. See the "Determine build mode" step below. + tags: + - '*-reviewed-for-compile' + - '*-reviewed-for-build' + # paths-ignore is not evaluated for tag pushes (GitHub rule), so it only + # scopes branch pushes, exactly as before. paths-ignore: - 'wiki/**' - '.github/workflows/*' @@ -58,7 +78,21 @@ jobs: checks: write actions: read runs-on: ${{ github.event.inputs.runner_cpus != '' && format('ubuntu-latest-{0}cpu', github.event.inputs.runner_cpus) || 'ubuntu-latest' }} + outputs: + skip_tests: ${{ steps.mode.outputs.skip_tests }} steps: + - name: Determine build mode + id: mode + shell: bash + # skip_tests=true means "compile only, no tests, no secrets". It is true + # for a workflow_dispatch with skip_tests=true, OR for a push of a + # *-reviewed-for-compile tag. Everything else (branches, *-reviewed-for-build + # tags) runs the full build with tests and secrets. + run: | + skip=false + if [[ "${{ github.event.inputs.skip_tests }}" == "true" ]]; then skip=true; fi + if [[ "${{ github.ref }}" == refs/tags/*-reviewed-for-compile ]]; then skip=true; fi + echo "skip_tests=$skip" >> "$GITHUB_OUTPUT" - name: Free Disk Space (Ubuntu) uses: jlumbroso/free-disk-space@v1.3.1 with: @@ -137,17 +171,21 @@ jobs: - shell: bash env: # Or as an environment variable TMP: /mnt/tmp - AWS_S3_TEST_S3ACCESSID: ${{ secrets.AWS_S3_TEST_S3ACCESSID }} - AWS_S3_TEST_S3ACCESSKEY: ${{ secrets.AWS_S3_TEST_S3ACCESSKEY }} - GEONAMES_ORG_USERNAMES: ${{ secrets.GEONAMES_ORG_USERNAMES }} - GOOGLE_MAPS_AUTHENTICATION_PARAMS: ${{ secrets.GOOGLE_MAPS_AUTHENTICATION_PARAMS }} + # These five are consumed only at TEST runtime. For a compile-only run + # (skip_tests) they resolve to '' so the real secret values are never + # placed in the process environment where reviewed-but-fork-authored + # code could read them. The full build (skip_tests false) gets them all. + AWS_S3_TEST_S3ACCESSID: ${{ steps.mode.outputs.skip_tests == 'true' && '' || secrets.AWS_S3_TEST_S3ACCESSID }} + AWS_S3_TEST_S3ACCESSKEY: ${{ steps.mode.outputs.skip_tests == 'true' && '' || secrets.AWS_S3_TEST_S3ACCESSKEY }} + GEONAMES_ORG_USERNAMES: ${{ steps.mode.outputs.skip_tests == 'true' && '' || secrets.GEONAMES_ORG_USERNAMES }} + GOOGLE_MAPS_AUTHENTICATION_PARAMS: ${{ steps.mode.outputs.skip_tests == 'true' && '' || secrets.GOOGLE_MAPS_AUTHENTICATION_PARAMS }} POLAR_DATA_BASE_URL: ${{ vars.POLAR_DATA_BASE_URL }} - POLAR_DATA_BEARER_TOKEN: ${{ secrets.POLAR_DATA_BEARER_TOKEN }} + POLAR_DATA_BEARER_TOKEN: ${{ steps.mode.outputs.skip_tests == 'true' && '' || secrets.POLAR_DATA_BEARER_TOKEN }} APP_PARAMETERS: "-Daws.region=eu-west-1" JAVA8_HOME: ${{env.JAVA_HOME_8_X64}} JAVA17_HOME: ${{env.JAVA_HOME_17_X64}} run: | - ./configuration/buildAndUpdateProduct.sh -x ${{ github.event.inputs.runner_cpus == '' && '4' || github.event.inputs.runner_cpus }} ${{ github.event.inputs.skip_tests == 'true' && '-t' || '' }} ${{ github.event.inputs.local_target_platform == 'true' && '-v' || '' }} build 2>&1 + ./configuration/buildAndUpdateProduct.sh -x ${{ github.event.inputs.runner_cpus == '' && '4' || github.event.inputs.runner_cpus }} ${{ steps.mode.outputs.skip_tests == 'true' && '-t' || '' }} ${{ github.event.inputs.local_target_platform == 'true' && '-v' || '' }} build 2>&1 - name: show disk stats if: always() run: df -h @@ -160,7 +198,9 @@ jobs: retention-days: 90 - name: Collect Test Reports uses: dorny/test-reporter@a6ddd83ac95ff4586f5d3aceeb314d9a1841db95 # v3.0.0 - if: always() + # Skip for compile-only runs: no tests ran, so there are no TEST-*.xml + # and fail-on-error would otherwise error on zero matches. + if: ${{ always() && steps.mode.outputs.skip_tests != 'true' }} with: name: Maven Tests path: '**/TEST-*.xml' @@ -240,6 +280,11 @@ jobs: echo "Identified CI job: ${JOB}" curl -u "${{ vars.CI_JOB_USERNAME }}:${{ secrets.CI_JOB_PASSWORD}}" ${{ vars.CI_BASE_URL }}/job/${JOB}/build?token=${{ secrets.CI_JOB_TOKEN }} + # build-gate is the REQUIRED status check in branch protection. It must go + # green ONLY for a genuine full build (tests actually ran) or a legitimate + # trivial-change skip. A compile-only run (skip_tests true, e.g. a + # *-reviewed-for-compile tag or a workflow_dispatch with skip_tests=true) must + # NOT satisfy it — so those cannot qualify a PR for merge. build-gate: permissions: {} needs: [changes, build] @@ -247,12 +292,35 @@ jobs: runs-on: ubuntu-latest steps: - run: | - if [[ "${{ needs.build.result }}" == "success" ]]; then - echo "Build passed." + if [[ "${{ needs.build.result }}" == "success" && "${{ needs.build.outputs.skip_tests }}" != "true" ]]; then + echo "Full build with tests passed." elif [[ "${{ needs.build.result }}" == "skipped" && "${{ needs.changes.outputs.should_run }}" == "false" ]]; then echo "No relevant changes — skipping is OK." + elif [[ "${{ needs.build.result }}" == "success" && "${{ needs.build.outputs.skip_tests }}" == "true" ]]; then + echo "Compile-only build (tests skipped) does not satisfy the required check." + exit 1 else echo "Build failed or was cancelled." exit 1 fi + # compile-gate is an ADVISORY (non-required) check. It reflects only whether + # the build job compiled successfully, regardless of whether tests ran — a full + # build implies compilation succeeded. It gives *-reviewed-for-compile runs a + # distinct green/red on the PR without touching the required build-gate check. + compile-gate: + permissions: {} + needs: [changes, build] + if: always() + runs-on: ubuntu-latest + steps: + - run: | + if [[ "${{ needs.build.result }}" == "success" ]]; then + echo "Compilation succeeded." + elif [[ "${{ needs.build.result }}" == "skipped" && "${{ needs.changes.outputs.should_run }}" == "false" ]]; then + echo "No relevant changes — nothing to compile." + else + echo "Compilation failed or was cancelled." + exit 1 + fi + From dfc113c19fc322850560346a51cadefbcdeb827e Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 18:26:01 +0200 Subject: [PATCH 13/28] Auto-build submitted PRs (same-repo: full build with tests+secrets; fork: compile-only, no secrets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A submitted PR now triggers a build automatically, split by trust: - Same-repo PR (branch pushed by a committer): full build with tests and secrets — same trust level as a direct branch push, which already builds with secrets. build-gate can go green with no manual tag. - Fork PR: compile-only with NO secrets. GitHub withholds secrets from fork pull_request runs at the event level, and a compile-only build needs none, so this is both automatic and safe. build-gate stays red; the secret-bearing full build of fork code remains gated behind a maintainer pushing a *-reviewed-for-build tag. Uses pull_request (not pull_request_target), so fork runs get a read-only token and no secrets; with the secret env values already resolving to '' when tests are skipped, fork code never meets a secret. - pull_request trigger: types [opened, synchronize, reopened, ready_for_review] with the same paths-ignore as push; no branches: filter (targets PRs into any base branch). - build job if:: draft PRs are excluded (build only once marked ready). - Determine build mode: a fork pull_request (head.repo.full_name != the base repo) sets skip_tests=true; same-repo PRs run the full build. The conditional secret env, -t flag, Test Reports guard, and both gate jobs already key on the computed skip_tests flag, so they need no change. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e045be9a26..b591c451ce2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,22 @@ on: - '**/*.md' - 'README.md' - 'docs/**' + # Auto-build submitted PRs. A same-repo PR (branch pushed by a committer) runs + # the full build with tests and secrets — same trust as a direct branch push. + # A fork PR runs compile-only with NO secrets (GitHub withholds secrets from + # fork pull_request runs at the event level); the secret-bearing full build of + # fork code stays gated behind a maintainer pushing a *-reviewed-for-build tag. + # No branches: filter here — for pull_request that would filter by BASE branch; + # omitting it targets PRs into any branch. Draft PRs are excluded by the build + # job's if:. See the "Determine build mode" step for the fork/same-repo split. + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths-ignore: + - 'wiki/**' + - '.github/workflows/*' + - '**/*.md' + - 'README.md' + - 'docs/**' workflow_dispatch: inputs: skip_tests: @@ -72,7 +88,9 @@ jobs: build: needs: changes - if: ${{ needs.changes.outputs.should_run == 'true' || github.event_name == 'workflow_dispatch' }} + if: >- + ${{ (needs.changes.outputs.should_run == 'true' || github.event_name == 'workflow_dispatch') + && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} permissions: contents: write checks: write @@ -85,13 +103,20 @@ jobs: id: mode shell: bash # skip_tests=true means "compile only, no tests, no secrets". It is true - # for a workflow_dispatch with skip_tests=true, OR for a push of a - # *-reviewed-for-compile tag. Everything else (branches, *-reviewed-for-build - # tags) runs the full build with tests and secrets. + # for a workflow_dispatch with skip_tests=true, for a push of a + # *-reviewed-for-compile tag, OR for a fork-originated pull_request (fork + # runs get no secrets, so we compile without tests; the secret-bearing + # full build of fork code is gated behind a *-reviewed-for-build tag). + # Everything else (branches, same-repo PRs, *-reviewed-for-build tags) + # runs the full build with tests and secrets. run: | skip=false if [[ "${{ github.event.inputs.skip_tests }}" == "true" ]]; then skip=true; fi if [[ "${{ github.ref }}" == refs/tags/*-reviewed-for-compile ]]; then skip=true; fi + if [[ "${{ github.event_name }}" == "pull_request" \ + && "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]]; then + skip=true + fi echo "skip_tests=$skip" >> "$GITHUB_OUTPUT" - name: Free Disk Space (Ubuntu) uses: jlumbroso/free-disk-space@v1.3.1 From e0679de32d1a72787f1b3e1bdc73021fadc2751b Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Thu, 27 Aug 2026 20:19:15 +0200 Subject: [PATCH 14/28] Upgrade actions/checkout, setup-java, upload-artifact to node24 releases (pinned by SHA) actions/checkout@v4, setup-java@v4 and upload-artifact@v4 run on the node20 runtime, which is EOL (Node 20 reached end-of-life on 2026-03-24) and emits a deprecation warning on every run. Bump each to its current release, all of which run on node24, and pin by full commit SHA (matching how the third-party actions in this workflow are pinned): - actions/checkout v4 -> v7.0.1 (3d3c42e5aac5ba805825da76410c181273ba90b1) - actions/setup-java v4 -> v6.0.0 (dd06d9cba3e5552c54d9f8ea23572deb30010f7c) - actions/upload-artifact v4 -> v7.0.1 (043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) checkout v5+ requires Actions Runner >= v2.327.1, which GitHub-hosted ubuntu-latest satisfies. setup-java v6 is unaffected for the Temurin 8/17/25 toolchains used here. actions/upload-release-asset@v1 is intentionally left as is: it is archived with no maintained successor and belongs to a separate change, not this node20 sweep. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b591c451ce2..caa6de2866a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: outputs: should_run: ${{ steps.filter.outputs.code }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: filter uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 with: @@ -158,23 +158,23 @@ jobs: echo Installed firefox versions: ${{ steps.setup-firefox.outputs.firefox-version }} ${{ steps.setup-firefox.outputs.firefox-path }} --version - name: Check out the repository to the runner - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: '500' - name: Install JDK 8 - uses: actions/setup-java@v4 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '8' mvn-toolchain-id: 'JavaSE-1.8' - name: Install JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '17' mvn-toolchain-id: 'JavaSE-17' - name: Install JDK 25 - uses: actions/setup-java@v4 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '25' @@ -215,7 +215,7 @@ jobs: if: always() run: df -h - name: Upload build log - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: build.log @@ -243,7 +243,7 @@ jobs: run: | echo "SIMPLE_VERSION_INFO=$( basename dist/* )" >>${GITHUB_ENV} - name: Upload test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: test-results @@ -251,7 +251,7 @@ jobs: **/surefire-reports/** retention-days: 90 - name: Upload distribution artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/docker-24' || github.ref == 'refs/heads/docker-25' || startsWith(github.ref, 'refs/heads/releases/') }} with: name: ${{ env.SIMPLE_VERSION_INFO }} From 31f0e80564f7381237a2be14e966acd925326fd1 Mon Sep 17 00:00:00 2001 From: MashaKashirina Date: Fri, 28 Aug 2026 10:26:26 +0200 Subject: [PATCH 15/28] Update createJenkinsJobForIssue.sh MacOS --- configuration/createJenkinsJobForIssue.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/configuration/createJenkinsJobForIssue.sh b/configuration/createJenkinsJobForIssue.sh index d06e1d1a411..55800f438c7 100755 --- a/configuration/createJenkinsJobForIssue.sh +++ b/configuration/createJenkinsJobForIssue.sh @@ -22,7 +22,11 @@ echo "Trying to obtain bug summary/title from Github..." BUG_SUMMARY="$( curl -s -H 'Accept: application/vnd.github+json' https://api.github.com/repos/eclipse-sailing-analytics/sailing-analytics/issues/${BUG_ID} | jq -r '.title' )" echo "Found: ${BUG_SUMMARY}" if [ -z "${USERNAME}" ]; then - read -p "Jenkins Username (e-mail): " USERNAME + if [[ "$OSTYPE" == *"$OS_FOR_GSED"* ]]; then +   read -e -p "Jenkins Username (e-mail): " USERNAME +  else +   read -p "Jenkins Username (e-mail): " USERNAME +fi fi if [ -z "${PASSWORD}" ]; then read -s -p "Jenkins Password (API Token): " PASSWORD From 51403fbe1f50b9409f0d51ab5a8e4e9948e06875 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 12:08:59 +0200 Subject: [PATCH 16/28] Skip build-gate on successful compile-only runs so they show pending (not red) while staying merge-blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful compile-only run (fork pull_request, *-reviewed-for-compile tag, or workflow_dispatch with skip_tests=true) previously made build-gate exit 1: the required check went red AND the whole Actions run showed as failed, even though compilation succeeded. That reads as breakage when nothing broke. Fork pull_request runs get a read-only GITHUB_TOKEN (permissions: is capped to read-only regardless of what the workflow requests), so the Commit Statuses and Checks APIs both 403 there — a posted 'neutral'/status cannot mark the check. And neutral would not block merge anyway (GitHub treats neutral/skipped/success as passing for required checks). The only state that is both non-red and still merge-blocking is a required check that is never reported: it sits 'Expected — waiting' (pending). So build-gate is now SKIPPED via a job-level if: for exactly the successful compile-only case. A required check whose job never starts stays pending, so the PR remains un-mergeable without a red X and the run is not marked failed. This must be a job-level skip (job never starts): a job that runs and reports conclusion 'skipped' counts as PASSED and would let the PR merge. The guard is deliberately narrow — !(build.result == success && skip_tests == true) — so a compile-only build that FAILED still runs the gate and goes red, and the trivial-change skip still runs and goes green (docs-only PRs are not left pending forever). The now-unreachable compile-only branch in the gate body is removed. Merge qualification for fork code continues to come from a maintainer pushing a *-reviewed-for-build tag (full trusted build with tests). compile-gate (advisory) still runs and goes green, giving a clear positive compile signal beside the pending build-gate. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index caa6de2866a..c3d035255df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -307,13 +307,28 @@ jobs: # build-gate is the REQUIRED status check in branch protection. It must go # green ONLY for a genuine full build (tests actually ran) or a legitimate - # trivial-change skip. A compile-only run (skip_tests true, e.g. a - # *-reviewed-for-compile tag or a workflow_dispatch with skip_tests=true) must - # NOT satisfy it — so those cannot qualify a PR for merge. + # trivial-change skip. + # + # A SUCCESSFUL compile-only run (build succeeded AND skip_tests true — e.g. a + # fork pull_request, a *-reviewed-for-compile tag, or a workflow_dispatch with + # skip_tests=true) must NOT qualify a PR for merge, but should also NOT show as + # a failed/red run. So for that one case this job is SKIPPED via its if:. A + # required check whose job never starts is left "Expected — waiting" (pending): + # the PR stays un-mergeable WITHOUT a red X, and the workflow run is not marked + # failed. Merge qualification for fork code then comes only from a maintainer + # pushing a *-reviewed-for-build tag (full trusted build with tests). + # + # IMPORTANT: this must be a job-level skip (job never starts), NOT run-then- + # report-skipped — a job that runs and reports conclusion "skipped" counts as + # PASSED for branch protection and would let the PR merge. Also note the guard + # is deliberately narrow: it fires ONLY for a *successful* compile-only build. + # A compile-only build that FAILED (build.result != success) still runs the + # gate below and goes red, and the trivial-change skip still runs and goes + # green — so docs-only PRs are not left pending forever. build-gate: permissions: {} needs: [changes, build] - if: always() + if: ${{ !(needs.build.result == 'success' && needs.build.outputs.skip_tests == 'true') }} runs-on: ubuntu-latest steps: - run: | @@ -321,9 +336,6 @@ jobs: echo "Full build with tests passed." elif [[ "${{ needs.build.result }}" == "skipped" && "${{ needs.changes.outputs.should_run }}" == "false" ]]; then echo "No relevant changes — skipping is OK." - elif [[ "${{ needs.build.result }}" == "success" && "${{ needs.build.outputs.skip_tests }}" == "true" ]]; then - echo "Compile-only build (tests skipped) does not satisfy the required check." - exit 1 else echo "Build failed or was cancelled." exit 1 From b01257f12623deeec99e88cc2be7ce6df9b29e5f Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 15:33:02 +0200 Subject: [PATCH 17/28] bug6279: use Util.size instead of force-looping; O(1) now for typical collections --- .../impl/CandidateChooserImpl.java | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java b/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java index 7a31c63b3d1..d8006ff26ca 100755 --- a/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java +++ b/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java @@ -1048,21 +1048,13 @@ public class CandidateChooserImpl implements CandidateChooser { private void adjustGraph( Competitor c, Pair, Iterable> filteredCandidatesAddedAndRemoved) { - final Map> competitorEdges = allEdges.get(c); final Map> competitorIncomingEdges = incomingEdges.get(c); - - int removedCandidates = 0; - for (@SuppressWarnings("unused") final Candidate candidate : - filteredCandidatesAddedAndRemoved.getB()) { - removedCandidates++; - } - + final int removedCandidates = Util.size(filteredCandidatesAddedAndRemoved.getB()); long edgesBeforeRemoval = 0; for (final Set edgeSet : competitorEdges.values()) { edgesBeforeRemoval += edgeSet.size(); } - logger.info("DIAG REMOVE WORKLOAD for " + c + ": removedCandidates=" + removedCandidates + ", edgeSets=" + competitorEdges.size() @@ -1070,22 +1062,15 @@ public class CandidateChooserImpl implements CandidateChooser { final TimePoint removeEdgesStartedAt = TimePoint.now(); logger.info("DIAG START removeEdgesForCandidates for " + c); - - for (final Candidate candidateRemoved : - filteredCandidatesAddedAndRemoved.getB()) { - logger.finest(() -> "Removing all edges containing " - + candidateRemoved + "of " + c); + for (final Candidate candidateRemoved : filteredCandidatesAddedAndRemoved.getB()) { + logger.finest(() -> "Removing all edges containing " + candidateRemoved + "of " + c); removeEdgesForCandidate(candidateRemoved, competitorEdges, competitorIncomingEdges); } - logger.info("DIAG END removeEdgesForCandidates for " + c + ": " + removeEdgesStartedAt.until(TimePoint.now())); - final TimePoint createNewEdgesStartedAt = TimePoint.now(); logger.info("DIAG START createNewEdges for " + c); - createNewEdges(c, filteredCandidatesAddedAndRemoved.getA()); - logger.info("DIAG END createNewEdges for " + c + ": " + createNewEdgesStartedAt.until(TimePoint.now())); } From e6ac76f0fac011298e19d743ba39241af094f939 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 16:00:47 +0200 Subject: [PATCH 18/28] bug6279: use Supplier for logs and changed from info to fine --- .../impl/CandidateChooserImpl.java | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java b/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java index d8006ff26ca..9c5ec34cc96 100755 --- a/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java +++ b/java/com.sap.sailing.domain/src/com/sap/sailing/domain/markpassingcalculation/impl/CandidateChooserImpl.java @@ -17,6 +17,7 @@ import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import java.util.logging.Logger; +import java.util.stream.Collectors; import com.sap.sailing.domain.base.Competitor; import com.sap.sailing.domain.base.Course; @@ -26,6 +27,7 @@ import com.sap.sailing.domain.common.tracking.GPSFixMoving; import com.sap.sailing.domain.markpassingcalculation.Candidate; import com.sap.sailing.domain.markpassingcalculation.CandidateChooser; import com.sap.sailing.domain.markpassingcalculation.MarkPassingCalculator; +import com.sap.sailing.domain.markpassinghash.MarkPassingRaceFingerprintRegistry; import com.sap.sailing.domain.shared.tracking.impl.TimedComparator; import com.sap.sailing.domain.tracking.DynamicTrackedRace; import com.sap.sailing.domain.tracking.GPSFixTrack; @@ -452,25 +454,25 @@ public class CandidateChooserImpl implements CandidateChooser { } } final TimePoint updateStationarySequencesStartedAt = TimePoint.now(); - logger.info("DIAG START updateStationarySequences for "+c); + logger.fine(()->"DIAG START updateStationarySequences for "+c); updateStationarySequences(c, newFixes, fixesReplacingExistingOnes); - logger.info("DIAG END updateStationarySequences for "+c+": "+ updateStationarySequencesStartedAt.until(TimePoint.now())); + logger.fine(()->"DIAG END updateStationarySequences for "+c+": "+ updateStationarySequencesStartedAt.until(TimePoint.now())); final TimePoint removeCandidatesStartedAt = TimePoint.now(); - logger.info("DIAG START removeCandidates for "+c); + logger.fine(()->"DIAG START removeCandidates for "+c); removeCandidates(c, oldCans); - logger.info("DIAG END removeCandidates for "+c+": "+ + logger.fine(()->"DIAG END removeCandidates for "+c+": "+ removeCandidatesStartedAt.until(TimePoint.now())); final TimePoint addCandidatesStartedAt = TimePoint.now(); - logger.info("DIAG START addCandidates for "+c); + logger.fine(()->"DIAG START addCandidates for "+c); addCandidates(c, newCans); - logger.info("DIAG END addCandidates for "+c+": "+ + logger.fine(()->"DIAG END addCandidates for "+c+": "+ addCandidatesStartedAt.until(TimePoint.now())); final TimePoint findShortestPathStartedAt = TimePoint.now(); - logger.info("DIAG START findShortestPath for "+c); + logger.fine(()->"DIAG START findShortestPath for "+c); findShortestPath(c); - logger.info("DIAG END findShortestPath for "+c+": "+ + logger.fine(()->"DIAG END findShortestPath for "+c+": "+ findShortestPathStartedAt.until(TimePoint.now())); } @@ -1028,16 +1030,16 @@ public class CandidateChooserImpl implements CandidateChooser { */ private void updateFilteredCandidatesAndAdjustGraph(Competitor c, Iterable newCandidates, Iterable removedCandidates) { final TimePoint filteringStartedAt = TimePoint.now(); - logger.info("DIAG START updateFilteredCandidates for "+c); + logger.fine(()->"DIAG START updateFilteredCandidates for "+c); Pair, Iterable> filteredCandidatesAddedAndRemoved = updateFilteredCandidates(c, newCandidates, removedCandidates); - logger.info("DIAG END updateFilteredCandidates for "+c+": "+ + logger.fine(()->"DIAG END updateFilteredCandidates for "+c+": "+ filteringStartedAt.until(TimePoint.now())); final TimePoint adjustGraphStartedAt = TimePoint.now(); - logger.info("DIAG START adjustGraph for "+c); + logger.fine(()->"DIAG START adjustGraph for "+c); adjustGraph(c, filteredCandidatesAddedAndRemoved); - logger.info("DIAG END adjustGraph for "+c+": "+ + logger.fine(()->"DIAG END adjustGraph for "+c+": "+ adjustGraphStartedAt.until(TimePoint.now())); } @@ -1050,28 +1052,23 @@ public class CandidateChooserImpl implements CandidateChooser { Pair, Iterable> filteredCandidatesAddedAndRemoved) { final Map> competitorEdges = allEdges.get(c); final Map> competitorIncomingEdges = incomingEdges.get(c); - final int removedCandidates = Util.size(filteredCandidatesAddedAndRemoved.getB()); - long edgesBeforeRemoval = 0; - for (final Set edgeSet : competitorEdges.values()) { - edgesBeforeRemoval += edgeSet.size(); - } - logger.info("DIAG REMOVE WORKLOAD for " + c - + ": removedCandidates=" + removedCandidates - + ", edgeSets=" + competitorEdges.size() - + ", edgesBeforeRemoval=" + edgesBeforeRemoval); + logger.fine(() -> "DIAG REMOVE WORKLOAD for " + c.getName() + ": removedCandidates=" + + Util.size(filteredCandidatesAddedAndRemoved.getB()) + ", edgeSets=" + competitorEdges.size() + + ", edgesBeforeRemoval=" + + Long.toString(competitorEdges.values().stream().collect(Collectors.summingLong(Set::size)))); final TimePoint removeEdgesStartedAt = TimePoint.now(); - logger.info("DIAG START removeEdgesForCandidates for " + c); + logger.fine(()->"DIAG START removeEdgesForCandidates for " + c); for (final Candidate candidateRemoved : filteredCandidatesAddedAndRemoved.getB()) { logger.finest(() -> "Removing all edges containing " + candidateRemoved + "of " + c); removeEdgesForCandidate(candidateRemoved, competitorEdges, competitorIncomingEdges); } - logger.info("DIAG END removeEdgesForCandidates for " + c + ": " + logger.fine(()->"DIAG END removeEdgesForCandidates for " + c + ": " + removeEdgesStartedAt.until(TimePoint.now())); final TimePoint createNewEdgesStartedAt = TimePoint.now(); - logger.info("DIAG START createNewEdges for " + c); + logger.fine(()->"DIAG START createNewEdges for " + c); createNewEdges(c, filteredCandidatesAddedAndRemoved.getA()); - logger.info("DIAG END createNewEdges for " + c + ": " + logger.fine(()->"DIAG END createNewEdges for " + c + ": " + createNewEdgesStartedAt.until(TimePoint.now())); } From bb946937fa2977bfb3d111355c87b7b0abfc06d8 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 18:17:02 +0200 Subject: [PATCH 19/28] release.yml: restore always() on build-gate so a failed build goes red A regression: replacing 'if: always()' with the bare content guard 'if: ${{ !(needs.build.result == '\''success'\'' && ... skip_tests == '\''true'\'') }}' lost always() semantics. Because 'build' is in needs:, a FAILED build then cascade-skips build-gate (GitHub's default: a dependent job is skipped when a needs: dependency fails unless the if: contains always()/!cancelled()). That left the REQUIRED build-gate check quietly pending on a real build failure instead of going red (observed on run 33162860634, a push to main with a failing build). Restore always() so the gate runs for every build outcome and the narrow guard alone decides the sole non-run case (a *successful* compile-only build, which must stay pending/blocked, not red). A real build failure now runs the gate and it exits 1 -> red. Documented why always() is required in the comment. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3d035255df..b3cedd0c218 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -325,10 +325,17 @@ jobs: # A compile-only build that FAILED (build.result != success) still runs the # gate below and goes red, and the trivial-change skip still runs and goes # green — so docs-only PRs are not left pending forever. + # + # The always() is REQUIRED: without it, a FAILED build (a job listed in needs:) + # would cascade-skip this gate by GitHub's default rule, leaving the required + # check quietly pending instead of red. always() forces the gate to run for + # every build outcome so the guard alone — not the needs: cascade — decides + # whether it runs; the sole non-run case is the narrow successful-compile-only + # one above. (A real build failure then runs the gate and it exits 1 → red.) build-gate: permissions: {} needs: [changes, build] - if: ${{ !(needs.build.result == 'success' && needs.build.outputs.skip_tests == 'true') }} + if: ${{ always() && !(needs.build.result == 'success' && needs.build.outputs.skip_tests == 'true') }} runs-on: ubuntu-latest steps: - run: | From 5fe998c19dbddd926dce0279cbf7013be7b40ca7 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 19:46:14 +0200 Subject: [PATCH 20/28] removed extra empty line --- .../src/com/sap/sse/branding/impl/Activator.java | 1 - 1 file changed, 1 deletion(-) diff --git a/java/com.sap.sse.branding/src/com/sap/sse/branding/impl/Activator.java b/java/com.sap.sse.branding/src/com/sap/sse/branding/impl/Activator.java index d64f3cd5bd5..57f075e15c9 100644 --- a/java/com.sap.sse.branding/src/com/sap/sse/branding/impl/Activator.java +++ b/java/com.sap.sse.branding/src/com/sap/sse/branding/impl/Activator.java @@ -2,7 +2,6 @@ package com.sap.sse.branding.impl; import org.osgi.framework.BundleActivator; import org.osgi.framework.BundleContext; - import com.sap.sse.branding.BrandingConfigurationService; public class Activator implements BundleActivator { From 612690569ecc986775345c16f956c46ec8768cad Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 20:46:48 +0200 Subject: [PATCH 21/28] added configuration/merge-upstream-to-downstream.sh for automated PR generation --- configuration/merge-upstream-to-downstream.sh | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100755 configuration/merge-upstream-to-downstream.sh diff --git a/configuration/merge-upstream-to-downstream.sh b/configuration/merge-upstream-to-downstream.sh new file mode 100755 index 00000000000..f1e99c1af3d --- /dev/null +++ b/configuration/merge-upstream-to-downstream.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# +# merge-upstream-to-downstream.sh +# +# Merge an upstream repo into a downstream repo by way of a PR opened from a +# secondary account's fork, so the account you review with stays free to approve +# the PR (branch protection commonly forbids self-approval / requires last-push +# approval by someone other than the pusher). +# +# The fork remote's URL is expected to embed a PAT (https://@host/owner/repo); +# that PAT is the single source of truth for credentials and is read at runtime, +# never printed. Keep this script free of any local repo/remote/branch details: +# everything is passed as named options so the script itself is safe to commit. +# +# Flow (names below are the resolved values, see options): +# 1. Check out (should track /). +# 2. Fetch / -> ff . +# 3. Fetch / -> ff . +# 4. Merge into . +# 5. Merge into . +# 6. Push -> /. +# 7. Open PR : -> :, +# body = incoming commit titles, maintainer-edit enabled (gh default), +# authored via the fork PAT. +# +# Options (all named; only --fork-remote is required): +# --fork-remote NAME (required) remote for the fork; its URL holds the PAT +# --upstream-remote NAME default: eclipse +# --downstream-remote NAME default: sap +# --upstream-branch NAME default: main (branch on the upstream remote) +# --downstream-branch NAME default: main (branch on the downstream remote) +# --fork-branch NAME default: main (branch on the fork remote) +# --local-upstream-branch NAME default: - +# --local-downstream-branch NAME default: - +# --local-fork-branch NAME default: -- +# -h | --help show this help and exit +# +# Example (author's layout, downstream remote actually named "github"): +# ./merge-upstream-to-downstream.sh --fork-remote aksajhfduwafe --downstream-remote github \ +# --local-downstream-branch sap-main --local-fork-branch aksajhfduwafe-sap-main +# +# (Defaults assume the downstream remote is named "sap"; the author's is "github", +# so the local-branch names are given explicitly to match sap-main / aksajhfduwafe-sap-main. +# Alternatively rename the remote to "sap" and the local-branch defaults line up.) + +set -euo pipefail + +# --- defaults ----------------------------------------------------------------- +FORK_REMOTE="" +UPSTREAM_REMOTE="eclipse" +DOWNSTREAM_REMOTE="sap" +UPSTREAM_BRANCH="main" +DOWNSTREAM_BRANCH="main" +FORK_BRANCH="main" +LOCAL_UPSTREAM_BRANCH="" # resolved after parsing if left empty +LOCAL_DOWNSTREAM_BRANCH="" +LOCAL_FORK_BRANCH="" + +usage() { + cat <@host/owner/repo); it is read at runtime and +never printed. + +Required: + --fork-remote NAME remote for the fork; its URL holds the PAT + +Remotes: + --upstream-remote NAME upstream remote (default: ${UPSTREAM_REMOTE}) + --downstream-remote NAME downstream/base remote (default: ${DOWNSTREAM_REMOTE}) + +Remote branches: + --upstream-branch NAME branch on upstream remote (default: ${UPSTREAM_BRANCH}) + --downstream-branch NAME branch on downstream remote(default: ${DOWNSTREAM_BRANCH}) + --fork-branch NAME branch on fork remote (default: ${FORK_BRANCH}) + +Local branches (defaults derive from the names above): + --local-upstream-branch NAME default: - + --local-downstream-branch NAME default: - + --local-fork-branch NAME default: -- + +Other: + -h, --help show this help and exit + +Example (downstream remote named "github" rather than the default "sap"): + $(basename "$0") --fork-remote aksajhfduwafe --downstream-remote github \\ + --local-downstream-branch sap-main --local-fork-branch aksajhfduwafe-sap-main +EOF +} + +# --- named-option parsing ----------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --fork-remote) FORK_REMOTE="$2"; shift 2 ;; + --upstream-remote) UPSTREAM_REMOTE="$2"; shift 2 ;; + --downstream-remote) DOWNSTREAM_REMOTE="$2"; shift 2 ;; + --upstream-branch) UPSTREAM_BRANCH="$2"; shift 2 ;; + --downstream-branch) DOWNSTREAM_BRANCH="$2"; shift 2 ;; + --fork-branch) FORK_BRANCH="$2"; shift 2 ;; + --local-upstream-branch) LOCAL_UPSTREAM_BRANCH="$2"; shift 2 ;; + --local-downstream-branch) LOCAL_DOWNSTREAM_BRANCH="$2"; shift 2 ;; + --local-fork-branch) LOCAL_FORK_BRANCH="$2"; shift 2 ;; + --*=*) # support --opt=value form + set -- "${1%%=*}" "${1#*=}" "${@:2}" ;; + -h|--help) usage; exit 0 ;; + *) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 2 ;; + esac +done + +# --- resolve derived defaults ------------------------------------------------- +[ -n "$FORK_REMOTE" ] || { printf 'ERROR: --fork-remote is required.\n\n' >&2; usage >&2; exit 2; } +: "${LOCAL_UPSTREAM_BRANCH:=${UPSTREAM_REMOTE}-${UPSTREAM_BRANCH}}" +: "${LOCAL_DOWNSTREAM_BRANCH:=${DOWNSTREAM_REMOTE}-${DOWNSTREAM_BRANCH}}" +: "${LOCAL_FORK_BRANCH:=${FORK_REMOTE}-${DOWNSTREAM_REMOTE}-${DOWNSTREAM_BRANCH}}" + +PR_TITLE="Merged latest ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH} updates to downstream" +# ------------------------------------------------------------------------------ + +log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; } +die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +# Restore the branch the user started on, even on failure; clean temp file. +ORIGINAL_REF="$(git rev-parse --abbrev-ref HEAD)" +PR_BODY_FILE="" +cleanup() { + [ -n "$PR_BODY_FILE" ] && rm -f "$PR_BODY_FILE" + git checkout --quiet "$ORIGINAL_REF" 2>/dev/null || true +} +trap cleanup EXIT + +# --- preflight ---------------------------------------------------------------- +command -v gh >/dev/null 2>&1 || die "gh CLI not found on PATH." +for r in "$FORK_REMOTE" "$UPSTREAM_REMOTE" "$DOWNSTREAM_REMOTE"; do + git remote get-url "$r" >/dev/null 2>&1 || die "remote '$r' not defined." +done +git show-ref --verify --quiet "refs/heads/$LOCAL_FORK_BRANCH" \ + || die "local branch '$LOCAL_FORK_BRANCH' does not exist (create it tracking $FORK_REMOTE/$FORK_BRANCH)." +git show-ref --verify --quiet "refs/heads/$LOCAL_DOWNSTREAM_BRANCH" \ + || die "local branch '$LOCAL_DOWNSTREAM_BRANCH' does not exist (create it tracking $DOWNSTREAM_REMOTE/$DOWNSTREAM_BRANCH)." +git show-ref --verify --quiet "refs/heads/$LOCAL_UPSTREAM_BRANCH" \ + || die "local branch '$LOCAL_UPSTREAM_BRANCH' does not exist (create it tracking $UPSTREAM_REMOTE/$UPSTREAM_BRANCH)." +# Block only on modified/staged TRACKED files — those are what branch switching +# and merging would clobber. Untracked files (build artifacts, .bak, etc.) are +# left alone by checkout/merge, so they must not block the run. +[ -z "$(git status --porcelain --untracked-files=no)" ] || die "working tree has uncommitted tracked changes; commit/stash first." + +# Extract the bare PAT embedded in the fork remote URL (https://@host/...). +# Never echoed; only exported into gh's environment for the PR call. +FORK_URL="$(git remote get-url "$FORK_REMOTE")" +FORK_PAT="$(printf '%s' "$FORK_URL" | sed -nE 's#^https://([^@/]+)@.*#\1#p')" +[ -n "$FORK_PAT" ] || die "could not extract a PAT from the '$FORK_REMOTE' remote URL (expected https://@host/owner/repo)." +FORK_OWNER="$(printf '%s' "$FORK_URL" | sed -nE 's#^https://[^@/]+@[^/]+/([^/]+)/.*#\1#p')" +FORK_HOST="$(printf '%s' "$FORK_URL" | sed -nE 's#^https://[^@/]+@([^/]+)/.*#\1#p')" +[ -n "$FORK_OWNER" ] || die "could not parse the fork owner from the '$FORK_REMOTE' URL." +: "${FORK_HOST:=github.com}" + +# Derive the downstream (base) repo "owner/name" from the downstream remote URL, +# supporting both git@host:owner/repo(.git) and https://host/owner/repo(.git). +# Strip any trailing ".git" explicitly — a non-greedy name group won't drop it. +DOWNSTREAM_URL="$(git remote get-url "$DOWNSTREAM_REMOTE")" +BASE_REPO="$(printf '%s' "$DOWNSTREAM_URL" \ + | sed -nE 's#^(git@[^:]+:|https?://[^/]+/)([^/]+/[^/]+)$#\2#p')" +BASE_REPO="${BASE_REPO%.git}" +[ -n "$BASE_REPO" ] || die "could not derive owner/name from '$DOWNSTREAM_REMOTE' URL: $DOWNSTREAM_URL" + +# --- 1. check out the fork branch --------------------------------------------- +log "Checking out $LOCAL_FORK_BRANCH" +git checkout "$LOCAL_FORK_BRANCH" + +# --- 2. fetch downstream into its local branch -------------------------------- +log "Fetching $DOWNSTREAM_REMOTE/$DOWNSTREAM_BRANCH into $LOCAL_DOWNSTREAM_BRANCH" +git fetch "$DOWNSTREAM_REMOTE" "$DOWNSTREAM_BRANCH" +git checkout "$LOCAL_DOWNSTREAM_BRANCH" +git merge --ff-only "$DOWNSTREAM_REMOTE/$DOWNSTREAM_BRANCH" \ + || die "$LOCAL_DOWNSTREAM_BRANCH cannot fast-forward to $DOWNSTREAM_REMOTE/$DOWNSTREAM_BRANCH (local diverged?)." + +# --- 3. fetch upstream into its local branch ---------------------------------- +log "Fetching $UPSTREAM_REMOTE/$UPSTREAM_BRANCH into $LOCAL_UPSTREAM_BRANCH" +git fetch "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH" +git checkout "$LOCAL_UPSTREAM_BRANCH" +git merge --ff-only "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH" \ + || die "$LOCAL_UPSTREAM_BRANCH cannot fast-forward to $UPSTREAM_REMOTE/$UPSTREAM_BRANCH (local diverged?)." + +# --- upstream-contribution guard ---------------------------------------------- +# The whole point is to carry UPSTREAM changes downstream. If upstream has no +# commit that isn't already on downstream main, there is nothing to contribute: +# stop before merging/pushing/PRing so we never open a ridiculous empty (or +# downstream-into-itself) PR. (A downstream-only advance with no upstream content +# is excluded here too, precisely because it contributes nothing from upstream.) +UPSTREAM_CONTRIB_COUNT="$(git rev-list --count "$DOWNSTREAM_REMOTE/$DOWNSTREAM_BRANCH..$LOCAL_UPSTREAM_BRANCH")" +if [ "$UPSTREAM_CONTRIB_COUNT" -eq 0 ]; then + log "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH has no commits beyond $DOWNSTREAM_REMOTE/$DOWNSTREAM_BRANCH — nothing to contribute. No merge, push, or PR. Done." + exit 0 +fi +log "$UPSTREAM_CONTRIB_COUNT upstream commit(s) not yet on downstream; proceeding." + +# --- 4 & 5. merge downstream then upstream into the fork branch --------------- +git checkout "$LOCAL_FORK_BRANCH" +log "Merging $LOCAL_DOWNSTREAM_BRANCH into $LOCAL_FORK_BRANCH" +git merge --no-edit "$LOCAL_DOWNSTREAM_BRANCH" \ + || die "merge of $LOCAL_DOWNSTREAM_BRANCH hit conflicts; resolve, commit, then re-run (fetches are idempotent)." +log "Merging $LOCAL_UPSTREAM_BRANCH into $LOCAL_FORK_BRANCH" +git merge --no-edit "$LOCAL_UPSTREAM_BRANCH" \ + || die "merge of $LOCAL_UPSTREAM_BRANCH hit conflicts; resolve, commit, then re-run." + +# --- compute the commits this PR would bring into the downstream base --------- +# Broader than the upstream-contribution guard above: this is everything the PR +# delivers to downstream (upstream commits + any downstream re-sync), used for +# the PR body. It stays a defensive belt-and-braces check — if it somehow ends +# up empty (e.g. everything was already merged concurrently), don't push/PR. +INCOMING_RANGE="$DOWNSTREAM_REMOTE/$DOWNSTREAM_BRANCH..$LOCAL_FORK_BRANCH" +INCOMING_COUNT="$(git rev-list --count "$INCOMING_RANGE")" +if [ "$INCOMING_COUNT" -eq 0 ]; then + log "No new commits over $DOWNSTREAM_REMOTE/$DOWNSTREAM_BRANCH — nothing to push or PR. Done." + exit 0 +fi +log "$INCOMING_COUNT commit(s) will be proposed to $BASE_REPO." + +# --- already-open-PR short-circuit (idempotency) ------------------------------ +# If an open PR already exists from this fork head into the downstream base AND +# its head commit already equals what we're about to push, there is nothing to +# do: don't push, don't open a duplicate PR — just report the existing one. If a +# PR exists but its head is OLDER (new upstream commits arrived since), we fall +# through and push, which updates that same PR; step 7 then reports it. +HEAD_SPEC="${FORK_OWNER}:${FORK_BRANCH}" +LOCAL_FORK_TIP="$(git rev-parse "$LOCAL_FORK_BRANCH")" +# Match on head branch AND head-fork owner (—head filters by branch name only, +# which could collide with a same-named branch from a different fork). Emit +# " " for the first match, or nothing. +EXISTING_PR="$(GH_TOKEN="$FORK_PAT" GH_HOST="$FORK_HOST" \ + gh pr list --repo "$BASE_REPO" --head "$FORK_BRANCH" --base "$DOWNSTREAM_BRANCH" --state open \ + --json url,headRefOid,headRepositoryOwner \ + --jq "[.[] | select(.headRepositoryOwner.login == \"$FORK_OWNER\")][0] | select(.) | \"\(.headRefOid) \(.url)\"" 2>/dev/null || true)" +if [ -n "$EXISTING_PR" ]; then + EXISTING_PR_SHA="${EXISTING_PR%% *}" + EXISTING_PR_URL="${EXISTING_PR#* }" + if [ "$EXISTING_PR_SHA" = "$LOCAL_FORK_TIP" ]; then + log "An open PR already targets $BASE_REPO:$DOWNSTREAM_BRANCH from $HEAD_SPEC at this exact commit — nothing to do: $EXISTING_PR_URL" + exit 0 + fi + log "An open PR exists ($EXISTING_PR_URL) but at an older head; pushing will update it." +fi + +# --- 6. push the fork branch -------------------------------------------------- +log "Pushing $LOCAL_FORK_BRANCH -> $FORK_REMOTE/$FORK_BRANCH" +git push "$FORK_REMOTE" "$LOCAL_FORK_BRANCH:$FORK_BRANCH" + +# --- 7. create (or report) the PR --------------------------------------------- +PR_BODY_FILE="$(mktemp)" +{ + echo "Automated merge of upstream \`$UPSTREAM_REMOTE/$UPSTREAM_BRANCH\` (and re-sync of downstream) into \`$BASE_REPO:$DOWNSTREAM_BRANCH\`." + echo + echo "### Incoming commits ($INCOMING_COUNT)" + echo + git log --no-merges --pretty=format:'- %s (%h)' "$INCOMING_RANGE" + echo +} > "$PR_BODY_FILE" + +HEAD_SPEC="${FORK_OWNER}:${FORK_BRANCH}" +log "Opening PR $HEAD_SPEC -> $BASE_REPO:$DOWNSTREAM_BRANCH (authored by the fork account)" +# GH_TOKEN makes gh act as the fork account so the maintainer-edit grant sticks +# (gh enables it by default; we deliberately omit --no-maintainer-edit) and the +# review account stays free to approve. GH_HOST pins gh to the fork's host. +if ! GH_TOKEN="$FORK_PAT" GH_HOST="$FORK_HOST" \ + gh pr create \ + --repo "$BASE_REPO" \ + --base "$DOWNSTREAM_BRANCH" \ + --head "$HEAD_SPEC" \ + --title "$PR_TITLE" \ + --body-file "$PR_BODY_FILE" ; then + EXISTING="$(GH_TOKEN="$FORK_PAT" GH_HOST="$FORK_HOST" \ + gh pr list --repo "$BASE_REPO" --head "$HEAD_SPEC" --state open \ + --json url --jq '.[0].url' 2>/dev/null || true)" + [ -n "$EXISTING" ] && log "A PR already exists for $HEAD_SPEC (updated by the push): $EXISTING" \ + || die "gh pr create failed and no existing open PR was found." +fi + +log "Done." From 15486910e203740aaf69021a2d24c38b172eb798 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 20:54:52 +0200 Subject: [PATCH 22/28] merge-upstream-to-downstream.sh: env-var defaults and step-7 PR-lookup fix Add MERGE_U2D_* environment variables that seed the default for every command-line option, with precedence CLI option > env var > built-in default. This lets the script run with no arguments when e.g. MERGE_U2D_FORK_REMOTE is exported from .bashrc; -h shows the live resolved defaults. Also fix the step-7 fallback so that when 'gh pr create' refuses because an open PR already exists for the fork head (the older-head path, where the push already updated it in place), the existing PR is found and its URL reported instead of erroring. 'gh pr list --head' filters by branch name only, so match on the bare fork branch plus a headRepositoryOwner filter, mirroring the early idempotency check. Assisted-By: Claude Opus 4.8 --- configuration/merge-upstream-to-downstream.sh | 70 +++++++++++++------ 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/configuration/merge-upstream-to-downstream.sh b/configuration/merge-upstream-to-downstream.sh index f1e99c1af3d..a310ad3845c 100755 --- a/configuration/merge-upstream-to-downstream.sh +++ b/configuration/merge-upstream-to-downstream.sh @@ -23,18 +23,26 @@ # body = incoming commit titles, maintainer-edit enabled (gh default), # authored via the fork PAT. # -# Options (all named; only --fork-remote is required): +# Options (all named; only --fork-remote is required unless MERGE_U2D_FORK_REMOTE +# is set). Precedence: command-line option > MERGE_U2D_* env var > built-in default. # --fork-remote NAME (required) remote for the fork; its URL holds the PAT -# --upstream-remote NAME default: eclipse -# --downstream-remote NAME default: sap -# --upstream-branch NAME default: main (branch on the upstream remote) -# --downstream-branch NAME default: main (branch on the downstream remote) -# --fork-branch NAME default: main (branch on the fork remote) +# env: MERGE_U2D_FORK_REMOTE +# --upstream-remote NAME default: eclipse env: MERGE_U2D_UPSTREAM_REMOTE +# --downstream-remote NAME default: sap env: MERGE_U2D_DOWNSTREAM_REMOTE +# --upstream-branch NAME default: main env: MERGE_U2D_UPSTREAM_BRANCH +# --downstream-branch NAME default: main env: MERGE_U2D_DOWNSTREAM_BRANCH +# --fork-branch NAME default: main env: MERGE_U2D_FORK_BRANCH # --local-upstream-branch NAME default: - +# env: MERGE_U2D_LOCAL_UPSTREAM_BRANCH # --local-downstream-branch NAME default: - +# env: MERGE_U2D_LOCAL_DOWNSTREAM_BRANCH # --local-fork-branch NAME default: -- +# env: MERGE_U2D_LOCAL_FORK_BRANCH # -h | --help show this help and exit # +# So, e.g., `export MERGE_U2D_FORK_REMOTE=aksajhfduwafe` in your .bashrc lets you +# run the script with no arguments at all. +# # Example (author's layout, downstream remote actually named "github"): # ./merge-upstream-to-downstream.sh --fork-remote aksajhfduwafe --downstream-remote github \ # --local-downstream-branch sap-main --local-fork-branch aksajhfduwafe-sap-main @@ -46,15 +54,21 @@ set -euo pipefail # --- defaults ----------------------------------------------------------------- -FORK_REMOTE="" -UPSTREAM_REMOTE="eclipse" -DOWNSTREAM_REMOTE="sap" -UPSTREAM_BRANCH="main" -DOWNSTREAM_BRANCH="main" -FORK_BRANCH="main" -LOCAL_UPSTREAM_BRANCH="" # resolved after parsing if left empty -LOCAL_DOWNSTREAM_BRANCH="" -LOCAL_FORK_BRANCH="" +# Precedence for every setting: command-line option > environment variable > +# built-in default. Each var is seeded here from its MERGE_U2D_* environment +# variable (empty if unset) or the built-in default; the option parser below +# then overwrites a var ONLY when its flag is actually given. So you can export +# e.g. MERGE_U2D_FORK_REMOTE=aksajhfduwafe in your .bashrc and run with no args, +# while still overriding any single value on the command line. +FORK_REMOTE="${MERGE_U2D_FORK_REMOTE:-}" +UPSTREAM_REMOTE="${MERGE_U2D_UPSTREAM_REMOTE:-eclipse}" +DOWNSTREAM_REMOTE="${MERGE_U2D_DOWNSTREAM_REMOTE:-sap}" +UPSTREAM_BRANCH="${MERGE_U2D_UPSTREAM_BRANCH:-main}" +DOWNSTREAM_BRANCH="${MERGE_U2D_DOWNSTREAM_BRANCH:-main}" +FORK_BRANCH="${MERGE_U2D_FORK_BRANCH:-main}" +LOCAL_UPSTREAM_BRANCH="${MERGE_U2D_LOCAL_UPSTREAM_BRANCH:-}" # resolved after parsing if left empty +LOCAL_DOWNSTREAM_BRANCH="${MERGE_U2D_LOCAL_DOWNSTREAM_BRANCH:-}" +LOCAL_FORK_BRANCH="${MERGE_U2D_LOCAL_FORK_BRANCH:-}" usage() { cat <@host/owner/repo); it is read at runtime and never printed. -Required: +Precedence for every setting: command-line option > MERGE_U2D_* env var > default. +Defaults shown below already reflect any MERGE_U2D_* env var currently exported, +so e.g. \`export MERGE_U2D_FORK_REMOTE=aksajhfduwafe\` lets you run with no args. + +Required (unless MERGE_U2D_FORK_REMOTE is set): --fork-remote NAME remote for the fork; its URL holds the PAT + [env MERGE_U2D_FORK_REMOTE] (current: ${FORK_REMOTE:-}) Remotes: --upstream-remote NAME upstream remote (default: ${UPSTREAM_REMOTE}) + [env MERGE_U2D_UPSTREAM_REMOTE] --downstream-remote NAME downstream/base remote (default: ${DOWNSTREAM_REMOTE}) + [env MERGE_U2D_DOWNSTREAM_REMOTE] Remote branches: --upstream-branch NAME branch on upstream remote (default: ${UPSTREAM_BRANCH}) + [env MERGE_U2D_UPSTREAM_BRANCH] --downstream-branch NAME branch on downstream remote(default: ${DOWNSTREAM_BRANCH}) + [env MERGE_U2D_DOWNSTREAM_BRANCH] --fork-branch NAME branch on fork remote (default: ${FORK_BRANCH}) + [env MERGE_U2D_FORK_BRANCH] Local branches (defaults derive from the names above): --local-upstream-branch NAME default: - + [env MERGE_U2D_LOCAL_UPSTREAM_BRANCH] --local-downstream-branch NAME default: - + [env MERGE_U2D_LOCAL_DOWNSTREAM_BRANCH] --local-fork-branch NAME default: -- + [env MERGE_U2D_LOCAL_FORK_BRANCH] Other: -h, --help show this help and exit @@ -111,7 +138,7 @@ while [ $# -gt 0 ]; do done # --- resolve derived defaults ------------------------------------------------- -[ -n "$FORK_REMOTE" ] || { printf 'ERROR: --fork-remote is required.\n\n' >&2; usage >&2; exit 2; } +[ -n "$FORK_REMOTE" ] || { printf 'ERROR: --fork-remote is required (or set MERGE_U2D_FORK_REMOTE).\n\n' >&2; usage >&2; exit 2; } : "${LOCAL_UPSTREAM_BRANCH:=${UPSTREAM_REMOTE}-${UPSTREAM_BRANCH}}" : "${LOCAL_DOWNSTREAM_BRANCH:=${DOWNSTREAM_REMOTE}-${DOWNSTREAM_BRANCH}}" : "${LOCAL_FORK_BRANCH:=${FORK_REMOTE}-${DOWNSTREAM_REMOTE}-${DOWNSTREAM_BRANCH}}" @@ -259,11 +286,13 @@ PR_BODY_FILE="$(mktemp)" echo } > "$PR_BODY_FILE" -HEAD_SPEC="${FORK_OWNER}:${FORK_BRANCH}" log "Opening PR $HEAD_SPEC -> $BASE_REPO:$DOWNSTREAM_BRANCH (authored by the fork account)" # GH_TOKEN makes gh act as the fork account so the maintainer-edit grant sticks # (gh enables it by default; we deliberately omit --no-maintainer-edit) and the # review account stays free to approve. GH_HOST pins gh to the fork's host. +# NOTE: `gh pr create --head` takes owner:branch (cross-fork form), but +# `gh pr list --head` (the fallback below) takes the BRANCH NAME ONLY — hence we +# filter the list by head-fork owner separately, matching the early check above. if ! GH_TOKEN="$FORK_PAT" GH_HOST="$FORK_HOST" \ gh pr create \ --repo "$BASE_REPO" \ @@ -272,8 +301,9 @@ if ! GH_TOKEN="$FORK_PAT" GH_HOST="$FORK_HOST" \ --title "$PR_TITLE" \ --body-file "$PR_BODY_FILE" ; then EXISTING="$(GH_TOKEN="$FORK_PAT" GH_HOST="$FORK_HOST" \ - gh pr list --repo "$BASE_REPO" --head "$HEAD_SPEC" --state open \ - --json url --jq '.[0].url' 2>/dev/null || true)" + gh pr list --repo "$BASE_REPO" --head "$FORK_BRANCH" --base "$DOWNSTREAM_BRANCH" --state open \ + --json url,headRepositoryOwner \ + --jq "[.[] | select(.headRepositoryOwner.login == \"$FORK_OWNER\")][0] | select(.) | .url" 2>/dev/null || true)" [ -n "$EXISTING" ] && log "A PR already exists for $HEAD_SPEC (updated by the push): $EXISTING" \ || die "gh pr create failed and no existing open PR was found." fi From 732be09a2ddf102c05189a93c981d3f733cc4e41 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 21:00:24 +0200 Subject: [PATCH 23/28] Add bash completion for merge-upstream-to-downstream.sh Tab-completes the script's named options and, intelligently, their values against the git repo containing the CWD: - remote-name options complete against 'git remote'; - remote-branch options (--upstream/downstream/fork-branch) complete against the branches OF THE RESOLVED REMOTE, resolving each role to a remote with the same precedence the script uses (explicit --*-remote on the line > MERGE_U2D_* env var > built-in default); - --local-*-branch options complete against local branches. Install by sourcing from ~/.bashrc or symlinking into a bash-completion completions dir under the command's basename. Assisted-By: Claude Opus 4.8 --- ...rge-upstream-to-downstream.bash-completion | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 configuration/merge-upstream-to-downstream.bash-completion diff --git a/configuration/merge-upstream-to-downstream.bash-completion b/configuration/merge-upstream-to-downstream.bash-completion new file mode 100644 index 00000000000..c16c2e65047 --- /dev/null +++ b/configuration/merge-upstream-to-downstream.bash-completion @@ -0,0 +1,92 @@ +# bash completion for merge-upstream-to-downstream.sh +# +# Provides Tab-completion for the script's named options and, intelligently, +# for their VALUES: +# * remote-name options (--fork-remote/--upstream-remote/--downstream-remote) +# complete against the git remotes of the repo containing the CWD; +# * remote-branch options (--upstream-branch/--downstream-branch/--fork-branch) +# complete against the branches OF THE MATCHING REMOTE — the script resolves +# each of those to a remote (via its paired --*-remote option already on the +# line, the MERGE_U2D_* env var, or the built-in default), so e.g. +# `--upstream-remote eclipse --upstream-branch ` lists eclipse's branches; +# * local-branch options (--local-*-branch) complete against local branches. +# +# Install (any one of): +# * source it from ~/.bashrc: source /path/to/merge-upstream-to-downstream.bash-completion +# * or drop/symlink into the user dir: ~/.local/share/bash-completion/completions/merge-upstream-to-downstream.sh +# * or system-wide: /usr/share/bash-completion/completions/merge-upstream-to-downstream.sh +# (The last two use on-demand loading; the file's basename must match the command +# being completed — hence the symlink name ending in .sh below.) + +# Enumerate git remotes for the repo containing $PWD (empty & silent if not a repo). +__m2d_remotes() { + git remote 2>/dev/null +} + +# Enumerate branch short-names present on a given remote (arg 1), via the +# remote-tracking refs. strip=3 turns refs/remotes// into . +__m2d_remote_branches() { + local remote="$1" + [ -n "$remote" ] || return 0 + git for-each-ref --format='%(refname:strip=3)' "refs/remotes/${remote}/" 2>/dev/null +} + +# Enumerate local branch short-names. strip=2 turns refs/heads/ into . +__m2d_local_branches() { + git for-each-ref --format='%(refname:strip=2)' refs/heads/ 2>/dev/null +} + +# Resolve which remote a given "role" (fork|upstream|downstream) points at, using +# the SAME precedence the script itself uses: an explicit ---remote already +# typed on the command line > the MERGE_U2D__REMOTE env var > built-in default. +# $1 = role; the current COMP_WORDS are scanned for the explicit flag. +__m2d_resolve_remote() { + local role="$1" flag="--${1}-remote" i + # 1) explicit flag already on the line (supports both "--flag val" and "--flag=val"). + for (( i = 1; i < COMP_CWORD; i++ )); do + case "${COMP_WORDS[i]}" in + "$flag") [ $((i + 1)) -lt $COMP_CWORD ] && { printf '%s' "${COMP_WORDS[i+1]}"; return 0; } ;; + "$flag"=*) printf '%s' "${COMP_WORDS[i]#*=}"; return 0 ;; + esac + done + # 2) env var, then 3) built-in default. + case "$role" in + fork) printf '%s' "${MERGE_U2D_FORK_REMOTE:-}" ;; + upstream) printf '%s' "${MERGE_U2D_UPSTREAM_REMOTE:-eclipse}" ;; + downstream) printf '%s' "${MERGE_U2D_DOWNSTREAM_REMOTE:-sap}" ;; + esac +} + +_merge_upstream_to_downstream() { + local cur prev opts + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + opts="--fork-remote --upstream-remote --downstream-remote \ + --upstream-branch --downstream-branch --fork-branch \ + --local-upstream-branch --local-downstream-branch --local-fork-branch \ + -h --help" + # Complete the VALUE when the previous word is an option that takes one. + case "$prev" in + --fork-remote|--upstream-remote|--downstream-remote) + COMPREPLY=( $(compgen -W "$(__m2d_remotes)" -- "$cur") ); return 0 ;; + --upstream-branch) + COMPREPLY=( $(compgen -W "$(__m2d_remote_branches "$(__m2d_resolve_remote upstream)")" -- "$cur") ); return 0 ;; + --downstream-branch) + COMPREPLY=( $(compgen -W "$(__m2d_remote_branches "$(__m2d_resolve_remote downstream)")" -- "$cur") ); return 0 ;; + --fork-branch) + COMPREPLY=( $(compgen -W "$(__m2d_remote_branches "$(__m2d_resolve_remote fork)")" -- "$cur") ); return 0 ;; + --local-upstream-branch|--local-downstream-branch|--local-fork-branch) + COMPREPLY=( $(compgen -W "$(__m2d_local_branches)" -- "$cur") ); return 0 ;; + esac + # Otherwise complete an option NAME (only when the user is typing one, so a + # bare Tab in value position doesn't spam every flag). + if [[ "$cur" == -* ]]; then + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + fi + return 0 +} + +# Register for the plain name, the .sh form, and any path invocation (./x, /abs/x). +complete -F _merge_upstream_to_downstream \ + merge-upstream-to-downstream.sh \ + ./merge-upstream-to-downstream.sh From b5218fa611c46fb396c2ab11cc2bb74e85e48e3a Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 21:03:45 +0200 Subject: [PATCH 24/28] bash completion: list all options on a bare Tab after the command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the option-name branch was guarded by `$cur == -*`, so a Tab in option position with an empty current word produced nothing. Drop the guard: compgen -W filters by $cur anyway, so an empty cur now lists every option while a partial still narrows. Value completion is unaffected — those cases return earlier in the function. Assisted-By: Claude Opus 4.8 --- .../merge-upstream-to-downstream.bash-completion | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/configuration/merge-upstream-to-downstream.bash-completion b/configuration/merge-upstream-to-downstream.bash-completion index c16c2e65047..d980b7fc676 100644 --- a/configuration/merge-upstream-to-downstream.bash-completion +++ b/configuration/merge-upstream-to-downstream.bash-completion @@ -78,11 +78,11 @@ _merge_upstream_to_downstream() { --local-upstream-branch|--local-downstream-branch|--local-fork-branch) COMPREPLY=( $(compgen -W "$(__m2d_local_branches)" -- "$cur") ); return 0 ;; esac - # Otherwise complete an option NAME (only when the user is typing one, so a - # bare Tab in value position doesn't spam every flag). - if [[ "$cur" == -* ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - fi + # Otherwise complete an option NAME. compgen -W filters by "$cur", so an + # empty cur (bare Tab right after the command) lists every option, while a + # partial like "--fork" narrows it — and a stray non-option word yields the + # options too rather than nothing. + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) return 0 } From 434093cedce2962d269015e90e3860fe4351afe4 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 21:33:09 +0200 Subject: [PATCH 25/28] removed extra empty line from README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index a1e09db5b95..5d41746e149 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,6 @@ The project welcomes contributions in the form of pull requests, for example, en The issue tracker at [https://bugzilla.sapsailing.com](https://bugzilla.sapsailing.com) is currently used for any sort of issue and enhancement request tracking. Help to migrate this smoothly to Github Issues would be much appreciated, ideally keeping issue numbers stable due to many references to those Bugzilla bug numbers, be it in the source code, the Wiki, or the build infrastructure. - ## Code of Conduct We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](https://github.com/SAP/.github/blob/main/CODE_OF_CONDUCT.md) at all times. From 9ced74e0d5a9ba01fe67255120daf39b75b6c404 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Fri, 28 Aug 2026 23:39:30 +0200 Subject: [PATCH 26/28] bug53: fix build-gate letting compile-only fork PRs merge A required status check whose job is skipped by its own if: still materializes a check run with conclusion "skipped", which GitHub branch protection / rulesets treat as SATISFIED (mergeable). The prior design assumed an if:-false job would leave the required check "pending" and block merge without a red X; that pending state does not exist for a job that is part of a triggered workflow. As a result, a successful compile-only fork pull_request build reported build-gate as "skipped" = passed, and PRs #53/#54 became mergeable WITHOUT the secret-bearing test build ever running (#53 was merged this way). Fix: build-gate now runs unconditionally (if: always()) and, for a successful compile-only run (build success AND skip_tests==true), exits 1 instead of skipping, so the required check goes red and the PR stays un-mergeable. Only a genuine full build with tests, or a legitimate trivial-change skip, satisfies the gate. Merge qualification for fork code still comes solely from a maintainer pushing a *-reviewed-for-build tag. compile-gate (advisory) is unchanged and still shows the compile succeeded. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 58 ++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3cedd0c218..5c95d9b1830 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -305,37 +305,40 @@ jobs: echo "Identified CI job: ${JOB}" curl -u "${{ vars.CI_JOB_USERNAME }}:${{ secrets.CI_JOB_PASSWORD}}" ${{ vars.CI_BASE_URL }}/job/${JOB}/build?token=${{ secrets.CI_JOB_TOKEN }} - # build-gate is the REQUIRED status check in branch protection. It must go - # green ONLY for a genuine full build (tests actually ran) or a legitimate - # trivial-change skip. + # build-gate is the REQUIRED status check in branch protection. It must be + # SATISFIED (pass) ONLY for a genuine full build (tests actually ran) or a + # legitimate trivial-change skip; in every other case it must FAIL (red). # - # A SUCCESSFUL compile-only run (build succeeded AND skip_tests true — e.g. a - # fork pull_request, a *-reviewed-for-compile tag, or a workflow_dispatch with - # skip_tests=true) must NOT qualify a PR for merge, but should also NOT show as - # a failed/red run. So for that one case this job is SKIPPED via its if:. A - # required check whose job never starts is left "Expected — waiting" (pending): - # the PR stays un-mergeable WITHOUT a red X, and the workflow run is not marked - # failed. Merge qualification for fork code then comes only from a maintainer - # pushing a *-reviewed-for-build tag (full trusted build with tests). + # CRITICAL — why this job runs unconditionally (if: always()) and never skips + # itself: GitHub branch protection / rulesets treat a required check whose + # conclusion is "skipped" as SATISFIED (the PR becomes mergeable). This is true + # BOTH for a job that runs and reports "skipped" AND for a job skipped by its + # own if: — a skipped job still MATERIALIZES a check run with conclusion + # "skipped". There is NO "job never starts -> pending" state for a job that is + # part of a triggered workflow: an if:-false job reports "skipped", which + # counts as PASSED. (This exact mistake let fork PRs #53/#54 become mergeable + # on a compile-only build: the old if: skipped build-gate on a successful + # compile-only run, GitHub read that as passed, and the PR merged WITHOUT the + # secret-bearing test build ever running.) # - # IMPORTANT: this must be a job-level skip (job never starts), NOT run-then- - # report-skipped — a job that runs and reports conclusion "skipped" counts as - # PASSED for branch protection and would let the PR merge. Also note the guard - # is deliberately narrow: it fires ONLY for a *successful* compile-only build. - # A compile-only build that FAILED (build.result != success) still runs the - # gate below and goes red, and the trivial-change skip still runs and goes - # green — so docs-only PRs are not left pending forever. + # Therefore the ONLY conclusion that keeps a PR un-mergeable is FAILURE. A + # SUCCESSFUL compile-only run (build succeeded AND skip_tests true — a fork + # pull_request, a *-reviewed-for-compile tag, or workflow_dispatch with + # skip_tests=true) must NOT qualify a PR for merge, so this gate RUNS for that + # case and EXITS 1 (red). It is accurate: compile-only validation has not been + # met. Merge qualification for fork code comes only from a maintainer pushing a + # *-reviewed-for-build tag (full trusted build with tests), which turns this + # gate green. compile-gate (advisory) still goes green to show the compile + # itself succeeded. # - # The always() is REQUIRED: without it, a FAILED build (a job listed in needs:) - # would cascade-skip this gate by GitHub's default rule, leaving the required - # check quietly pending instead of red. always() forces the gate to run for - # every build outcome so the guard alone — not the needs: cascade — decides - # whether it runs; the sole non-run case is the narrow successful-compile-only - # one above. (A real build failure then runs the gate and it exits 1 → red.) + # always() also ensures a FAILED build (a job in needs:) does not cascade-skip + # this gate by GitHub's default rule; the gate runs and the else-branch exits 1 + # -> red. The trivial-change skip (build skipped, no relevant changes) is the + # one legitimate green-without-tests path. build-gate: permissions: {} needs: [changes, build] - if: ${{ always() && !(needs.build.result == 'success' && needs.build.outputs.skip_tests == 'true') }} + if: always() runs-on: ubuntu-latest steps: - run: | @@ -343,6 +346,11 @@ jobs: echo "Full build with tests passed." elif [[ "${{ needs.build.result }}" == "skipped" && "${{ needs.changes.outputs.should_run }}" == "false" ]]; then echo "No relevant changes — skipping is OK." + elif [[ "${{ needs.build.result }}" == "success" && "${{ needs.build.outputs.skip_tests }}" == "true" ]]; then + echo "Compile-only build succeeded but tests did NOT run — this does not" + echo "satisfy the required check. Push a *-reviewed-for-build tag at the" + echo "reviewed SHA to run the full build with tests." + exit 1 else echo "Build failed or was cancelled." exit 1 From 137ffc0df62b5807979e93a14901f318edd4ffe6 Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Sat, 29 Aug 2026 00:20:25 +0200 Subject: [PATCH 27/28] bug53: make build-gate a posted commit status, not a skippable job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous build-gate was a JOB, and GitHub Actions auto-creates a check-run for every job whose conclusion is filled from the job's fate — including "skipped" when the job's if: is false. Branch protection / rulesets treat a skipped required check as SATISFIED (mergeable), so a compile-only fork PR reported build-gate=skipped=passed and became mergeable without tests (PRs #53/#54; #53 merged this way). No job can represent "absent": a skipped job still materializes a skipped check-run. The earlier attempt to make it exit 1 (red) worked but was ugly and, on a failed build, still mis-reported skipped in practice. A commit status (legacy Statuses API) is created only if we explicitly POST it, and a skipped STEP posts nothing (steps are not check-runs). The ruleset matches the required context "build-gate" by NAME across both the Checks and Statuses APIs, so a posted commit status with context=build-gate satisfies it exactly as the old check-run did — the ruleset needs no change. Change: remove the build-gate job; add job post-build-gate-status (named so its own auto check-run carries a context the ruleset ignores) whose single step POSTs a commit status context=build-gate: - state=success when the change was trivial (build skipped, no relevant changes) OR a full build ran WITH tests and succeeded; - state=failure when a full build ran WITH tests and failed/cancelled (loud red X for a committer's own broken build); - NOTHING for any compile-only run (fork PR, *-reviewed-for-compile, workflow_dispatch skip_tests), succeeded or failed — so the build-gate context stays absent and the PR is blocked at "Expected" with no red X. The status is posted against the PR HEAD sha on pull_request events (github.sha there is the ephemeral refs/pull/N/merge commit the ruleset does not evaluate) and github.sha otherwise. Merge qualification for fork code still comes only from a maintainer pushing a *-reviewed-for-build tag (full trusted build with tests), which posts the success status. compile-gate (advisory) is unchanged. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 110 +++++++++++++++++++++------------- 1 file changed, 69 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c95d9b1830..3f4f3f47d1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -305,55 +305,83 @@ jobs: echo "Identified CI job: ${JOB}" curl -u "${{ vars.CI_JOB_USERNAME }}:${{ secrets.CI_JOB_PASSWORD}}" ${{ vars.CI_BASE_URL }}/job/${JOB}/build?token=${{ secrets.CI_JOB_TOKEN }} - # build-gate is the REQUIRED status check in branch protection. It must be - # SATISFIED (pass) ONLY for a genuine full build (tests actually ran) or a - # legitimate trivial-change skip; in every other case it must FAIL (red). + # build-gate is the REQUIRED status check in the branch ruleset (context + # "build-gate"). It must be SATISFIED only for a genuine full build (tests + # actually ran and passed) or a legitimate trivial-change skip; every other + # case — most importantly a compile-only fork PR — must leave the PR + # un-mergeable WITHOUT a red X. # - # CRITICAL — why this job runs unconditionally (if: always()) and never skips - # itself: GitHub branch protection / rulesets treat a required check whose - # conclusion is "skipped" as SATISFIED (the PR becomes mergeable). This is true - # BOTH for a job that runs and reports "skipped" AND for a job skipped by its - # own if: — a skipped job still MATERIALIZES a check run with conclusion - # "skipped". There is NO "job never starts -> pending" state for a job that is - # part of a triggered workflow: an if:-false job reports "skipped", which - # counts as PASSED. (This exact mistake let fork PRs #53/#54 become mergeable - # on a compile-only build: the old if: skipped build-gate on a successful - # compile-only run, GitHub read that as passed, and the PR merged WITHOUT the - # secret-bearing test build ever running.) + # WHY THIS IS A POSTED COMMIT STATUS, NOT A JOB NAMED build-gate: + # GitHub Actions auto-creates a check-run for every JOB, and fills its + # conclusion from the job's fate — including "skipped" when the job's if: is + # false. A rule set / branch protection treats a "skipped" required check as + # SATISFIED (mergeable). So a job named build-gate can NEVER represent "absent" + # — a skipped job still materializes a skipped=passed check-run. That is the + # exact bug that let compile-only fork PRs #53/#54 merge without tests. # - # Therefore the ONLY conclusion that keeps a PR un-mergeable is FAILURE. A - # SUCCESSFUL compile-only run (build succeeded AND skip_tests true — a fork - # pull_request, a *-reviewed-for-compile tag, or workflow_dispatch with - # skip_tests=true) must NOT qualify a PR for merge, so this gate RUNS for that - # case and EXITS 1 (red). It is accurate: compile-only validation has not been - # met. Merge qualification for fork code comes only from a maintainer pushing a - # *-reviewed-for-build tag (full trusted build with tests), which turns this - # gate green. compile-gate (advisory) still goes green to show the compile - # itself succeeded. + # A COMMIT STATUS (legacy Statuses API) is different: it exists only if we + # explicitly POST it. A skipped STEP posts nothing (steps are not check-runs). + # The ruleset matches the required context "build-gate" by NAME across both + # APIs, so a posted commit status with context=build-gate satisfies it exactly + # as the old check-run did — no ruleset change needed. # - # always() also ensures a FAILED build (a job in needs:) does not cascade-skip - # this gate by GitHub's default rule; the gate runs and the else-branch exits 1 - # -> red. The trivial-change skip (build skipped, no relevant changes) is the - # one legitimate green-without-tests path. - build-gate: - permissions: {} + # Hence: this job is named post-build-gate-status (NOT build-gate) so its own + # auto check-run carries a context the ruleset ignores. Its single step POSTs a + # commit status context=build-gate ONLY when: the change was trivial (build + # skipped, no relevant changes) OR a full build ran WITH tests and succeeded + # -> state=success; OR a full build ran WITH tests and FAILED (build.result not + # success/skipped, skip_tests==false) -> state=failure (red X, so a committer's + # own broken build is loud). In every OTHER case — a compile-only run, whether + # it succeeded OR failed (fork PR, *-reviewed-for-compile, workflow_dispatch + # skip_tests) — it posts NOTHING, so no build-gate context exists for the head + # SHA and the PR stays blocked at "Expected" with no red X. (A compile-only run + # ran no tests, so there is nothing meaningful to fail on; it simply hasn't met + # the gate.) Merge qualification for fork code comes only from a maintainer + # pushing a *-reviewed-for-build tag (full trusted build with tests), which + # posts success/failure. compile-gate (advisory) still shows compile pass/fail. + # + # The status is posted against the PR HEAD sha on pull_request events + # (github.sha there is the ephemeral refs/pull/N/merge commit, which the + # ruleset does not evaluate) and github.sha otherwise. needs:[changes, build] + # + if: always() so the trivial-change path (build skipped) is still evaluated. + post-build-gate-status: + permissions: + statuses: write needs: [changes, build] if: always() runs-on: ubuntu-latest steps: - - run: | - if [[ "${{ needs.build.result }}" == "success" && "${{ needs.build.outputs.skip_tests }}" != "true" ]]; then - echo "Full build with tests passed." - elif [[ "${{ needs.build.result }}" == "skipped" && "${{ needs.changes.outputs.should_run }}" == "false" ]]; then - echo "No relevant changes — skipping is OK." - elif [[ "${{ needs.build.result }}" == "success" && "${{ needs.build.outputs.skip_tests }}" == "true" ]]; then - echo "Compile-only build succeeded but tests did NOT run — this does not" - echo "satisfy the required check. Push a *-reviewed-for-build tag at the" - echo "reviewed SHA to run the full build with tests." - exit 1 + - name: Post build-gate commit status + env: + GH_TOKEN: ${{ github.token }} + BUILD_RESULT: ${{ needs.build.result }} + SKIP_TESTS: ${{ needs.build.outputs.skip_tests }} + SHOULD_RUN: ${{ needs.changes.outputs.should_run }} + GATE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + STATE="" + if [[ "$BUILD_RESULT" == "success" && "$SKIP_TESTS" != "true" ]]; then + STATE=success; REASON="Full build with tests passed." + elif [[ "$BUILD_RESULT" == "skipped" && "$SHOULD_RUN" == "false" ]]; then + STATE=success; REASON="No relevant changes — nothing to build." + elif [[ "$BUILD_RESULT" != "success" && "$BUILD_RESULT" != "skipped" && "$SKIP_TESTS" != "true" ]]; then + STATE=failure; REASON="Full build with tests failed." + fi + if [[ -n "$STATE" ]]; then + echo "Posting build-gate=$STATE on $GATE_SHA: $REASON" + gh api "repos/${GITHUB_REPOSITORY}/statuses/${GATE_SHA}" \ + -f state="$STATE" \ + -f context=build-gate \ + -f description="$REASON" \ + -f target_url="$RUN_URL" else - echo "Build failed or was cancelled." - exit 1 + echo "Not posting build-gate: build.result=$BUILD_RESULT skip_tests=$SKIP_TESTS should_run=$SHOULD_RUN" + echo "The build-gate context stays absent, so the PR remains un-mergeable" + echo "at 'Expected' without a red X — this covers a successful OR failed" + echo "compile-only run (e.g. a fork PR, where no tests ran so there is" + echo "nothing to fail on). A *-reviewed-for-build tag runs the full build" + echo "with tests and posts success (or failure) to the build-gate context." fi # compile-gate is an ADVISORY (non-required) check. It reflects only whether From 08b7205d6d00963ab7846e11fec0e3d884ed4efc Mon Sep 17 00:00:00 2001 From: Axel Uhl Date: Sat, 29 Aug 2026 00:24:27 +0200 Subject: [PATCH 28/28] bug53: don't post build-gate on a cancelled trusted build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cancelled build is indeterminate — the tests might have passed had the run not been interrupted — so posting build-gate=failure would be misleading. Restrict the failure post to build.result==failure (a real tested failure); a cancelled trusted build now posts NOTHING, leaving the build-gate context absent so the PR stays blocked at "Expected" with no red X. Re-run to obtain a real verdict. Assisted-By: Claude Opus 4.8 --- .github/workflows/release.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f4f3f47d1f..998b4b6ddbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -329,14 +329,16 @@ jobs: # auto check-run carries a context the ruleset ignores. Its single step POSTs a # commit status context=build-gate ONLY when: the change was trivial (build # skipped, no relevant changes) OR a full build ran WITH tests and succeeded - # -> state=success; OR a full build ran WITH tests and FAILED (build.result not - # success/skipped, skip_tests==false) -> state=failure (red X, so a committer's - # own broken build is loud). In every OTHER case — a compile-only run, whether - # it succeeded OR failed (fork PR, *-reviewed-for-compile, workflow_dispatch - # skip_tests) — it posts NOTHING, so no build-gate context exists for the head - # SHA and the PR stays blocked at "Expected" with no red X. (A compile-only run - # ran no tests, so there is nothing meaningful to fail on; it simply hasn't met - # the gate.) Merge qualification for fork code comes only from a maintainer + # -> state=success; OR a full build ran WITH tests and FAILED + # (build.result==failure, skip_tests==false) -> state=failure (red X, so a + # committer's own broken build is loud). In every OTHER case it posts NOTHING, + # so no build-gate context exists for the head SHA and the PR stays blocked at + # "Expected" with no red X. That "nothing" covers: any compile-only run, + # succeeded OR failed (fork PR, *-reviewed-for-compile, workflow_dispatch + # skip_tests) — no tests ran, so there is nothing meaningful to fail on; AND a + # CANCELLED trusted build — cancellation is indeterminate (tests might have + # passed had it finished), so "failure" would be misleading; re-run to get a + # real verdict. Merge qualification for fork code comes only from a maintainer # pushing a *-reviewed-for-build tag (full trusted build with tests), which # posts success/failure. compile-gate (advisory) still shows compile pass/fail. # @@ -365,7 +367,7 @@ jobs: STATE=success; REASON="Full build with tests passed." elif [[ "$BUILD_RESULT" == "skipped" && "$SHOULD_RUN" == "false" ]]; then STATE=success; REASON="No relevant changes — nothing to build." - elif [[ "$BUILD_RESULT" != "success" && "$BUILD_RESULT" != "skipped" && "$SKIP_TESTS" != "true" ]]; then + elif [[ "$BUILD_RESULT" == "failure" && "$SKIP_TESTS" != "true" ]]; then STATE=failure; REASON="Full build with tests failed." fi if [[ -n "$STATE" ]]; then