diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml
index cb335fde..760bb4f0 100644
--- a/.github/workflows/pullrequest.yml
+++ b/.github/workflows/pullrequest.yml
@@ -1,5 +1,37 @@
name: Build Pull Request
+# The red `Build Pull Request` check on every release PR is NOT a broken job and NOT the trigger
+# filter - it is GitHub's approval gate for pull requests created with GITHUB_TOKEN: `pull_request`
+# events with the opened/synchronize/reopened activity types from GITHUB_TOKEN "create workflow
+# runs that require approval" (docs: Events that trigger workflows -> pull_request). Nobody
+# approves them, so the run is created with 0 jobs and resolves to `failure` once the release PR
+# is merged: it never executes, so it can never go green (0.15.14: run 36324298904,
+# event=pull_request, actor=github-actions[bot], 0 jobs, on the release-please branch - the same
+# shape on 0.15.13, 0.15.12, 0.15.11, 0.15.10 and 0.15.7).
+#
+# This workflow declares no `paths:` filter, so it matches *every* pull request - the release PR
+# included, on purpose. That is why the run exists at all: a workflow whose trigger does not match
+# gets no run whatsoever, so a filter that skipped the release PR's own files
+# (.release-please-manifest.json, CHANGELOG.md) would only turn an accepted red check into a
+# missing one. Do not "fix" the phantom that way.
+#
+# The one release-PR run of this workflow that ever executed is 0.15.7 (run 32509901978): it
+# carries run_attempt=2 and triggering_actor=robinbraemer, i.e. a human with write access approved
+# it from the pull request page, which is the documented manual remedy. No release PR run executes
+# without that approval - and that approved attempt is what built `build (17)` / `build (21)` for
+# the head that merged.
+#
+# Consequence, and the reason this note sits above the `on:` block: never add `Build Pull Request`
+# (or any `Build Pull Request / ...`) context to main's required status checks. The gated run
+# reports no check run at all, so a release PR would sit in "Expected - waiting for status to be
+# reported" while release-please.yml retries its merge and finally reds the release job. The only
+# contexts a release PR can satisfy are the native matrix check runs of the build release-please.yml
+# dispatches for it with `workflow_dispatch` (exempt from the gate): `build (17)` and
+# `build (21)`. Nothing is mirrored into branch protection to paper over the gate
+# (ReleasePleaseCheckAuditTest), and main is currently unprotected. Making the check real would
+# require release-please to open its PR with a GitHub App/PAT token instead of GITHUB_TOKEN - a
+# credential change, not a workflow edit.
+# ReleaseBranchCheckContractTest pins all of the above.
on:
pull_request:
workflow_dispatch:
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
index 86cc27a4..1d794f97 100644
--- a/.github/workflows/release-please.yml
+++ b/.github/workflows/release-please.yml
@@ -36,6 +36,14 @@ jobs:
# author. For the same reason no expression is interpolated into any `run:` block in this
# workflow; the runner's own environment ($GITHUB_REPOSITORY, $GITHUB_REF_NAME) is used
# instead. Pinned by core/.../release/ReleasePleasePayloadTest.
+ # The release branch is validated here rather than through the check the pull request page
+ # shows for it: GitHub creates an approval-gated run of pullrequest.yml for any
+ # GITHUB_TOKEN-created pull request, which never gets approved and therefore resolves to
+ # `failure` with 0 jobs on every release PR. That run is the accepted red check documented
+ # above the `on:` block of pullrequest.yml; the check runs this step audits are the native
+ # `build (17)` / `build (21)` ones of the run dispatched below. Nothing is mirrored into
+ # branch protection for a release PR (ReleasePleaseCheckAuditTest / the contract test
+ # ReleaseBranchCheckContractTest).
- name: Validate and merge release PR
if: ${{ steps.rp.outputs.pr }}
env:
diff --git a/core/src/test/java/com/minekube/connect/release/ReleaseBranchCheckContractTest.java b/core/src/test/java/com/minekube/connect/release/ReleaseBranchCheckContractTest.java
new file mode 100644
index 00000000..0cb9e89a
--- /dev/null
+++ b/core/src/test/java/com/minekube/connect/release/ReleaseBranchCheckContractTest.java
@@ -0,0 +1,769 @@
+/*
+ * Copyright (c) 2019-2022 Minekube. https://minekube.com
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ * @author Minekube
+ * @link https://github.com/minekube/connect-java
+ */
+
+package com.minekube.connect.release;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.function.UnaryOperator;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.nodes.MappingNode;
+import org.yaml.snakeyaml.nodes.Node;
+import org.yaml.snakeyaml.nodes.NodeTuple;
+import org.yaml.snakeyaml.nodes.ScalarNode;
+import org.yaml.snakeyaml.nodes.SequenceNode;
+
+/**
+ * Every release PR shows a red {@code Build Pull Request} check and it can never go green. That is
+ * not a broken job and it is not a trigger-filter artefact: it is GitHub's documented approval gate
+ * for pull requests that a workflow created with {@code GITHUB_TOKEN}.
+ *
+ *
+ * "When a pull request is created or updated by a workflow using `GITHUB_TOKEN`, `pull_request`
+ * events with the `opened`, `synchronize`, or `reopened` activity types create workflow runs that
+ * require approval. A user with write access to the repository can approve these runs from the
+ * pull request page. With the exception of `workflow_dispatch` and `repository_dispatch`, other
+ * `GITHUB_TOKEN`-triggered events do not create workflow runs at all."
+ * - GitHub docs, "Events that trigger workflows" -> `pull_request`
+ *
+ *
+ * Observed on this repository (2026-09-27): release PR #167 (0.15.14) head {@code 622af57f}
+ * carries two {@code GitHub Actions} check suites - {@code 98357508591} ({@code status=completed},
+ * {@code conclusion=failure}, {@code latest_check_runs_count=0}: the approval-gated run
+ * {@code 36324298904}, {@code event=pull_request}, {@code actor=github-actions[bot]}, 0 jobs) and
+ * {@code 98357509913} ({@code success}, 2 check runs: {@code build (17)} and {@code build (21)}
+ * from the run {@code release-please.yml} dispatched with {@code workflow_dispatch}). The same
+ * shape is on 0.15.13 (34791964301), 0.15.12 (34790457463), 0.15.11 (33797944232), 0.15.10
+ * (33603995522) and 0.15.7 (32509874033).
+ *
+ *
The one release-PR run of this workflow that ever executed is the interesting one: 0.15.7 run
+ * {@code 32509901978} reports {@code run_attempt=2} and {@code triggering_actor=robinbraemer} - a
+ * human with write access approved it from the pull request page, which is exactly the documented
+ * remedy above. No release PR run executes without that; the gate holds, and the approved attempt
+ * is what built {@code build (17)}/{@code build (21)} for the head that merged.
+ *
+ *
Why the trigger is not the cause, and why it must not be "fixed" by touching it:
+ * {@code pullrequest.yml} declares no {@code paths:} filter, so it matches every pull request -
+ * the release PR included, on purpose. That is why the gated run exists at all: a workflow whose
+ * filter does not match gets no run whatsoever (a docs-only PR produces zero runs). Adding a
+ * filter that skips the release PR's own files ({@code .release-please-manifest.json},
+ * {@code CHANGELOG.md}) can therefore only convert an accepted red check into a missing one.
+ *
+ *
Disposition, and the reason this test exists: the only contexts a release PR can ever
+ * satisfy are the native matrix check runs of the build {@code release-please.yml} dispatches -
+ * {@code build (17)} and {@code build (21)}. The workflow-level context of the gated workflow
+ * ("{@code Build Pull Request}") must never be added to {@code main}'s required status checks:
+ * the gated run reports no check run at all, so a release PR would sit in "Expected - waiting for
+ * status to be reported" while the automation retries its merge and finally reds the release job.
+ * Nothing is mirrored into branch protection either (no synthetic check run, no legacy commit
+ * status) - {@code ReleasePleaseCheckAuditTest} pins that a release PR keeps showing exactly the
+ * native checks of the run the automation validated. Making the check real would require
+ * release-please to open its PR with a GitHub App/PAT token instead of {@code GITHUB_TOKEN}
+ * (GitHub docs, "Triggering a workflow from a workflow": an App installation token or PAT "also
+ * lets `pull_request` workflows run automatically (without the approval prompt described above)
+ * when the pull request is created or updated by automation"). That is a credential change, not a
+ * workflow edit, so it is deliberately not done here. The acceptance note above the {@code on:}
+ * block of {@code pullrequest.yml} carries the same disposition for a reader of the workflow.
+ */
+class ReleaseBranchCheckContractTest {
+
+ private static final Path WORKFLOW_DIR = Paths.get("..", ".github", "workflows");
+ private static final Path GATED_WORKFLOW = WORKFLOW_DIR.resolve("pullrequest.yml");
+ private static final Path RELEASE_PLEASE_WORKFLOW = WORKFLOW_DIR.resolve("release-please.yml");
+ private static final Path REPOSITORY_GIT_PATH = Paths.get("..", ".git");
+
+ /** The message prefix every rejection carries, so a mutation test can assert the reason. */
+ private static final String CONTRACT = "release-branch-check contract:";
+
+ /** The gated workflow's own name: it is the check a release PR sees on the pull request page. */
+ private static final String GATED_WORKFLOW_NAME = "Build Pull Request";
+ private static final String GATED_WORKFLOW_FILE = "pullrequest.yml";
+
+ /** The dispatchable build whose native checks are the only contexts a release PR can satisfy. */
+ private static final String DISPATCHED_WORKFLOW_FILE = "pullrequest.yml";
+
+ /** {@code release-please.yml} dispatches {@link #DISPATCHED_WORKFLOW_FILE} on the release branch. */
+ private static final String VALIDATE_STEP = "Validate and merge release PR";
+
+ /** Every workflow file in this repository, asserted before it is swept. */
+ private static final Set EXPECTED_WORKFLOW_FILES =
+ Set.of("pullrequest.yml", "release-please.yml", "release-repair.yml", "release.yml");
+
+ /** The only workflow a release PR reaches: the gated one. */
+ private static final Set WANT_PULL_REQUEST_WORKFLOWS = Set.of(GATED_WORKFLOW_FILE);
+
+ /**
+ * The file set release-please writes into its own PR. Observed on #165 and #167 ({@code
+ * .release-please-manifest.json} + {@code CHANGELOG.md}, nothing else - this component's
+ * version lives in the manifest, not in a version file), which is what the trigger analysis
+ * depends on.
+ */
+ private static final List RELEASE_PR_FILES =
+ List.of(".release-please-manifest.json", "CHANGELOG.md");
+
+ /**
+ * The contexts a release PR can satisfy: the matrix check runs of the dispatched build, derived
+ * from the workflow's own job name and matrix. Nothing else may be required, and nothing
+ * synthetic may be created to make the gated workflow-level context satisfiable.
+ */
+ private static final Set WANT_NATIVE_CHECK_CONTEXTS = Set.of("build (17)", "build (21)");
+
+ /** Nothing is mirrored into branch protection: no synthetic check run, no legacy status. */
+ private static final Set WANT_MIRRORED_CONTEXTS = Set.of();
+
+ // ---------------------------------------------------------------------------------------
+ // The contract: every workflow fact the disposition depends on.
+ // ---------------------------------------------------------------------------------------
+
+ /** Every workflow fact the acceptance note above {@code pullrequest.yml}'s {@code on:} rests on. */
+ private static final class Contract {
+ /** The name of the workflow that reports the approval-gated check. */
+ private String gatedWorkflowName;
+ /**
+ * The {@code paths} filter of the gated workflow's {@code pull_request} trigger, or
+ * {@code null} when the workflow declares no filter at all (its current shape, and the
+ * shape that keeps it matching the release PR on purpose).
+ */
+ private List gatedTriggerPaths;
+ /** The workflows a {@code pull_request} event reaches - the gated one, and nothing else. */
+ private Set pullRequestWorkflows;
+ /**
+ * The {@code token:} the release-please action step receives. Empty means it uses the
+ * runner's {@code GITHUB_TOKEN}, i.e. the approval gate applies.
+ */
+ private String releasePleaseTokenInput;
+ /** The branch-protection contexts the merge step creates itself: none. */
+ private List mirroredContexts;
+ /** The check-run names the dispatched build reports: the only contexts a PR can satisfy. */
+ private List nativeCheckContexts;
+
+ private Contract(
+ String gatedWorkflowName,
+ List gatedTriggerPaths,
+ Set pullRequestWorkflows,
+ String releasePleaseTokenInput,
+ List mirroredContexts,
+ List nativeCheckContexts) {
+ this.gatedWorkflowName = gatedWorkflowName;
+ this.gatedTriggerPaths = gatedTriggerPaths;
+ this.pullRequestWorkflows = pullRequestWorkflows;
+ this.releasePleaseTokenInput = releasePleaseTokenInput;
+ this.mirroredContexts = mirroredContexts;
+ this.nativeCheckContexts = nativeCheckContexts;
+ }
+
+ /** The same facts, for a mutation to weaken. */
+ private Contract copy() {
+ return new Contract(gatedWorkflowName, gatedTriggerPaths, pullRequestWorkflows,
+ releasePleaseTokenInput, mirroredContexts, nativeCheckContexts);
+ }
+ }
+
+ /**
+ * The live contract, read from the workflows themselves. The {@code on:} block is read from the
+ * YAML node tree, where the key stays the literal {@code on} instead of the boolean a YAML 1.1
+ * resolver would make of it.
+ */
+ private static Contract liveContract() throws Exception {
+ Node gated = readWorkflow(GATED_WORKFLOW);
+ Set pullRequestWorkflows = new TreeSet<>();
+ for (Path workflow : workflowFiles()) {
+ if (triggersOn(readWorkflow(workflow), "pull_request")) {
+ pullRequestWorkflows.add(workflow.getFileName().toString());
+ }
+ }
+ return new Contract(
+ scalar(gated, "name"),
+ triggerPaths(gated, GATED_WORKFLOW_FILE, "pull_request"),
+ pullRequestWorkflows,
+ releasePleaseTokenInput(),
+ mirroredContexts(),
+ nativeCheckContexts(gated));
+ }
+
+ /**
+ * Fails when any fact the documented disposition has changed. The messages name the
+ * disposition, so a future reader re-decides it instead of deleting the guard.
+ */
+ private static String validate(Contract contract) {
+ if (!GATED_WORKFLOW_NAME.equals(contract.gatedWorkflowName)) {
+ return CONTRACT + " the workflow that reports the approval-gated check on a release PR "
+ + "is no longer named \"" + GATED_WORKFLOW_NAME + "\" (got \""
+ + contract.gatedWorkflowName + "\"); the acceptance note above the on: block of "
+ + GATED_WORKFLOW_FILE + " names it explicitly, so the disposition has to be "
+ + "re-decided before this test is updated";
+ }
+
+ if (contract.gatedTriggerPaths != null) {
+ List matched = new ArrayList<>();
+ for (String file : RELEASE_PR_FILES) {
+ if (matchesAny(contract.gatedTriggerPaths, file)) {
+ matched.add(file);
+ }
+ }
+ if (matched.isEmpty()) {
+ return CONTRACT + " " + GATED_WORKFLOW_FILE + " now filters pull_request by paths "
+ + "that no release PR matches (" + contract.gatedTriggerPaths + " matches "
+ + "none of " + RELEASE_PR_FILES + "): GitHub then creates no run for the "
+ + "release PR at all, so the accepted red check silently becomes a missing "
+ + "one and the note in " + GATED_WORKFLOW_FILE + " describes something that "
+ + "no longer happens";
+ }
+ }
+
+ if (!WANT_PULL_REQUEST_WORKFLOWS.equals(contract.pullRequestWorkflows)) {
+ return CONTRACT + " the workflows a pull_request event reaches are "
+ + contract.pullRequestWorkflows + ", want exactly " + WANT_PULL_REQUEST_WORKFLOWS
+ + ". " + GATED_WORKFLOW_FILE + " is the only workflow a release PR reaches: "
+ + "another one would add contexts a release PR reports (and has to be ruled on), "
+ + "and its absence would remove the accepted red check that "
+ + GATED_WORKFLOW_FILE + " documents";
+ }
+
+ if (!contract.releasePleaseTokenInput.isEmpty()) {
+ return CONTRACT + " the release-please action now receives a token (\""
+ + contract.releasePleaseTokenInput + "\"), so its pull request is no longer "
+ + "created with GITHUB_TOKEN and the approval gate described in "
+ + GATED_WORKFLOW_FILE + " does not apply anymore. Check whether the run is "
+ + "green now, remove the acceptance note, and reconsider which contexts a "
+ + "release PR may be required to satisfy";
+ }
+
+ Set mirrored = new TreeSet<>(contract.mirroredContexts);
+ if (!WANT_MIRRORED_CONTEXTS.equals(mirrored)) {
+ return CONTRACT + " " + RELEASE_PLEASE_WORKFLOW.getFileName() + " now mirrors "
+ + mirrored + " into branch protection, want none. A release PR is validated "
+ + "through the checks of the run the automation dispatched; a synthetic context "
+ + "duplicates them on the commit and would let the workflow-level context of "
+ + GATED_WORKFLOW_FILE + " (or anything else) be required as if it were "
+ + "satisfiable";
+ }
+
+ Set native_ = new TreeSet<>(contract.nativeCheckContexts);
+ if (!WANT_NATIVE_CHECK_CONTEXTS.equals(native_)) {
+ return CONTRACT + " the dispatched build now reports " + native_ + " on a release PR, "
+ + "want exactly " + WANT_NATIVE_CHECK_CONTEXTS + ". These are the only contexts "
+ + "a release PR can satisfy; if the job name or the matrix changed, re-decide "
+ + "which contexts may be required on main and update the acceptance note in "
+ + GATED_WORKFLOW_FILE + " with it";
+ }
+
+ return null;
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // The guard and its facts.
+ // ---------------------------------------------------------------------------------------
+
+ /** The live workflows must still satisfy every fact the documented disposition depends on. */
+ @Test
+ void releaseBranchCheckContractMatchesLiveWorkflows() throws Exception {
+ Contract contract = liveContract();
+
+ assertNull(validate(contract),
+ "the live workflows no longer back the acceptance note in " + GATED_WORKFLOW_FILE
+ + ": " + validate(contract));
+ assertTrue(contract.releasePleaseTokenInput.isEmpty(),
+ CONTRACT + " the release-please step must not pass a token, or the approval gate "
+ + "(and the note) is void (got \"" + contract.releasePleaseTokenInput + "\")");
+
+ for (String required : EXPECTED_WORKFLOW_FILES) {
+ assertTrue(workflowFiles().stream()
+ .map(path -> path.getFileName().toString())
+ .anyMatch(required::equals),
+ "the contract did not read " + required + "; a sweep that silently misses a "
+ + "workflow file cannot rule on the contexts a release PR reports");
+ }
+ }
+
+ /**
+ * Both halves the note rests on: the release PR reaches the gated workflow (which is why the
+ * red check exists), and it reaches no other workflow (a workflow whose trigger does not match
+ * produces no run at all - that is the control that makes the red check the approval gate
+ * rather than a filter artefact).
+ */
+ @Test
+ void releasePrsReachTheGatedWorkflowAndNoOtherWorkflow() throws Exception {
+ Contract contract = liveContract();
+
+ for (String file : RELEASE_PR_FILES) {
+ assertTrue(contract.gatedTriggerPaths == null
+ || matchesAny(contract.gatedTriggerPaths, file),
+ GATED_WORKFLOW_FILE + " no longer triggers for " + file
+ + " (paths: " + contract.gatedTriggerPaths + "), so a release PR shows "
+ + "no run of this workflow - the accepted red check the note describes "
+ + "would not exist");
+ }
+ assertEquals(WANT_PULL_REQUEST_WORKFLOWS, contract.pullRequestWorkflows,
+ "exactly one workflow may be reachable by a release PR, and it is the gated one");
+ }
+
+ /**
+ * The connect-java form of "only {@code lint-test} is mirrored": nothing is. A release PR must
+ * keep showing exactly the native checks of the run the automation dispatched, and the workflow
+ * it validates with is not one the gate can block ({@code workflow_dispatch} is exempt).
+ */
+ @Test
+ void nothingIsMirroredForAReleasePrAndTheDispatchedRunIsTheOnlyEvidence() throws Exception {
+ String script = mergeStepScript();
+
+ assertTrue(mirroredContexts().isEmpty(),
+ RELEASE_PLEASE_WORKFLOW.getFileName() + " creates synthetic branch-protection "
+ + "contexts for a release PR; the note in " + GATED_WORKFLOW_FILE
+ + " assumes it does not");
+
+ assertTrue(script.contains("gh workflow run " + DISPATCHED_WORKFLOW_FILE + " \\"),
+ RELEASE_PLEASE_WORKFLOW.getFileName() + " no longer dispatches "
+ + DISPATCHED_WORKFLOW_FILE + " (the exempt event that produces the native "
+ + "checks a release PR is judged by)");
+ assertTrue(script.contains("--event workflow_dispatch"),
+ "the release PR build must be found by its workflow_dispatch event; a "
+ + "pull_request-triggered run of that workflow is the approval-gated one and "
+ + "never executes");
+ assertTrue(script.contains("commits/$HEAD_SHA/check-runs?per_page=100")
+ && script.contains("startswith($run_url + \"/job/\")"),
+ "the merge step must audit the dispatched run's own native checks on the exact "
+ + "head, not any other context on that commit");
+ assertFalse(script.contains(GATED_WORKFLOW_NAME),
+ "the merge step now waits for or requires the workflow-level context \""
+ + GATED_WORKFLOW_NAME + "\", which a release PR can never satisfy");
+ }
+
+ /**
+ * The acceptance note is the disposition a reader of the workflow file sees, and comments are
+ * invisible to the YAML contract above, so it is pinned on the raw text: the facts it has to
+ * name, in the {@code on:} block it documents. Removing or hollowing it out reds this test
+ * instead of quietly losing the reasoning.
+ */
+ @Test
+ void theAcceptanceNoteInTheGatedWorkflowsOnBlockNamesTheDisposition() throws Exception {
+ String text = workflowText(GATED_WORKFLOW);
+ int onIndex = text.indexOf("\non:");
+ assertTrue(onIndex > 0, GATED_WORKFLOW_FILE + " has no on: block");
+
+ String note = text.substring(0, onIndex).toLowerCase(Locale.ROOT);
+ for (String fact : List.of("github_token", "approval", "0 jobs", "required",
+ GATED_WORKFLOW_NAME.toLowerCase(Locale.ROOT), "release-please", "workflow_dispatch")) {
+ assertTrue(note.contains(fact),
+ "the acceptance note above " + GATED_WORKFLOW_FILE + "'s on: block no longer "
+ + "names \"" + fact + "\". The note is what tells a reader that the red "
+ + "check on every release PR is GitHub's approval gate for "
+ + "GITHUB_TOKEN pull requests, that this workflow matches release PRs on "
+ + "purpose, and that no context of it may be required on main. If the "
+ + "gate no longer applies (for example because release-please now opens "
+ + "its PR with an App/PAT token), re-decide the disposition and update "
+ + "this test with it - do not just delete the note");
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // The guard has teeth: every weakening mutation has to be rejected, with a stated reason.
+ // ---------------------------------------------------------------------------------------
+
+ @Test
+ void releaseBranchCheckContractRejectsMutations() throws Exception {
+ Contract live = liveContract();
+
+ Map> mutations = new LinkedHashMap<>();
+ mutations.put("gated-workflow-renamed", contract -> {
+ Contract mutated = contract.copy();
+ mutated.gatedWorkflowName = "CI";
+ return mutated;
+ });
+ mutations.put("gated-workflow-loses-the-pull-request-trigger", contract -> {
+ Contract mutated = contract.copy();
+ mutated.pullRequestWorkflows = new TreeSet<>();
+ return mutated;
+ });
+ mutations.put("another-workflow-now-triggers-on-pull-requests", contract -> {
+ Contract mutated = contract.copy();
+ mutated.pullRequestWorkflows = new TreeSet<>(
+ List.of(GATED_WORKFLOW_FILE, "release-please.yml"));
+ return mutated;
+ });
+ mutations.put("paths-filter-skips-the-release-pr-files", contract -> {
+ Contract mutated = contract.copy();
+ mutated.gatedTriggerPaths = List.of("core/**", "api/**");
+ return mutated;
+ });
+ mutations.put("release-please-token-added", contract -> {
+ Contract mutated = contract.copy();
+ mutated.releasePleaseTokenInput = "${{ secrets.RELEASE_PLEASE_TOKEN }}";
+ return mutated;
+ });
+ mutations.put("synthetic-check-run-mirrored", contract -> {
+ Contract mutated = contract.copy();
+ mutated.mirroredContexts = List.of("build (17)");
+ return mutated;
+ });
+ mutations.put("gated-context-mirrored", contract -> {
+ Contract mutated = contract.copy();
+ mutated.mirroredContexts = List.of(GATED_WORKFLOW_NAME);
+ return mutated;
+ });
+ mutations.put("matrix-or-job-name-changed", contract -> {
+ Contract mutated = contract.copy();
+ mutated.nativeCheckContexts = List.of("build (17)", "build (21)", "build (25)");
+ return mutated;
+ });
+
+ for (Map.Entry> mutation : mutations.entrySet()) {
+ String rejection = validate(mutation.getValue().apply(live));
+
+ assertFalse(rejection == null,
+ "MUTATION_ACCEPTED|" + mutation.getKey()
+ + "|the contract let a weakened workflow through");
+ assertTrue(rejection.startsWith(CONTRACT),
+ "MUTATION_WRONG_REASON|" + mutation.getKey() + "|" + rejection);
+ System.out.println(
+ "MUTATION_REJECTED|" + mutation.getKey() + "|reason=" + rejection);
+ }
+
+ // The guard is not "no filter may ever be declared": a filter that still covers the release
+ // PR files is a legitimate edit and must be accepted, otherwise this test would force a
+ // re-decision on every perf tweak instead of on the facts that matter.
+ Contract withMatchingFilter = live.copy();
+ withMatchingFilter.gatedTriggerPaths = List.of("core/**", "CHANGELOG.md");
+ assertNull(validate(withMatchingFilter),
+ CONTRACT + " a paths filter that still matches " + RELEASE_PR_FILES
+ + " was rejected: " + validate(withMatchingFilter));
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // The path matcher itself, so the analysis above cannot pass on a wrong glob implementation.
+ // ---------------------------------------------------------------------------------------
+
+ @Test
+ void releasePrPathMatcherFollowsGitHubGlobbing() {
+ Map cases = new LinkedHashMap<>();
+ cases.put("CHANGELOG.md|CHANGELOG.md", true);
+ cases.put("CHANGELOG.md|CHANGELOG.txt", false);
+ cases.put(".release-please-manifest.json|.release-please-manifest.json", true);
+ cases.put("core/**|core/src/main/java/com/minekube/connect/Constants.java", true);
+ cases.put("core/**|core/build.gradle.kts", true);
+ cases.put("core/**|api/src/main/java/com/minekube/connect/api/Connect.java", false);
+ // Only file paths are matched here: a trailing `/**` in GitHub's matcher needs the
+ // directory's separator (`core/**` does not match the directory entry `core` itself), and
+ // that edge cannot influence whether the release PR's own files match a filter.
+ cases.put("**/*.md|CHANGELOG.md", true);
+ cases.put("**/*.md|docs/CHANGELOG.md", true);
+ cases.put("**/*.md|CHANGELOG.txt", false);
+ cases.put("api/**|api/build.gradle.kts", true);
+ cases.put(".github/workflows/pullrequest.yml|.github/workflows/pullrequest.yml", true);
+
+ for (Map.Entry testCase : cases.entrySet()) {
+ String[] parts = testCase.getKey().split("\\|", 2);
+ assertEquals(testCase.getValue(), matches(parts[0], parts[1]),
+ CONTRACT + " pattern " + parts[0] + " against " + parts[1]);
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // Workflow access.
+ // ---------------------------------------------------------------------------------------
+
+ private static List workflowFiles() throws Exception {
+ if (!Files.isDirectory(WORKFLOW_DIR)) {
+ if (Files.exists(REPOSITORY_GIT_PATH)) {
+ throw new AssertionError(WORKFLOW_DIR + " is missing from the repository checkout");
+ }
+ assumeTrue(false, WORKFLOW_DIR + " is unavailable outside a repository checkout");
+ return List.of();
+ }
+ try (Stream files = Files.list(WORKFLOW_DIR)) {
+ return files.filter(path -> {
+ String name = path.getFileName().toString();
+ return name.endsWith(".yml") || name.endsWith(".yaml");
+ })
+ .sorted()
+ .collect(Collectors.toList());
+ }
+ }
+
+ private static String workflowText(Path path) throws Exception {
+ if (!Files.exists(path)) {
+ if (Files.exists(REPOSITORY_GIT_PATH)) {
+ throw new AssertionError(path + " is missing from the repository checkout");
+ }
+ assumeTrue(false, path + " is unavailable outside a repository checkout");
+ }
+ return Files.readString(path, StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Parses a workflow as a YAML node tree, so the {@code on:} key is read as the literal scalar
+ * GitHub writes rather than the boolean a YAML 1.1 resolver would make of it.
+ */
+ private static Node readWorkflow(Path path) throws Exception {
+ return new Yaml().compose(new StringReader(workflowText(path)));
+ }
+
+ private static Node mappingValue(Node node, String key) {
+ if (!(node instanceof MappingNode)) {
+ return null;
+ }
+ for (NodeTuple tuple : ((MappingNode) node).getValue()) {
+ Node keyNode = tuple.getKeyNode();
+ if (keyNode instanceof ScalarNode && key.equals(((ScalarNode) keyNode).getValue())) {
+ return tuple.getValueNode();
+ }
+ }
+ return null;
+ }
+
+ private static String scalarOf(Node node) {
+ return node instanceof ScalarNode ? ((ScalarNode) node).getValue() : "";
+ }
+
+ private static String scalar(Node mapping, String key) {
+ return scalarOf(mappingValue(mapping, key));
+ }
+
+ /** Whether the workflow declares the given event under its {@code on:} block. */
+ private static boolean triggersOn(Node workflow, String event) {
+ Node on = mappingValue(workflow, "on");
+ return on != null && isKey(on, event);
+ }
+
+ private static boolean isKey(Node mapping, String key) {
+ if (!(mapping instanceof MappingNode)) {
+ return false;
+ }
+ for (NodeTuple tuple : ((MappingNode) mapping).getValue()) {
+ if (scalarOf(tuple.getKeyNode()).equals(key)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * The {@code paths} filter of a workflow's event trigger, or {@code null} when the trigger
+ * declares none (then it matches every pull request).
+ */
+ private static List triggerPaths(Node workflow, String workflowFile, String event) {
+ Node on = mappingValue(workflow, "on");
+ if (on == null) {
+ throw new AssertionError(CONTRACT + " " + workflowFile + " declares no on: block");
+ }
+ Node eventNode = mappingValue(on, event);
+ if (eventNode == null) {
+ throw new AssertionError(CONTRACT + " " + workflowFile + " does not trigger on " + event
+ + "; the release PR analysis assumes it does");
+ }
+ Node paths = mappingValue(eventNode, "paths");
+ if (paths == null) {
+ return null;
+ }
+ List declared = new ArrayList<>();
+ if (paths instanceof SequenceNode) {
+ for (Node item : ((SequenceNode) paths).getValue()) {
+ declared.add(scalarOf(item));
+ }
+ }
+ return declared;
+ }
+
+ /** The step with the given name, anywhere in the workflow. */
+ private static Node stepNode(Node workflow, String name) {
+ Node jobs = mappingValue(workflow, "jobs");
+ if (!(jobs instanceof MappingNode)) {
+ throw new AssertionError(CONTRACT + " the workflow declares no jobs");
+ }
+ for (NodeTuple job : ((MappingNode) jobs).getValue()) {
+ Node steps = mappingValue(job.getValueNode(), "steps");
+ if (!(steps instanceof SequenceNode)) {
+ continue;
+ }
+ for (Node step : ((SequenceNode) steps).getValue()) {
+ if (name.equals(scalar(step, "name"))) {
+ return step;
+ }
+ }
+ }
+ throw new AssertionError(CONTRACT + " no step named \"" + name + "\"");
+ }
+
+ /** The shell script of release-please's release-PR validation/merge step. */
+ private static String mergeStepScript() throws Exception {
+ return scalarOf(mappingValue(
+ stepNode(readWorkflow(RELEASE_PLEASE_WORKFLOW), VALIDATE_STEP), "run"));
+ }
+
+ /** The {@code token:} the release-please action step receives (empty means {@code GITHUB_TOKEN}). */
+ private static String releasePleaseTokenInput() throws Exception {
+ Node workflow = readWorkflow(RELEASE_PLEASE_WORKFLOW);
+ Node jobs = mappingValue(workflow, "jobs");
+ Node job = mappingValue(jobs, "release-please");
+ if (job == null) {
+ throw new AssertionError(CONTRACT + " " + RELEASE_PLEASE_WORKFLOW.getFileName()
+ + " has no release-please job");
+ }
+ Node steps = mappingValue(job, "steps");
+ if (steps instanceof SequenceNode) {
+ for (Node step : ((SequenceNode) steps).getValue()) {
+ String uses = scalar(step, "uses");
+ if (!uses.contains("release-please-action")) {
+ continue;
+ }
+ Node with = mappingValue(step, "with");
+ if (with == null) {
+ return "";
+ }
+ return scalar(with, "token");
+ }
+ }
+ throw new AssertionError(CONTRACT + " " + RELEASE_PLEASE_WORKFLOW.getFileName()
+ + " no longer uses release-please-action, so the release PR (and with it the "
+ + "approval-gated run) no longer exists");
+ }
+
+ /**
+ * The branch-protection contexts the merge step creates itself, read back out of its own
+ * script: a {@code gh api} that sets {@code name=} or {@code context=} posts a check run or a
+ * legacy commit status. None may appear - a release PR is judged by the native checks of the
+ * dispatched run (see {@code ReleasePleaseCheckAuditTest}).
+ */
+ private static List mirroredContexts() throws Exception {
+ String script = mergeStepScript();
+ Set contexts = new TreeSet<>();
+ for (String pattern : List.of("(?:-f|--field)\\s+name='([^']*)'",
+ "(?:-f|--field)\\s+context='([^']*)'")) {
+ java.util.regex.Matcher matcher = Pattern.compile(pattern).matcher(script);
+ while (matcher.find()) {
+ contexts.add(matcher.group(1));
+ }
+ }
+ return new ArrayList<>(contexts);
+ }
+
+ /**
+ * The check-run names the dispatched build reports on a release PR, derived from the workflow's
+ * job name and its matrix - the only contexts a release PR can satisfy.
+ */
+ private static List nativeCheckContexts(Node gatedWorkflow) {
+ Node jobs = mappingValue(gatedWorkflow, "jobs");
+ if (!(jobs instanceof MappingNode) || ((MappingNode) jobs).getValue().isEmpty()) {
+ throw new AssertionError(CONTRACT + " " + GATED_WORKFLOW_FILE + " declares no jobs");
+ }
+ NodeTuple job = ((MappingNode) jobs).getValue().get(0);
+ String jobName = scalarOf(job.getKeyNode());
+ Node matrix = mappingValue(mappingValue(job.getValueNode(), "strategy"), "matrix");
+ Node java = mappingValue(matrix, "java");
+ if (!(java instanceof SequenceNode)) {
+ throw new AssertionError(CONTRACT + " " + GATED_WORKFLOW_FILE + "'s \"" + jobName
+ + "\" job declares no java matrix, so the check runs a release PR reports (and "
+ + "may be required to satisfy) changed");
+ }
+ List contexts = new ArrayList<>();
+ for (Node value : ((SequenceNode) java).getValue()) {
+ contexts.add(jobName + " (" + scalarOf(value) + ")");
+ }
+ return contexts;
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // GitHub's `paths` globbing, in the subset this repository uses.
+ // ---------------------------------------------------------------------------------------
+
+ private static boolean matchesAny(List patterns, String file) {
+ for (String pattern : patterns) {
+ if (matches(pattern, file)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** {@code **} spans path separators, {@code *} and {@code ?} stay inside one segment. */
+ private static boolean matches(String pattern, String path) {
+ return matchesSegments(pattern.split("/", -1), path.split("/", -1));
+ }
+
+ private static boolean matchesSegments(String[] pattern, String[] path) {
+ if (pattern.length == 0) {
+ return path.length == 0;
+ }
+ if ("**".equals(pattern[0])) {
+ for (int i = 0; i <= path.length; i++) {
+ if (matchesSegments(java.util.Arrays.copyOfRange(pattern, 1, pattern.length),
+ java.util.Arrays.copyOfRange(path, i, path.length))) {
+ return true;
+ }
+ }
+ return false;
+ }
+ if (path.length == 0) {
+ return false;
+ }
+ if (!matchesSegment(pattern[0], path[0])) {
+ return false;
+ }
+ return matchesSegments(java.util.Arrays.copyOfRange(pattern, 1, pattern.length),
+ java.util.Arrays.copyOfRange(path, 1, path.length));
+ }
+
+ private static boolean matchesSegment(String pattern, String segment) {
+ if (pattern.isEmpty()) {
+ return segment.isEmpty();
+ }
+ switch (pattern.charAt(0)) {
+ case '*':
+ // `*` never crosses a separator, so it can only consume the rest of this segment.
+ for (int i = 0; i <= segment.length(); i++) {
+ if (matchesSegment(pattern.substring(1), segment.substring(i))) {
+ return true;
+ }
+ }
+ return false;
+ case '?':
+ return !segment.isEmpty()
+ && matchesSegment(pattern.substring(1), segment.substring(1));
+ default:
+ return !segment.isEmpty() && segment.charAt(0) == pattern.charAt(0)
+ && matchesSegment(pattern.substring(1), segment.substring(1));
+ }
+ }
+}
diff --git a/core/src/test/java/com/minekube/connect/release/ReleasePleaseCheckAuditTest.java b/core/src/test/java/com/minekube/connect/release/ReleasePleaseCheckAuditTest.java
index 4996b5a3..358369a6 100644
--- a/core/src/test/java/com/minekube/connect/release/ReleasePleaseCheckAuditTest.java
+++ b/core/src/test/java/com/minekube/connect/release/ReleasePleaseCheckAuditTest.java
@@ -113,4 +113,42 @@ void releasePrAuditsNativeChecksOnTheExactHeadWithoutDuplicatingThem() throws Ex
"if [ \"$STATE\" != \"MERGED\" ] || [ \"$CURRENT_HEAD_SHA\" != \"$HEAD_SHA\" ]; then"),
"release-please does not verify the final merged head");
}
+
+ /**
+ * A release PR is validated through the checks of the run release-please.yml dispatches for it,
+ * never through a context of the approval-gated run GitHub creates on its own. That run has no
+ * approval, no jobs and no check runs (it resolves to {@code failure} with 0 jobs once the PR
+ * is merged - the accepted red check documented above {@code pullrequest.yml}'s {@code on:}
+ * block), so a merge step that waited for a context named after that workflow would deadlock
+ * every release instead of merging it.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ void releasePrIsValidatedThroughTheDispatchedRunNotThroughTheGatedWorkflowContext()
+ throws Exception {
+ Map workflow = workflow();
+ assumeTrue(!workflow.isEmpty());
+
+ Map jobs = (Map) workflow.get("jobs");
+ Map releasePlease = (Map) jobs.get("release-please");
+ List