diff --git a/.agents/skills/workflow/SKILL.md b/.agents/skills/workflow/SKILL.md index d48ed4bd..552a3c91 100644 --- a/.agents/skills/workflow/SKILL.md +++ b/.agents/skills/workflow/SKILL.md @@ -211,6 +211,21 @@ The two modes have opposite goals — design them accordingly: **Real run (`false`):** no extra `echo`, no debug flags, let failures surface via exit codes, one `::notice::` summary on success at most. +**Exception — `continue-on-error` steps must annotate their own failures.** The +rule above leans on the exit code to carry a failure. A step that deliberately +swallows its exit code has no such channel, so a failure there is invisible: the +job reports success and nobody looks at the log. Emit `::warning::` on the +failure path. + +This is not hypothetical. `pr-validation.yml`'s breaking-change comment took a +403 on every run from 2026-08-06 and reported success each time, because the +step was `continue-on-error` and said nothing. The comment it posts explains to +an author what to add; for two months it never arrived, and the only symptom was +a blocking check failing with no explanation. + +Gate the annotation on the failure itself (`if: steps..outcome == 'failure'`) +so a successful run stays silent. + ## Step section titles When a job has more than one logical group of steps, separate each group with a titled section comment: diff --git a/.github/labels.yml b/.github/labels.yml index 8498552c..6e04c717 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -82,6 +82,10 @@ color: "b60205" description: Callers must update their workflow configuration after this change +- name: awaiting-breaking-approval + color: "fbca04" + description: A breaking change on this PR is waiting on a human decision + # ── Capabilities ────────────────────────────────────────────────────────────── - name: build diff --git a/.github/workflows/api-dog-e2e-tests.yml b/.github/workflows/api-dog-e2e-tests.yml index 56b6a989..e34e91bf 100644 --- a/.github/workflows/api-dog-e2e-tests.yml +++ b/.github/workflows/api-dog-e2e-tests.yml @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: ${{ inputs.node_version }} @@ -131,7 +131,7 @@ jobs: - name: Upload Test Reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: apidog-e2e-test-reports-${{ env.TAG_TYPE }} path: ./apidog-reports diff --git a/.github/workflows/breaking-change-approval.yml b/.github/workflows/breaking-change-approval.yml index 6112fdea..9183030b 100644 --- a/.github/workflows/breaking-change-approval.yml +++ b/.github/workflows/breaking-change-approval.yml @@ -125,6 +125,20 @@ on: required: false type: string default: '' + fallback_reviewers: + description: >- + Who may answer the gate when `approval_reviewers` is empty and + CODEOWNERS yields nobody — same `team:` / `user:` form. + + Most repositories have a CODEOWNERS without a catch-all `*` rule, so + a repository-wide gate finds no owner to ask. Without a fallback the + Environment cannot be created, the preflight fails, and the first + breaking change in that repository is blocked by a gate nobody can + answer. Empty keeps that behaviour — refusing rather than inventing a + reviewer. + required: false + type: string + default: '' create_approval_environment: description: >- Create `breaking-approval` when it is missing, using `approval_reviewers`. @@ -144,10 +158,40 @@ on: type: boolean default: true label: - description: Label applied while the pull request waits for its approval, and removed once resolved. Cosmetic — failure to apply it never fails the job. Empty disables it. + description: >- + Label stating that this pull request carries a breaking change. A fact + about the change, so it survives the approval — the break does not go + away because somebody agreed to it. Removed only when the breaking + commits are gone. Empty disables it. + + Behaviour change: this label used to come off on approval. A caller + that set it as a temporary waiting marker should move that value to + `pending_label` and set this one to an empty string. required: false type: string default: breaking-change + pending_label: + description: >- + Label stating that the breaking change is waiting on a decision. A + state, so it comes off once approved or rejected. Separate from + `label` because the two stop being true at different moments. + Empty disables it. + required: false + type: string + default: awaiting-breaking-approval + enable_approval: + description: >- + Hold breaking changes at the `breaking-approval` Environment. Off + leaves the gate reporting and labelling without ever asking for a + decision — the author acknowledgement is a separate control and is + still enforced by the caller. + + Off is not the same as not calling this workflow: the acknowledgement + comment, the one place that tells the author which line to paste, + comes from here. + required: false + type: boolean + default: true enable_comment: description: Post (and keep updated) a single comment carrying the request, the approval link and the outcome. required: false @@ -168,7 +212,14 @@ on: description: 'Whether the pull request was gated by this workflow (true/false)' value: ${{ jobs.detect.outputs.in-scope == 'true' && 'true' || 'false' }} has_breaking_changes: - description: 'Whether at least one pull request commit is breaking (true/false). Always false when out of scope — nothing was scanned.' + description: >- + Whether at least one pull request commit is breaking (true/false). + Reports what was detected, including out of scope — a pull request + this workflow does not gate can still carry a break, and a caller + asking the question deserves the answer rather than a false. + + Behaviour change: this used to be forced to `false` out of scope. + Gate `in_scope` alongside it to get the old reading. value: ${{ jobs.detect.outputs.has-breaking-changes == 'true' && 'true' || 'false' }} approved: description: 'Whether a reviewer approved the breaking change through the Environment (true/false)' @@ -205,13 +256,20 @@ jobs: contents: read pull-requests: read outputs: - in-scope: ${{ steps.scope.outputs.in-scope }} + # `enable_approval: false` is out of scope for the approval and nothing + # more. Everything downstream already does the right thing with that — + # it reports, it labels, it keeps the acknowledgement instructions, and + # it never touches an Environment. + in-scope: ${{ inputs.enable_approval && steps.scope.outputs.in-scope || 'false' }} pr-number: ${{ steps.scope.outputs.pr-number }} base-ref: ${{ steps.scope.outputs.base-ref }} head-sha: ${{ steps.scope.outputs.head-sha }} + head-ref: ${{ steps.scope.outputs.head-ref }} author: ${{ steps.scope.outputs.author }} - reason: ${{ steps.scope.outputs.reason }} + reason: ${{ inputs.enable_approval && steps.scope.outputs.reason || 'approval is disabled by the caller' }} has-breaking-changes: ${{ steps.normalize.outputs.has-breaking-changes }} + fingerprint: ${{ steps.commits.outputs.fingerprint }} + subjects: ${{ steps.commits.outputs.subjects }} acknowledged: ${{ steps.normalize.outputs.acknowledged }} detection-succeeded: ${{ steps.normalize.outputs.detection-succeeded }} @@ -232,53 +290,46 @@ jobs: - name: Announce the scope verdict env: IN_SCOPE: ${{ steps.scope.outputs.in-scope }} + APPROVAL_ENABLED: ${{ inputs.enable_approval && 'true' || 'false' }} REASON: ${{ steps.scope.outputs.reason }} PR_NUMBER: ${{ steps.scope.outputs.pr-number }} HEAD_REF: ${{ steps.scope.outputs.head-ref }} BASE_REF: ${{ steps.scope.outputs.base-ref }} run: | set -euo pipefail - if [ "$IN_SCOPE" = "true" ]; then + if [ "$APPROVAL_ENABLED" != "true" ]; then + # Detection still runs below — the acknowledgement comment needs to + # know whether there is a break to acknowledge. + echo "::notice::PR #${PR_NUMBER} will not be held for approval — the caller disabled it. Reporting and labelling only." + elif [ "$IN_SCOPE" = "true" ]; then echo "::notice::PR #${PR_NUMBER} is gated for breaking changes via ${REASON}" else echo "::notice::PR #${PR_NUMBER} (${HEAD_REF} → ${BASE_REF}) is not gated — ${REASON}" fi - # The composite reads Git history, so it needs `origin/` and a HEAD - # that is the pull request. The second checkout exists because the first - # one leaves HEAD on the event's commit, which is only the pull request - # under `pull_request`. `clean: false` keeps what the first one fetched. - - name: Check out full history - id: checkout - if: steps.scope.outputs.in-scope == 'true' && inputs.detected_breaking_changes == '' - continue-on-error: true - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Move HEAD onto the pull request head - id: checkout-head - if: steps.scope.outputs.in-scope == 'true' && steps.checkout.outcome == 'success' - continue-on-error: true - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - with: - ref: refs/pull/${{ steps.scope.outputs.pr-number }}/head - fetch-depth: 0 - clean: false - persist-credentials: false - - # Nothing from the checked-out tree is executed — the composite only reads - # commit messages — so fork code never runs here. - - name: Detect breaking changes - id: guard - if: steps.scope.outputs.in-scope == 'true' && steps.checkout-head.outcome == 'success' && inputs.detected_breaking_changes == '' + # Which commits tripped the gate, and a digest of the set. The digest is + # what lets an approval survive a push that did not touch the breaking + # change — see the `request` job. + # + # This also detects, when the caller did not. It replaced a pair of + # checkouts and the Git-reading composite: CodeQL flagged them as + # `actions/untrusted-checkout`, and it was right to — a non-privileged + # workflow was putting a contributor's tree on disk beside MANAGE_TOKEN. + # Nothing executed it, but nothing needed it either. Reading the API + # removes the tree, the token exposure and the alert together. + # + # `breaking-change-guard` stays the canonical detector and still runs in + # pr-validation over the real commit range; the patterns here are kept + # identical to it and tested against the same negative cases. + - name: List the breaking-change commits + id: commits + if: steps.scope.outputs.in-scope == 'true' continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/breaking-change-guard@v1 + # No checkout: it reads the pull request through the API. + uses: LerianStudio/github-actions-shared-workflows/src/validate/breaking-change-commits@develop with: - base-ref: ${{ steps.scope.outputs.base-ref }} - breaking-change-acknowledgement: ${{ inputs.author_acknowledgement }} - acknowledgement-match-mode: exact-visible-line + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + pr-number: ${{ steps.scope.outputs.pr-number }} - name: Normalize detection result id: normalize @@ -287,11 +338,8 @@ jobs: IN_SCOPE: ${{ steps.scope.outputs.in-scope }} SUPPLIED_HAS_BREAKING_CHANGES: ${{ inputs.detected_breaking_changes }} SUPPLIED_ACKNOWLEDGED: ${{ inputs.detected_acknowledged }} - CHECKOUT_OUTCOME: ${{ steps.checkout.outcome }} - CHECKOUT_HEAD_OUTCOME: ${{ steps.checkout-head.outcome }} - GUARD_OUTCOME: ${{ steps.guard.outcome }} - RAW_HAS_BREAKING_CHANGES: ${{ steps.guard.outputs.has-breaking-changes }} - RAW_ACKNOWLEDGED: ${{ steps.guard.outputs.approved }} + COMMITS_OUTCOME: ${{ steps.commits.outcome }} + BREAKING_COUNT: ${{ steps.commits.outputs.count }} run: | set -euo pipefail @@ -300,9 +348,18 @@ jobs: detection_succeeded=false if [ "$IN_SCOPE" != "true" ]; then - # Nothing was scanned, and nothing needs to be: an out-of-scope pull - # request is a successful no-op, not a failed detection. + # Out of scope means no approval is required — it does not mean + # nothing was found. When the caller already detected, keep that + # answer: the acknowledgement is still enforced everywhere, and the + # author needs to be told which line to add. Scanning here is what + # would be pointless, not knowing. detection_succeeded=true + case "$SUPPLIED_HAS_BREAKING_CHANGES" in + true | false ) + has_breaking_changes="$SUPPLIED_HAS_BREAKING_CHANGES" + [ "$SUPPLIED_ACKNOWLEDGED" = "true" ] && acknowledged=true + ;; + esac elif [ -n "$SUPPLIED_HAS_BREAKING_CHANGES" ]; then # The caller already ran the same composite over the same commits. # Anything but a clean boolean is a wiring mistake, and a wiring @@ -315,22 +372,32 @@ jobs: ;; unknown ) # The documented way for a caller to say "my own detection did not - # complete". It must not read as "no breaking changes". + # complete". It must not read as "no breaking changes" — and the + # acknowledgement it reported alongside is just as unevaluated. + acknowledged="" echo "The caller reported that its breaking-change detection did not complete — failing closed." >&2 ;; * ) + acknowledged="" echo "detected_breaking_changes must be 'true', 'false', 'unknown' or empty; got '${SUPPLIED_HAS_BREAKING_CHANGES}'." >&2 ;; esac - elif [ "$CHECKOUT_OUTCOME" = "success" ] && [ "$CHECKOUT_HEAD_OUTCOME" = "success" ] \ - && [ "$GUARD_OUTCOME" = "success" ] \ - && { [ "$RAW_HAS_BREAKING_CHANGES" = "true" ] || [ "$RAW_HAS_BREAKING_CHANGES" = "false" ]; } \ - && { [ "$RAW_ACKNOWLEDGED" = "true" ] || [ "$RAW_ACKNOWLEDGED" = "false" ]; }; then - has_breaking_changes="$RAW_HAS_BREAKING_CHANGES" - acknowledged="$RAW_ACKNOWLEDGED" + elif [ "$COMMITS_OUTCOME" = "success" ] && [ -n "$BREAKING_COUNT" ]; then + [ "$BREAKING_COUNT" -gt 0 ] && has_breaking_changes=true detection_succeeded=true + # Left empty on purpose, not false. Without the caller's verdict + # the acknowledgement was never evaluated here — the detector that + # reads the description lives in pr-validation — and reporting + # "missing" for something nobody looked at would send the author + # after a line no check is asking for. + acknowledged="" else - echo "Breaking change detection failed: checkout=${CHECKOUT_OUTCOME} checkout_head=${CHECKOUT_HEAD_OUTCOME} guard=${GUARD_OUTCOME}" >&2 + # Empty, not false. Detection did not complete, so nothing read the + # description — and `false` here reaches the blocked comment as + # "your acknowledgement is missing", sending an author who may well + # have written the line after a line no check evaluated. + acknowledged="" + echo "Breaking change detection failed: commits=${COMMITS_OUTCOME} count=${BREAKING_COUNT:-missing}" >&2 fi { @@ -351,7 +418,244 @@ jobs: permissions: contents: read pull-requests: write + outputs: + already-approved: ${{ steps.reuse.outputs.already-approved }} + approved-by: ${{ steps.prior.outputs.approved-by }} steps: + # Has this exact breaking change already been approved on this pull + # request? The approval comment records the fingerprint it approved, so a + # push that leaves the breaking commits alone — a typo fix, a review + # follow-up — does not send the reviewer the same question again. + # + # Asking again for unchanged content is how a gate becomes a formality: + # the second time costs attention, the fifth time gets a reflex click. + - name: Check for a prior approval of the same change + id: prior + # Runs under dry_run too: it only reads, and a dry run that cannot say + # whether the approval would be reused is not previewing the decision + # that matters most here. + if: >- + needs.detect.result == 'success' + && needs.detect.outputs.in-scope == 'true' + && needs.detect.outputs.has-breaking-changes == 'true' + && needs.detect.outputs.fingerprint != '' + env: + GH_TOKEN: ${{ secrets.MANAGE_TOKEN || github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ needs.detect.outputs.pr-number }} + HEAD_REF: ${{ needs.detect.outputs.head-ref }} + FINGERPRINT: ${{ needs.detect.outputs.fingerprint }} + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + run: | + set -euo pipefail + + candidate_run="" + candidate_head="" + approved_by="" + approving_run="" + unverified="" + + # Two things have to be true, because the comment alone is not + # evidence. Anyone who can read the repository can comment on a pull + # request, and the fingerprint is computable from public data — the + # commit SHAs are on the pull request and the digest is in this + # repository. A marker on its own is therefore forgeable by a + # stranger, which would hand them the approval gate. + # + # 1. the record sits in a comment from someone inside the + # organisation, which is where the reporter writes + # 2. the run it names really was approved for this Environment + # + # The second is the one that matters: forging the text is easy, + # producing a run whose Environment approval actually happened is not. + # + # No `grep -q` anywhere below: with `pipefail`, it can exit on the + # first match while `--paginate` is still writing, and the broken pipe + # would read as "no match" — asking for an approval already given. + # + # `github-actions[bot]` is accepted by login because GitHub can report + # `author_association: NONE` for it — and that is the identity the + # reporter writes under whenever no MANAGE_TOKEN is passed, which is + # most callers. Filtering it out would quietly disable the whole + # feature for them. It is not a weakening: nobody but Actions can + # author a comment under that login. + records=$( + gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \ + --jq '.[] + | select(.author_association == "OWNER" + or .author_association == "MEMBER" + or .user.login == "github-actions[bot]") + | .body' 2>/dev/null \ + | grep -oE "" \ + || true + ) + + # Every record, not just the first. The reporter keeps them all, and + # runs are deleted on the repository's retention schedule — 90 days by + # default — so the oldest record for a fingerprint is the first to + # become unverifiable while a newer one is still perfectly good. + # Stopping at the first would ask for an approval that is on file. + while IFS= read -r record; do + [ -z "$record" ] && continue + approving_run=$(grep -oE 'run=[0-9]+' <<< "$record" | cut -d= -f2) + [ -z "$approving_run" ] && continue + + # Verified against the Environment's own approval log. Needs + # `actions: read`; when that is missing the check cannot be made, so + # the approval is not reused and the reviewer is asked again. Asking + # twice is a cost, trusting an unverified record is a hole. + # Whose run is it? Confirming that it carried *an* approval proves + # nothing: every approved breaking change in the repository has a + # run like that, and a record naming one of those is a sentence + # anybody with write access can type. Without this check, an + # approval granted to someone else's change releases this one. + # + # Matched on the branch, because `pull_requests` on a run comes + # back empty in practice — verified against run 38967637198, which + # really did carry an approval for this gate. + # + # The run's own head is read here and nowhere else. It is the only + # unforgeable statement of *what* that approval covered: the + # fingerprint in the record says what the writer claims it covered, + # and anybody who can read the repository can write a record. + run_facts=$( + gh api "repos/${REPO}/actions/runs/${approving_run}" \ + --jq '[(.head_branch // ""), (.head_sha // "")] | @tsv' 2>/dev/null || true + ) + run_branch=$(printf '%s' "$run_facts" | cut -f1) + run_head=$(printf '%s' "$run_facts" | cut -f2) + + if [ -z "$run_branch" ] || [ "$run_branch" != "$HEAD_REF" ]; then + unverified="${unverified:+$unverified }${approving_run}" + [ "$DRY_RUN" = "true" ] && echo " record run=${approving_run} : belongs to '${run_branch:-unknown}', not '${HEAD_REF}'" + continue + fi + + approvers=$( + gh api "repos/${REPO}/actions/runs/${approving_run}/approvals" \ + --jq '.[] | select(.state == "approved") + | select((.environments // []) | map(.name) | index("breaking-approval")) + | .user.login' 2>/dev/null || true + ) + + if [ -n "$approvers" ] && [ -n "$run_head" ]; then + # A candidate, not a verdict. What this run approved is still + # unknown — the next step resolves the breaking set as it stood + # at `run_head` and only then is the record believed. + candidate_run="$approving_run" + candidate_head="$run_head" + approved_by="$approvers" + break + fi + + unverified="${unverified:+$unverified }${approving_run}" + [ "$DRY_RUN" = "true" ] && echo " record run=${approving_run} : approval could not be verified" + done <<< "$records" + + # One line on a real run, per the dry_run contract. Everything else is + # for the preview, where being able to predict the decision is the + # entire point. + if [ "$DRY_RUN" = "true" ]; then + echo "::notice::DRY RUN — approval reuse lookup" + echo " fingerprint : ${FINGERPRINT}" + echo " records found : $(grep -c . <<< "$records" || true)" + echo " unverified runs : [${unverified:-}]" + echo " candidate run : ${candidate_run:-none}${approved_by:+ (by ${approved_by})}" + echo " candidate head : ${candidate_head:-none}" + elif [ -n "$candidate_run" ]; then + echo "::notice::Run ${candidate_run} carried an approval for this branch — checking what it approved" + elif [ -n "$records" ]; then + echo "::warning::A recorded approval of ${FINGERPRINT} exists but could not be verified against the Environment log (runs: ${unverified:-none}). Asking again." + fi + + # The identity goes out with the verdict. Reuse skips the approval + # job, so the outcome reporter reads an empty `approvers` from a run + # that held no Environment decision and attributes the approval to + # "a reviewer" — erasing the name of whoever actually gave it. Both + # values are verified against the Environment log above; neither is + # read from the comment. + # One login per line is what the approvals API gives back, and an + # Environment can require more than one reviewer. Written straight + # out, the second login lands on its own line with no `=`, the runner + # rejects it, and this step fails — taking the whole reuse contract + # with it: `request` fails, the verdict reads `blocked`, and a + # previously approved breaking set is blocked on every push. + # + # Comma-separated is also what the reporter already parses. + approved_by=$(printf '%s' "$approved_by" | awk 'NF' | paste -sd, -) + + { + echo "candidate-run=${candidate_run}" + echo "candidate-head=${candidate_head}" + echo "approved-by=${approved_by}" + } >> "$GITHUB_OUTPUT" + + # What did that run actually approve? Resolved from the run's own head, + # which nobody can write, rather than from the fingerprint in the record, + # which anybody who can read the repository can write. + # + # @fredcamaral demonstrated the gap on #926. The fingerprint is derived + # from public commit SHAs, so it is computable in advance: a member posts + # a record carrying fp(A,B) and pointing at the run that approved only A, + # then pushes the breaking commit B. Every check up to here passes — the + # record is from a MEMBER, the run is on this branch, the run really did + # carry an approval — and nothing asked what it approved. The verdict + # came back `approved`, exit 0, no reviewer involved. + - name: Resolve what the approving run approved + id: approved-set + if: steps.prior.outputs.candidate-head != '' + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/validate/breaking-change-commits@develop + with: + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + pr-number: ${{ needs.detect.outputs.pr-number }} + at-sha: ${{ steps.prior.outputs.candidate-head }} + base-ref: ${{ needs.detect.outputs.base-ref }} + + - name: Confirm the approval covers this change + id: reuse + env: + CANDIDATE_RUN: ${{ steps.prior.outputs.candidate-run }} + CANDIDATE_HEAD: ${{ steps.prior.outputs.candidate-head }} + APPROVED_BY: ${{ steps.prior.outputs.approved-by }} + APPROVED_FINGERPRINT: ${{ steps.approved-set.outputs.fingerprint }} + RESOLVE_OUTCOME: ${{ steps.approved-set.outcome }} + FINGERPRINT: ${{ needs.detect.outputs.fingerprint }} + PR_NUMBER: ${{ needs.detect.outputs.pr-number }} + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + run: | + set -euo pipefail + + already=false + + # Three ways to not reuse, and all of them ask the reviewer again. + # Asking twice costs attention; reusing an approval that covered + # something else costs the gate. + if [ -z "$CANDIDATE_RUN" ]; then + reason="no verified approval on record" + elif [ "$RESOLVE_OUTCOME" != "success" ] || [ -z "$APPROVED_FINGERPRINT" ]; then + reason="could not resolve what run ${CANDIDATE_RUN} approved" + elif [ "$APPROVED_FINGERPRINT" != "$FINGERPRINT" ]; then + reason="run ${CANDIDATE_RUN} approved ${APPROVED_FINGERPRINT}, this is ${FINGERPRINT}" + else + already=true + reason="run ${CANDIDATE_RUN} approved ${APPROVED_FINGERPRINT}, unchanged" + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "::notice::DRY RUN — approval reuse" + echo " this change : ${FINGERPRINT:-none}" + echo " approved at : ${CANDIDATE_HEAD:-none}" + echo " that change was : ${APPROVED_FINGERPRINT:-unresolved}" + echo " reusing : ${already} — ${reason}" + elif [ "$already" = "true" ]; then + echo "::notice::PR #${PR_NUMBER} — ${reason}; not asking ${APPROVED_BY} again" + elif [ -n "$CANDIDATE_RUN" ]; then + echo "::warning::Not reusing the approval on PR #${PR_NUMBER} — ${reason}." + fi + + echo "already-approved=$already" >> "$GITHUB_OUTPUT" + # Read from the BASE ref, never the head: a CODEOWNERS taken from the pull # request branch would let an author edit who is allowed to approve their # own breaking change. @@ -359,6 +663,7 @@ jobs: id: codeowners if: >- needs.detect.result == 'success' + && needs.detect.outputs.in-scope == 'true' && needs.detect.outputs.has-breaking-changes == 'true' && inputs.approval_reviewers == '' && inputs.create_approval_environment @@ -368,9 +673,14 @@ jobs: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} ref: ${{ needs.detect.outputs.base-ref }} + # Creating or verifying the Environment is only meaningful when an + # approval is actually coming. Out of scope this fails the job over an + # Environment nobody is going to use. - name: Verify the breaking-approval Environment is protected + id: environment if: >- needs.detect.result == 'success' + && needs.detect.outputs.in-scope == 'true' && needs.detect.outputs.has-breaking-changes == 'true' && inputs.require_protected_environment && !inputs.dry_run @@ -379,25 +689,76 @@ jobs: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} environment: breaking-approval create-if-missing: ${{ inputs.create_approval_environment && 'true' || 'false' }} - reviewers: ${{ inputs.approval_reviewers != '' && inputs.approval_reviewers || steps.codeowners.outputs.reviewers }} + # Explicit, then CODEOWNERS, then the fallback. The caller's own + # answer wins; the fallback only covers a repository that has no + # catch-all rule to read. + reviewers: >- + ${{ inputs.approval_reviewers != '' && inputs.approval_reviewers + || (steps.codeowners.outputs.reviewers != '' + && steps.codeowners.outputs.reviewers + || inputs.fallback_reviewers) }} + + # Out of scope, so no approval is coming — but the acknowledgement is + # enforced on every pull request, and an author who omits it otherwise + # sees Blocking Checks fail with nothing explaining what to add. + - name: Report the acknowledgement requirement + if: >- + needs.detect.result == 'success' + && needs.detect.outputs.in-scope != 'true' + && needs.detect.outputs.has-breaking-changes == 'true' + && !inputs.dry_run + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/notify/breaking-change-approval-reporter@develop + with: + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + pr-number: ${{ needs.detect.outputs.pr-number }} + state: acknowledgement + head-sha: ${{ needs.detect.outputs.head-sha }} + acknowledged: ${{ needs.detect.outputs.acknowledged }} + author-acknowledgement: ${{ inputs.author_acknowledgement }} + label: ${{ inputs.label }} + pending-label: '' + enabled: ${{ inputs.enable_comment && 'true' || 'false' }} + # Nothing to request when the approval already stands. Without this the + # run announces "needs a decision", adds the pending label, and then the + # verdict takes both back seconds later — a flicker in the timeline for a + # decision that was never in question. - name: Request the approval on the pull request if: >- needs.detect.result == 'success' && needs.detect.outputs.in-scope == 'true' && needs.detect.outputs.has-breaking-changes == 'true' + && steps.reuse.outputs.already-approved != 'true' && !inputs.dry_run uses: LerianStudio/github-actions-shared-workflows/src/notify/breaking-change-approval-reporter@develop with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} pr-number: ${{ needs.detect.outputs.pr-number }} state: waiting + # Already resolved by `breaking-change-commits` in `detect`, to build + # the fingerprint. Forwarded so the comment names the commits the + # gate actually judged, instead of the reporter listing them a second + # time off its own API call. + subjects: ${{ needs.detect.outputs.subjects }} + author-acknowledgement: ${{ inputs.author_acknowledgement }} + fingerprint: ${{ needs.detect.outputs.fingerprint }} + # Explicit, then CODEOWNERS, then the fallback. The caller's own + # answer wins; the fallback only covers a repository that has no + # catch-all rule to read. + reviewers: >- + ${{ inputs.approval_reviewers != '' && inputs.approval_reviewers + || (steps.codeowners.outputs.reviewers != '' + && steps.codeowners.outputs.reviewers + || inputs.fallback_reviewers) }} + actual-reviewers: ${{ steps.environment.outputs.reviewers }} reason: ${{ needs.detect.outputs.reason }} head-sha: ${{ needs.detect.outputs.head-sha }} acknowledged: ${{ needs.detect.outputs.acknowledged }} run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} close-on-rejection: ${{ inputs.close_on_rejection && 'true' || 'false' }} label: ${{ inputs.label }} + pending-label: ${{ inputs.pending_label }} enabled: ${{ inputs.enable_comment && 'true' || 'false' }} # ----------------- The gate itself ----------------- @@ -409,12 +770,32 @@ jobs: name: Approve Breaking Change runs-on: ${{ vars.GENERAL_RUNNERS || inputs.runner_type }} needs: [detect, request] + # `in-scope` is not optional here. Out of scope means no approval is + # required — #918 started preserving the caller's real detection so the + # acknowledgement could still be reported, and in doing so removed the only + # thing keeping this job out of scope. A pull request excluded by a branch + # rule, a skip label or an ignored author would wait on an Environment it + # has no business touching, and the verdict would discard the answer. if: >- needs.detect.result == 'success' + && needs.detect.outputs.in-scope == 'true' && needs.request.result == 'success' && needs.detect.outputs.has-breaking-changes == 'true' + && needs.request.outputs.already-approved != 'true' && !inputs.dry_run environment: breaking-approval + # One open request per pull request. Every push creates another pending + # deployment, and the old ones do not go away on their own — they sit in the + # reviewers' queue, each tied to a commit that is no longer the head. The + # risk is not clutter: approving a superseded one approves a revision nobody + # is merging, while the current one is still waiting. + # + # Cancelling the previous request is also what expires an approval. A push + # kills the pending job, so the next revision has to be approved again + # rather than inheriting a yes given to different code. + concurrency: + group: breaking-approval-${{ github.repository }}-${{ needs.detect.outputs.pr-number }} + cancel-in-progress: true permissions: contents: read steps: @@ -459,9 +840,15 @@ jobs: - name: Resolve the verdict id: verdict env: + GH_TOKEN: ${{ secrets.MANAGE_TOKEN || github.token }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ needs.detect.outputs.head-sha }} + HEAD_REF: ${{ needs.detect.outputs.head-ref }} + RUN_ID: ${{ github.run_id }} DETECT_RESULT: ${{ needs.detect.result }} REQUEST_RESULT: ${{ needs.request.result }} APPROVAL_RESULT: ${{ needs.approval.result }} + ALREADY_APPROVED: ${{ needs.request.outputs.already-approved }} IN_SCOPE: ${{ needs.detect.outputs.in-scope }} REASON: ${{ needs.detect.outputs.reason }} PR_NUMBER: ${{ needs.detect.outputs.pr-number }} @@ -470,6 +857,8 @@ jobs: APPROVERS: ${{ steps.decisions.outputs.approvers }} REJECTERS: ${{ steps.decisions.outputs.rejecters }} API_READABLE: ${{ steps.decisions.outputs.api-readable }} + LABEL: ${{ inputs.label }} + PENDING_LABEL: ${{ inputs.pending_label }} DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} run: | set -euo pipefail @@ -491,6 +880,92 @@ jobs: } >> "$GITHUB_OUTPUT" } + # A superseded run must not touch the pull request. When something + # cancels the pending approval, this job still runs — and without this + # guard it would overwrite the live revision's comment with "the + # approval did not complete", which is true of the dead run and + # misleading about the live one. + # + # A cancelled approval is a reason to look, not a verdict on its own. + # #912 treated every cancellation as supersession, because a run can + # be replaced without the head moving — editing the description fires + # `edited`, which queues a new run on the same commit. True, and too + # wide: a cancellation with nothing replacing it then reported success + # for a revision nobody approved, while `result=failure` sat in the + # outputs saying the opposite. + # + # Supersession has to be established. Either the head moved on (the + # comparison below), or a newer run exists for this branch to carry + # the decision. Neither means the cancellation stands unresolved, + # which does not pass. + if [ "$APPROVAL_RESULT" = "cancelled" ] && [ -n "${PR_NUMBER:-}" ]; then + # Scoped to this workflow, and to this pull request. + # `actions/runs?branch=` returns runs of every workflow on the + # branch, and the other ones from the same push start later with + # higher ids — so any cancellation would have found a "newer run" + # and passed, which is the false success this whole change exists + # to remove. Measured on a real branch: a run of Self — Repository + # Routines sat above the gate's own run. + # + # One head branch can carry more than one pull request — same + # branch, different bases — so the workflow scope alone still lets + # another pull request's run stand in for this one's. + # `.pull_requests` carries the run's own pull request and settles + # it. + # + # And only a `pull_request` run counts. Without that filter any + # newer run of this workflow on the branch qualified, including one + # somebody started by hand: dispatch a run, cancel the pending + # approval, and the verdict reads `superseded`, which exits 0 — a + # green gate with no approval, available to anybody with write + # access. Reported by @fredcamaral and reproduced; the `event` + # filter is applied twice, once as a query parameter and once in + # the selection, because the parameter alone is the server's + # promise and this decides whether a merge is allowed. + # + # `.pull_requests` comes back empty for runs from forks. Those are + # matched on the head instead, which still ties the run to this + # revision rather than to the branch at large — the empty arm used + # to accept any run with no pull request attached, which is what + # let a dispatched run through. + workflow_id=$( + gh api "repos/${REPO}/actions/runs/${RUN_ID}" --jq '.workflow_id' 2>/dev/null || true + ) + + newer=0 + if [ -n "$workflow_id" ]; then + newer=$( + gh api "repos/${REPO}/actions/workflows/${workflow_id}/runs?branch=${HEAD_REF}&event=pull_request&per_page=50" \ + --jq "[.workflow_runs[] + | select(.id > ${RUN_ID}) + | select(.event == \"pull_request\") + | select(any(.pull_requests[]?; .number == ${PR_NUMBER}) + or ((.pull_requests | length) == 0 + and .head_sha == \"${HEAD_SHA}\"))] + | length" 2>/dev/null || echo 0 + ) + fi + + if [ "${newer:-0}" -gt 0 ]; then + state=superseded + emit + echo "::notice::This run's approval was cancelled and ${newer} newer run(s) exist for ${HEAD_REF} — leaving PR #${PR_NUMBER} alone" + exit 0 + fi + + echo "::warning::This run's approval was cancelled and nothing was found replacing it. Not treating that as supersession." + fi + + if [ -n "${PR_NUMBER:-}" ] && [ -n "${HEAD_SHA:-}" ]; then + live_sha=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || true) + if [ -n "$live_sha" ] && [ "$live_sha" != "$HEAD_SHA" ]; then + state=superseded + emit + echo "::notice::Superseded — this run covers ${HEAD_SHA:0:7} but PR #${PR_NUMBER} now heads at ${live_sha:0:7}; leaving the pull request alone" + exit 0 + fi + fi + # Detection failing is not "no breaking changes" — it is "we do not # know", and the point of the gate is that an unknown blocks. if [ "$DETECT_RESULT" != "success" ] || [ "$DETECTION_SUCCEEDED" != "true" ] \ @@ -501,11 +976,51 @@ jobs: exit 0 fi + # Only now, with the guards behind us. Placed any earlier this would + # report success for a run whose `request` job failed — the preflight + # that proves the Environment is real lives there — or for a run the + # head has moved out from under. An approval that still applies is not + # a reason to skip the checks that say whether this run may speak. + if [ "$ALREADY_APPROVED" = "true" ]; then + approved=true + result=success + state=approved + emit + echo "::notice::PR #${PR_NUMBER} carries a breaking change that was already approved — nothing changed about it" + exit 0 + fi + if [ "$IN_SCOPE" != "true" ] || [ "$HAS_BREAKING_CHANGES" != "true" ]; then approved=true result=success state=none emit + + # Two labels, two different reasons to come off, and this branch + # is reached for two different reasons — so they cannot share a + # loop. `state=none` means either the break is gone or the gate + # has no say over this pull request, and only the first of those + # makes `breaking-change` untrue. + # + # Removing it out of scope was worse than cosmetic: the + # acknowledgement reporter had just applied it, because the break + # is real and the author still has to declare it. The gate then + # took it straight back off. + if [ "$DRY_RUN" != "true" ] && [ -n "${PR_NUMBER:-}" ]; then + # Nothing is waiting on a decision either way, so the state + # label ends here. + if [ -n "$PENDING_LABEL" ]; then + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$PENDING_LABEL" >/dev/null 2>&1 || true + fi + + # The fact label only when the fact stopped being true. A reword + # dropping the `!` left `breaking-change` on the pull request for + # good — the cleanup was lost in an earlier rewrite of this job. + if [ "$HAS_BREAKING_CHANGES" != "true" ] && [ -n "$LABEL" ]; then + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$LABEL" >/dev/null 2>&1 || true + fi + fi + echo "::notice::Nothing to approve on PR #${PR_NUMBER} — ${REASON}" exit 0 fi @@ -539,25 +1054,43 @@ jobs: emit - name: Report the outcome on the pull request + # `blocked` belongs here too. Detection failing fails the check, and it + # used to do so without a word on the pull request — a red check with + # no comment reads as a flake and gets re-run instead of looked at. if: >- !inputs.dry_run && (steps.verdict.outputs.state == 'approved' || steps.verdict.outputs.state == 'rejected' - || steps.verdict.outputs.state == 'unresolved') + || steps.verdict.outputs.state == 'unresolved' || steps.verdict.outputs.state == 'blocked') continue-on-error: true uses: LerianStudio/github-actions-shared-workflows/src/notify/breaking-change-approval-reporter@develop with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} pr-number: ${{ needs.detect.outputs.pr-number }} state: ${{ steps.verdict.outputs.state }} + # Only a run whose Environment approval actually happened may write a + # record naming itself. A reused approval reports `approved` with the + # gate job skipped, and a record pointing at that run would assert + # something no log can confirm — then outlive the real one. + fingerprint: ${{ needs.approval.result == 'success' && needs.detect.outputs.fingerprint || '' }} + run-id: ${{ github.run_id }} reason: ${{ needs.detect.outputs.reason }} head-sha: ${{ needs.detect.outputs.head-sha }} acknowledged: ${{ needs.detect.outputs.acknowledged }} + # Forwarded so the approved comment can keep the acknowledgement + # instructions. Blocking Checks still fails without the line, and the + # approval rewriting this comment used to take the only copy of it. + author-acknowledgement: ${{ inputs.author_acknowledgement }} run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - approvers: ${{ steps.decisions.outputs.approvers }} + # On reuse the approval job is skipped, so the current run holds no + # Environment decision and `decisions` is empty. Falling back to the + # verified historical approver keeps the name of whoever actually + # decided, instead of degrading it to "a reviewer" on the next push. + approvers: ${{ steps.decisions.outputs.approvers || needs.request.outputs.approved-by }} rejecters: ${{ steps.verdict.outputs.rejecters }} rejection-comment: ${{ steps.decisions.outputs.rejection-comment }} close-on-rejection: ${{ inputs.close_on_rejection && 'true' || 'false' }} label: ${{ inputs.label }} + pending-label: ${{ inputs.pending_label }} enabled: ${{ inputs.enable_comment && 'true' || 'false' }} # Only on a recorded rejection, never on a cancellation. Closing a pull @@ -603,7 +1136,9 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" # dry_run never fails: it reports what a real run would have done. - if [ "$DRY_RUN" = "true" ] || [ "$RESULT" = "success" ]; then + # Neither does a superseded run — its verdict is about a commit that is + # no longer the head, and the live run is the one that decides. + if [ "$DRY_RUN" = "true" ] || [ "$RESULT" = "success" ] || [ "$STATE" = "superseded" ]; then exit 0 fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3dba508..b5f52563 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1057,7 +1057,7 @@ jobs: - name: Upload GitOps tag artifact if: inputs.enable_gitops_artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: gitops-tags-${{ matrix.app.name }} path: gitops-tags/ @@ -1087,7 +1087,7 @@ jobs: - name: Upload Helm component artifact if: inputs.emit_component_artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: helm-components-${{ matrix.app.name }} path: helm-components/ diff --git a/.github/workflows/dispatch-helm.yml b/.github/workflows/dispatch-helm.yml index 50335ae9..dc3f41d5 100644 --- a/.github/workflows/dispatch-helm.yml +++ b/.github/workflows/dispatch-helm.yml @@ -193,7 +193,7 @@ jobs: # the run instead and they are reassembled here, after every build finished. - name: Download component artifacts if: inputs.components_artifact_pattern != '' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: ${{ inputs.components_artifact_pattern }} path: .helm-components diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 1ee67c5e..41178e6d 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -106,7 +106,7 @@ jobs: go-version: ${{ inputs.go_version }} - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: ${{ inputs.node_version }} @@ -142,7 +142,7 @@ jobs: - name: Upload Allure reports (GitHub artifact) if: always() && steps.plan.outputs.has_modules == 'true' && !inputs.dry_run - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: e2e-allure-reports path: reports/allure-report-* diff --git a/.github/workflows/frontend-pr-analysis.yml b/.github/workflows/frontend-pr-analysis.yml index b9fb2b0d..ee314815 100644 --- a/.github/workflows/frontend-pr-analysis.yml +++ b/.github/workflows/frontend-pr-analysis.yml @@ -441,7 +441,7 @@ jobs: - name: Upload coverage artifact if: inputs.enable_coverage - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: coverage-${{ matrix.app.name }} path: | @@ -467,7 +467,7 @@ jobs: persist-credentials: false - name: Download coverage artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: coverage-${{ matrix.app.name }} path: ${{ matrix.app.working_dir }}/coverage @@ -940,7 +940,7 @@ jobs: - name: Upload visual regression report if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: visual-regression-report-${{ matrix.app.name }} path: | diff --git a/.github/workflows/gitops-update.yml b/.github/workflows/gitops-update.yml index 91dcd8a5..1149adc0 100644 --- a/.github/workflows/gitops-update.yml +++ b/.github/workflows/gitops-update.yml @@ -428,7 +428,7 @@ jobs: - name: Download GitOps tag artifacts (pattern-based) id: download-pattern - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: ${{ steps.setup.outputs.artifact_pattern }} path: .gitops-tags @@ -439,7 +439,7 @@ jobs: - name: Fallback to legacy artifact name if: steps.download-pattern.outcome == 'failure' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: gitops-tags path: .gitops-tags @@ -1204,7 +1204,7 @@ jobs: - name: Upload sync result artifact if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v5 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v5 with: name: argocd-sync-result-${{ matrix.target.server }}-${{ steps.record.outputs.safe_env }} path: /tmp/argocd-sync-result/ @@ -1225,7 +1225,7 @@ jobs: anacleto_sync_ok: ${{ steps.aggregate.outputs.anacleto_sync_ok }} steps: - name: Download all sync result artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: argocd-sync-result-anacleto-* path: .sync-results diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index af4c42c4..b4b0cd1a 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -145,7 +145,7 @@ jobs: - name: Upload coverage artifact if: inputs.enable_coverage_comment && matrix.os == 'ubuntu-latest' && matrix.go == inputs.go_version_lint - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: coverage-report path: coverage.txt @@ -165,7 +165,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download coverage artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: coverage-report @@ -244,7 +244,7 @@ jobs: done - name: Upload build artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: binaries path: build/ diff --git a/.github/workflows/go-fuzz.yml b/.github/workflows/go-fuzz.yml index b829e110..a11b4bc4 100644 --- a/.github/workflows/go-fuzz.yml +++ b/.github/workflows/go-fuzz.yml @@ -77,10 +77,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: ${{ inputs.go_version }} @@ -104,7 +104,7 @@ jobs: - name: Upload Fuzz Artifacts if: ${{ !inputs.dry_run && steps.fuzz.outcome == 'failure' }} - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-failures path: ${{ inputs.fuzz_artifacts_path }} diff --git a/.github/workflows/go-pr-analysis.yml b/.github/workflows/go-pr-analysis.yml index a17a87e4..6f54339a 100644 --- a/.github/workflows/go-pr-analysis.yml +++ b/.github/workflows/go-pr-analysis.yml @@ -401,7 +401,7 @@ jobs: - name: Upload Gosec SARIF if: always() - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: sarif_file: gosec-${{ matrix.app.name }}.sarif category: gosec-${{ matrix.app.name }} @@ -618,7 +618,7 @@ jobs: fi - name: Upload coverage artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: coverage-${{ matrix.app.name }} path: ${{ matrix.app.working_dir }}/coverage.txt @@ -652,7 +652,7 @@ jobs: cache: true - name: Download coverage artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: coverage-${{ matrix.app.name }} path: ${{ matrix.app.working_dir }} @@ -791,7 +791,7 @@ jobs: fi - name: Upload coverage report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: coverage-report-${{ matrix.app.name }} path: | diff --git a/.github/workflows/go-pr-coverage-comment.yml b/.github/workflows/go-pr-coverage-comment.yml index 3fc76231..e997e5aa 100644 --- a/.github/workflows/go-pr-coverage-comment.yml +++ b/.github/workflows/go-pr-coverage-comment.yml @@ -64,7 +64,7 @@ jobs: has_comments: ${{ steps.post.outputs.has_comments }} steps: - name: Download coverage-report artifacts from the source run - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: github-token: ${{ secrets.token || github.token }} run-id: ${{ inputs.source_run_id }} diff --git a/.github/workflows/go-pr-validation.yml b/.github/workflows/go-pr-validation.yml index eb56a31d..39bf3da7 100644 --- a/.github/workflows/go-pr-validation.yml +++ b/.github/workflows/go-pr-validation.yml @@ -179,6 +179,15 @@ on: description: 'Close the pull request when a reviewer rejects the breaking change.' type: boolean default: true + breaking_change_fallback_reviewers: + description: >- + Who answers the breaking-change gate in a repository whose CODEOWNERS + has no catch-all `*` rule. Defaults to the + `BREAKING_APPROVAL_FALLBACK_REVIEWERS` organisation variable — see + docs/breaking-change-approval.md. + required: false + type: string + default: '' breaking_change_approval_reviewers: description: 'Who may answer the breaking-change gate, as comma-separated `team:` or `user:`. Empty takes the repository own CODEOWNERS. Used only to create the `breaking-approval` Environment when it does not exist.' type: string @@ -472,6 +481,7 @@ jobs: breaking_change_skip_labels: ${{ inputs.breaking_change_skip_labels }} breaking_change_close_on_rejection: ${{ inputs.breaking_change_close_on_rejection }} breaking_change_approval_reviewers: ${{ inputs.breaking_change_approval_reviewers }} + breaking_change_fallback_reviewers: ${{ inputs.breaking_change_fallback_reviewers }} breaking_change_create_environment: ${{ inputs.breaking_change_create_environment }} breaking_change_require_protected_environment: ${{ inputs.breaking_change_require_protected_environment }} secrets: diff --git a/.github/workflows/go-security.yml b/.github/workflows/go-security.yml index 9ca6bfbf..b8bf23c7 100644 --- a/.github/workflows/go-security.yml +++ b/.github/workflows/go-security.yml @@ -124,7 +124,7 @@ jobs: - name: Upload Gosec SARIF if: always() && inputs.upload_sarif - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: sarif_file: gosec-results.sarif category: gosec @@ -220,7 +220,7 @@ jobs: - name: Upload Trivy SARIF if: always() && inputs.upload_sarif - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: sarif_file: trivy-results.sarif category: trivy @@ -307,7 +307,7 @@ jobs: cat licenses.txt - name: Upload license report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: license-report path: licenses.txt @@ -335,7 +335,7 @@ jobs: output-file: sbom.spdx.json - name: Upload SBOM - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: sbom path: sbom.spdx.json diff --git a/.github/workflows/js-alpha-preview.yml b/.github/workflows/js-alpha-preview.yml index 9b83bca4..23d253db 100644 --- a/.github/workflows/js-alpha-preview.yml +++ b/.github/workflows/js-alpha-preview.yml @@ -189,7 +189,7 @@ jobs: # ----------------- Build ----------------- - name: Setup Node.js if: steps.resolve.outputs.has-product == 'true' - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: '20' diff --git a/.github/workflows/js-pr-validation.yml b/.github/workflows/js-pr-validation.yml index 90d8f2a4..533ccd95 100644 --- a/.github/workflows/js-pr-validation.yml +++ b/.github/workflows/js-pr-validation.yml @@ -187,6 +187,15 @@ on: description: 'Close the pull request when a reviewer rejects the breaking change.' type: boolean default: true + breaking_change_fallback_reviewers: + description: >- + Who answers the breaking-change gate in a repository whose CODEOWNERS + has no catch-all `*` rule. Defaults to the + `BREAKING_APPROVAL_FALLBACK_REVIEWERS` organisation variable — see + docs/breaking-change-approval.md. + required: false + type: string + default: '' breaking_change_approval_reviewers: description: 'Who may answer the breaking-change gate, as comma-separated `team:` or `user:`. Empty takes the repository own CODEOWNERS. Used only to create the `breaking-approval` Environment when it does not exist.' type: string @@ -592,6 +601,7 @@ jobs: breaking_change_skip_labels: ${{ inputs.breaking_change_skip_labels }} breaking_change_close_on_rejection: ${{ inputs.breaking_change_close_on_rejection }} breaking_change_approval_reviewers: ${{ inputs.breaking_change_approval_reviewers }} + breaking_change_fallback_reviewers: ${{ inputs.breaking_change_fallback_reviewers }} breaking_change_create_environment: ${{ inputs.breaking_change_create_environment }} breaking_change_require_protected_environment: ${{ inputs.breaking_change_require_protected_environment }} secrets: diff --git a/.github/workflows/js-release.yml b/.github/workflows/js-release.yml index 12a13f33..9c2f76f2 100644 --- a/.github/workflows/js-release.yml +++ b/.github/workflows/js-release.yml @@ -448,7 +448,7 @@ jobs: with: persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: ${{ inputs.node_version }} cache: 'npm' @@ -467,7 +467,7 @@ jobs: - name: Upload Playwright report if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: playwright-report path: playwright-report/ diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index efc00f50..3469a000 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -421,7 +421,7 @@ jobs: # autobuild with whatever `v1` points at today (issue #866). - name: Initialize CodeQL if: steps.codeql-config.outputs.skip != 'true' - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: ${{ inputs.codeql_languages }} queries: security-extended @@ -437,11 +437,11 @@ jobs: - name: Autobuild if: steps.codeql-config.outputs.skip != 'true' - uses: github/codeql-action/autobuild@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/autobuild@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 - name: Perform CodeQL Analysis if: steps.codeql-config.outputs.skip != 'true' - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: '/language:${{ inputs.codeql_languages }}' output: '../results' diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index b0d45a69..0e973b29 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -24,10 +24,18 @@ on: reported rather than folded into `success`: a gate that never ran and a gate that passed are not the same fact, and only the caller knows which one it is willing to merge on. - value: ${{ jobs.breaking-change-approval.outputs.result || 'skipped' }} + # The disabled case is spelled out rather than inferred from the job + # being skipped. The job now runs with approval off, so that it can + # still carry the acknowledgement feedback — but nothing was approved, + # and reporting `success` here would claim otherwise. + value: >- + ${{ !inputs.enable_breaking_change_approval && 'skipped' + || (jobs.breaking-change-approval.outputs.result || 'skipped') }} breaking_change_approved_by_reviewer: description: 'Whether a reviewer approved the breaking change through the `breaking-approval` Environment (true/false)' - value: ${{ jobs.breaking-change-approval.outputs.approved == 'true' && 'true' || 'false' }} + value: >- + ${{ inputs.enable_breaking_change_approval + && jobs.breaking-change-approval.outputs.approved == 'true' && 'true' || 'false' }} inputs: runner_type: description: 'GitHub runner type to use' @@ -150,6 +158,19 @@ on: description: 'Close the pull request when a reviewer rejects the breaking change.' type: boolean default: true + breaking_change_fallback_reviewers: + description: >- + Who answers the breaking-change gate in a repository whose CODEOWNERS + has no catch-all `*` rule — the common case, which otherwise leaves + the Environment uncreatable and the first breaking change blocked by + a gate nobody can answer. + + Defaults to the `BREAKING_APPROVAL_FALLBACK_REVIEWERS` organisation + variable, so it is set once for the organisation rather than per + repository. Same `team:` / `user:` form. + required: false + type: string + default: '' breaking_change_approval_reviewers: description: >- Who may answer the breaking-change gate, as comma-separated `team:` @@ -288,21 +309,39 @@ jobs: breaking-change-approval: name: Breaking Change Approval needs: [breaking-change-guard] + # Deliberately not gated on `enable_breaking_change_approval`. That input + # turns off the human approval, not the acknowledgement — which Blocking + # Checks still enforces. Skipping the whole job took the only comment + # carrying the line to paste with it, leaving the author a red check and + # no instructions. The flag is passed through instead. if: >- always() && github.event_name == 'pull_request' && github.event.pull_request.draft != true - && inputs.enable_breaking_change_approval uses: ./.github/workflows/breaking-change-approval.yml with: + enable_approval: ${{ inputs.enable_breaking_change_approval }} detected_breaking_changes: ${{ needs.breaking-change-guard.result == 'success' && needs.breaking-change-guard.outputs.detection-succeeded == 'true' && needs.breaking-change-guard.outputs.has-breaking-changes || 'unknown' }} + # The acknowledgement state travels with it, so one comment can speak for + # both controls. It replaces `breaking-change-comment`, removed here: two + # bot comments about the same breaking change, one headed by a ✅ for the + # author's acknowledgement and one asking for a decision, read as a + # contradiction. The ✅ was taken for the approval. detected_acknowledged: ${{ needs.breaking-change-guard.outputs.approved }} + # Passed explicitly rather than left to the gate's default. The comment + # tells the author which line to paste, and the detector decides whether + # what they pasted counts — two literals in two files, previously kept + # equal by hand. A drift would have the gate teaching a line that fails. + author_acknowledgement: 'Breaking change acknowledged: I understand that this PR intentionally introduces a breaking change and requires the next release to be a major version.' scope_rules: ${{ inputs.breaking_change_scope_rules }} additional_scope_rules: ${{ inputs.breaking_change_additional_scope_rules }} force_labels: ${{ inputs.breaking_change_force_labels }} skip_labels: ${{ inputs.breaking_change_skip_labels }} close_on_rejection: ${{ inputs.breaking_change_close_on_rejection }} approval_reviewers: ${{ inputs.breaking_change_approval_reviewers }} + # The caller's value wins; the organisation variable is the default, so + # a repository that never heard of this gate still has someone to ask. + fallback_reviewers: ${{ inputs.breaking_change_fallback_reviewers != '' && inputs.breaking_change_fallback_reviewers || vars.BREAKING_APPROVAL_FALLBACK_REVIEWERS }} create_approval_environment: ${{ inputs.breaking_change_create_environment }} require_protected_environment: ${{ inputs.breaking_change_require_protected_environment }} dry_run: ${{ inputs.dry_run }} @@ -402,11 +441,20 @@ jobs: commit-signatures-outcome: ${{ steps.commit-signatures.outcome || 'skipped' }} # ----------------- Tier 2: Advisory Checks (shared checkout, depends on Tier 1) ----------------- + # Advisory by definition, so a blocking failure must not take them with it. + # They used to be gated on `needs.blocking-checks.result == 'success'`, from + # the 2-tier fail-fast model, which saved a runner at the cost of the exact + # pull request that needed the help: a wrong title or a missing signature + # meant no size label, no auto-labels and no assignee either. Those three are + # cheap, none of them can block, and they are most useful on a pull request + # that is already in trouble. + # + # `needs:` stays for ordering only. The verdict is no longer consulted. advisory-checks: name: Advisory Checks runs-on: ${{ vars.GENERAL_RUNNERS || inputs.runner_type }} needs: [blocking-checks] - if: always() && github.event_name == 'pull_request' && needs.blocking-checks.result == 'success' && github.event.pull_request.draft != true + if: always() && github.event_name == 'pull_request' && github.event.pull_request.draft != true outputs: metadata-result: ${{ steps.collect.outputs.metadata }} size-result: ${{ steps.collect.outputs.size }} @@ -476,95 +524,6 @@ jobs: dry-run: ${{ inputs.dry_run && 'true' || 'false' }} # ----------------- Breaking Change Feedback Comment (best effort) ----------------- - breaking-change-comment: - name: Breaking Change Comment - runs-on: ${{ vars.GENERAL_RUNNERS || inputs.runner_type }} - needs: [breaking-change-guard] - if: always() && github.event_name == 'pull_request' && github.event.pull_request.draft != true && !inputs.dry_run - permissions: - pull-requests: read - issues: write - - steps: - - name: Manage breaking change guard comment - continue-on-error: true - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - GUARD_JOB_RESULT: ${{ needs.breaking-change-guard.result }} - DETECTION_SUCCEEDED: ${{ needs.breaking-change-guard.outputs.detection-succeeded }} - HAS_BREAKING_CHANGES: ${{ needs.breaking-change-guard.outputs.has-breaking-changes }} - BREAKING_CHANGE_ACKNOWLEDGED: ${{ needs.breaking-change-guard.outputs.approved }} - EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - ACKNOWLEDGEMENT: 'Breaking change acknowledged: I understand that this PR intentionally introduces a breaking change and requires the next release to be a major version.' - with: - github-token: ${{ github.token }} - script: | - const marker = ''; - const guardJobSucceeded = process.env.GUARD_JOB_RESULT === 'success'; - const detectionSucceeded = process.env.DETECTION_SUCCEEDED === 'true'; - const hasBreakingChanges = process.env.HAS_BREAKING_CHANGES === 'true'; - const acknowledged = process.env.BREAKING_CHANGE_ACKNOWLEDGED === 'true'; - const eventHeadSha = process.env.EVENT_HEAD_SHA; - const acknowledgement = process.env.ACKNOWLEDGEMENT; - - const current = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - }); - if (!eventHeadSha || current.data.head.sha !== eventHeadSha) { - core.warning(`Skipping stale breaking change comment update: event head ${eventHeadSha || 'missing'}, current head ${current.data.head.sha}.`); - return; - } - - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - per_page: 100, - }); - const owned = comments.filter(comment => - comment.user?.login === 'github-actions[bot]' - && (comment.body === marker || comment.body?.startsWith(`${marker}\n`)) - ); - const deleteComment = comment => github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: comment.id, - }); - const upsertOwned = async body => { - const params = { owner: context.repo.owner, repo: context.repo.repo, body }; - - if (owned.length > 0) { - await github.rest.issues.updateComment({ ...params, comment_id: owned[0].id }); - await Promise.all(owned.slice(1).map(deleteComment)); - return; - } - - await github.rest.issues.createComment({ - ...params, - issue_number: context.issue.number, - }); - }; - - if (!guardJobSucceeded || !detectionSucceeded) { - const body = `${marker}\n## Breaking change guard\n\n**Status:** ⚠️ Detection failed\n\nThe workflow could not determine whether this pull request contains breaking changes. Blocking Checks failed closed.`; - await upsertOwned(body); - return; - } - - if (!hasBreakingChanges) { - await Promise.all(owned.map(deleteComment)); - return; - } - - const state = acknowledged ? '✅ Author acknowledged' : '🚫 Awaiting author acknowledgement'; - const detail = acknowledged - ? 'The exact visible author acknowledgement line is present in the pull request description. It records intentional awareness and does not grant maintainer permission.' - : 'The PR author can acknowledge intentional awareness by adding this exact visible line to the pull request description:'; - const body = `${marker}\n## Breaking change guard\n\n**Status:** ${state}\n\n${detail}\n\n\`${acknowledgement}\``; - await upsertOwned(body); - # ----------------- PR Validation Summary Comment ----------------- pr-validation-report: name: PR Validation Report diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39824ad2..52d0e6d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -404,7 +404,7 @@ jobs: printf '::warning::Release skipped for %s — %s could not be auto-merged into %s (PR: %s). Merge it and push again to release.\n' "$APP_NAME" "$BACKMERGE_SOURCE" "$REF_NAME" "$PR_URL" - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: '20' @@ -609,7 +609,7 @@ jobs: - name: Upload publish status if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: publish-status-${{ matrix.app.name }} path: ${{ runner.temp }}/publish-status/ @@ -627,7 +627,7 @@ jobs: release_git_tag: ${{ steps.aggregate.outputs.git_tag }} steps: - name: Download all publish status artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: publish-status-* path: /tmp/publish-status diff --git a/.github/workflows/self-pr-validation.yml b/.github/workflows/self-pr-validation.yml index 11fbf267..c51d7198 100644 --- a/.github/workflows/self-pr-validation.yml +++ b/.github/workflows/self-pr-validation.yml @@ -76,12 +76,35 @@ jobs: with: persist-credentials: false + # test-workflow.py imports yaml. Without this the job depends on the + # runner image happening to ship PyYAML, and `runner_type` is + # configurable — the same reasoning as the workflow-secrets job below, + # which was already doing this. + - name: Install dependencies + run: | + if ! python3 -c "import yaml" 2>/dev/null; then + sudo apt-get install -y --no-install-recommends python3-yaml + fi + - name: Run breaking-change detector matrix run: bash src/validate/breaking-change-guard/test.sh - name: Run breaking-change workflow integration tests run: python3 src/validate/breaking-change-guard/test-workflow.py + # The composites the gate is built from. Each ships a suite and none of + # them were invoked by any workflow — 76 cases, including the + # output-forgery and fingerprint ones, that a change could silently walk + # past. A suite nobody runs is documentation. + - name: Run gate-scope resolver tests + run: bash src/validate/pr-gate-scope/test.sh + + - name: Run breaking-change commit-list tests + run: bash src/validate/breaking-change-commits/test.sh + + - name: Run CODEOWNERS reviewer tests + run: bash src/validate/codeowners-reviewers/test.sh + # ----------------- GitOps Chart Tooling Tests ----------------- gitops-chart-tooling-tests: name: GitOps Chart Tooling Tests diff --git a/.github/workflows/spa-deploy.yml b/.github/workflows/spa-deploy.yml index a5204d41..132485ab 100644 --- a/.github/workflows/spa-deploy.yml +++ b/.github/workflows/spa-deploy.yml @@ -127,7 +127,7 @@ jobs: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ inputs.node_version }} cache: npm diff --git a/.github/workflows/typescript-build.yml b/.github/workflows/typescript-build.yml index efc826d5..d515ba21 100644 --- a/.github/workflows/typescript-build.yml +++ b/.github/workflows/typescript-build.yml @@ -370,7 +370,7 @@ jobs: - name: Upload GitOps tag artifact if: inputs.enable_gitops_artifacts && !inputs.dry_run - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: gitops-tags-${{ matrix.app.name }} path: gitops-tags/ diff --git a/.github/workflows/typescript-ci.yml b/.github/workflows/typescript-ci.yml index 749d767c..8a56225f 100644 --- a/.github/workflows/typescript-ci.yml +++ b/.github/workflows/typescript-ci.yml @@ -130,7 +130,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ inputs.node_version_primary }} cache: ${{ inputs.package_manager }} @@ -169,7 +169,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js ${{ matrix.node }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node }} cache: ${{ inputs.package_manager }} @@ -196,7 +196,7 @@ jobs: - name: Upload coverage artifact if: inputs.enable_coverage && matrix.node == inputs.node_version_primary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-report path: coverage/ @@ -224,7 +224,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ inputs.node_version_primary }} cache: ${{ inputs.package_manager }} @@ -251,12 +251,12 @@ jobs: continue-on-error: true - name: Run CodeQL Analysis - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: javascript-typescript - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 docs: name: Generate Documentation @@ -272,7 +272,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ inputs.node_version_primary }} cache: ${{ inputs.package_manager }} @@ -291,7 +291,7 @@ jobs: run: ${{ inputs.docs_cmd }} - name: Upload documentation artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: documentation path: docs/ diff --git a/.github/workflows/typescript-release.yml b/.github/workflows/typescript-release.yml index 42a5b9be..29b2117d 100644 --- a/.github/workflows/typescript-release.yml +++ b/.github/workflows/typescript-release.yml @@ -218,7 +218,7 @@ jobs: git_commit_gpgsign: true - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version: ${{ inputs.node_version }} diff --git a/docs/breaking-change-approval.md b/docs/breaking-change-approval.md index cd86d24b..1f2d2cb6 100644 --- a/docs/breaking-change-approval.md +++ b/docs/breaking-change-approval.md @@ -52,7 +52,8 @@ own: | Composite | Responsibility | |---|---| | [`pr-gate-scope`](../src/validate/pr-gate-scope/README.md) | Is this pull request in scope, and why — base → head rules, label overrides, author vetoes | -| [`breaking-change-guard`](../src/validate/breaking-change-guard/README.md) | Does the branch carry a breaking-change commit | +| [`breaking-change-guard`](../src/validate/breaking-change-guard/README.md) | The canonical detector, over the real commit range. Runs in `pr-validation.yml`, whose verdict this gate is handed | +| [`breaking-change-commits`](../src/validate/breaking-change-commits/README.md) | Which commits carry the break, and a digest of the set | | [`codeowners-reviewers`](../src/validate/codeowners-reviewers/README.md) | Who owns this repository, per its CODEOWNERS | | [`environment-protection`](../src/validate/environment-protection/README.md) | Does `breaking-approval` exist with required reviewers — creating it when it does not | | [`environment-approvals`](../src/validate/environment-approvals/README.md) | Who approved or rejected, and could we read that at all | @@ -83,6 +84,40 @@ guard job already ran the same composite over the same commits, so a second full-history checkout would buy nothing. The acknowledgement state is shown to the reviewer as context. +## Who answers the gate + +Three sources, in order. The first that yields anybody wins: + +1. **`approval_reviewers`** — the caller named them explicitly. +2. **CODEOWNERS**, read from the pull request's **base** ref. Reading the head + would let an author edit who is allowed to approve their own change. +3. **`fallback_reviewers`** — through `pr-validation`, the + `BREAKING_APPROVAL_FALLBACK_REVIEWERS` organisation variable. + +The third exists because of what the second actually finds. +`codeowners-reviewers` reads the **catch-all `*` rule**, which is the right +question for a repository-wide gate — but most CODEOWNERS files do not have +one. Measured across the consuming repositories before this was added: four of +five carried rules only for specific paths (`/.github/`, `/.releaserc.yml`), +and no `*`. + +In those four the gate resolved no reviewer at all. An Environment cannot be +created without one — `environment-protection` refuses, because an Environment +created without required reviewers is a gate that approves itself — so the +preflight failed, and the first breaking change in each of those repositories +would have been blocked by a gate nobody could answer. + +Set the organisation variable once and that stops being a per-repository +problem: + +``` +BREAKING_APPROVAL_FALLBACK_REVIEWERS = team:your-devops-team +``` + +Leaving it unset keeps the old behaviour: no reviewer, no Environment, and a +refusal rather than an invented approver. That is deliberate — a gate that +quietly picks someone to ask is worse than one that says it cannot run. + ## Setup ### The Environment creates itself, owned by CODEOWNERS @@ -176,10 +211,19 @@ jobs: MANAGE_TOKEN: ${{ secrets.MANAGE_TOKEN }} ``` -Called this way the workflow detects on its own, with two checkouts: a plain one -for the `origin/` remote-tracking refs, then `refs/pull//head` with -`clean: false` to move `HEAD` onto the pull request. Nothing from the tree is -executed — only commit messages are read — so fork code never runs. +Called this way the workflow detects on its own, through the API — it checks +out nothing at all. + +It used to use two checkouts and read Git directly. CodeQL flagged that as +`actions/untrusted-checkout`, and the alert was fair: a non-privileged workflow +was placing a contributor's tree on disk next to `MANAGE_TOKEN`. Nothing ever +executed it, but nothing needed it either, and the tree, the token exposure and +the alert all went away together. + +The acknowledgement is **not** evaluated on this path — the detector that reads +the pull request description runs in `pr-validation.yml`. The comment omits that +row rather than reporting it missing, because telling an author to add a line no +check is asking for is worse than saying nothing. ### Testing before adoption @@ -249,6 +293,122 @@ them match: REST returns `dependabot[bot]`, GraphQL returns `dependabot`, and `gh pr view --json author` — what this job reads — returns `app/dependabot`. Both affixes are stripped before comparing. +## An approval survives a push that did not change the breaking change + +The gate asks about a specific breaking change, not about a pull request. So it +remembers which one was approved, and only asks again when that changes. + +| The author… | Asked again? | +|---|---| +| fixes a typo the review pointed out | no | +| adds an ordinary commit | no | +| rewords the breaking commit | **yes** — it is different text now | +| adds another breaking commit | **yes** | + +This matters more than convenience, because of how the review gate is wired: +CodeRabbit only runs once every check has passed, which includes this one. So +without the memory, every iteration of *approve → review → fix → push* would +need a fresh human approval to get the next review — and the reviewer would be +answering the identical question each time. A question asked five times gets a +reflex answer by the third. + +**How it is remembered.** `breaking-change-commits` digests the SHAs of the +breaking commits. When an approval lands, the comment records that digest +together with the run that carried the approval: + +```html + +``` + +Every later run rebuilds the digest and looks for its record. A match skips the +Environment gate entirely; anything else asks again. Records accumulate, so +returning to a previously approved set is still approved. + +Built from SHAs, so a reword counts as a change — the approval was given to the +text that was read, and the subject is what the release parser acts on. + +### The record is checked, not believed + +A comment is not evidence. Anyone who can read the repository can comment on a +pull request, and the fingerprint is computable from public data: the commit +SHAs are on the pull request and the digest is defined in this repository. A +record on its own is therefore something a stranger can write. + +So three things must hold before an approval is reused: + +1. the record sits in a comment from an **owner or member** of the + organisation, or from `github-actions[bot]` — the identity the reporter + writes under when no `MANAGE_TOKEN` is passed, and one nobody but Actions + can author as +2. the **run it names really was approved** for the `breaking-approval` + Environment, read from the Environment's own approval log, and that run is + on this head branch +3. the **set that run approved** is this set — resolved from the run's own + `head_sha`, never from the fingerprint written in the record + +Every record for the fingerprint is tried, not only the first: runs are deleted +on the repository's retention schedule, so the oldest record goes unverifiable +while a newer one is still good. + +The third was missing until @fredcamaral demonstrated what it cost. The first +two establish that *an* approval happened on this branch; neither says what it +approved. Since the fingerprint is derived from public commit SHAs it is +computable before the commit exists, so a member could post a record carrying +`fp(A,B)` pointing at the run that approved only `A`, then push the breaking +commit `B` — and the gate would read "already approved" and return success with +no reviewer involved. Resolving the approved set from the run's own head closes +it: the record now says only *which run to ask*, and the run answers. + +When that resolution fails — a deleted run, an unreadable comparison, a pull +request past the commit cap — the approval is not reused. Not knowing is not +evidence that it still applies. + +Only a run whose Environment approval actually happened writes a record. A +reused approval reports `approved` with the gate job skipped, and a record +naming *that* run would assert something no log can confirm — then outlive the +real one. + +The second is what carries the weight. Writing the text is easy; producing a run +whose Environment approval actually happened is not. + +Verifying needs `actions: read`. Where that is unavailable the check cannot be +made, so the approval is **not** reused and the reviewer is asked again — asking +twice costs attention, trusting an unverified record costs the gate. + +**The records are never rewritten blind.** If the comment cannot be read back, +the reporter fails rather than replacing it, because a rewrite without the +records would erase approvals already given and send reviewers questions they +had already answered. + +## One open request, and approvals expire on a push + +Every push queues another pending deployment, and GitHub does not retire the +previous one. Left alone, a reviewer's queue fills with requests tied to commits +that are no longer the head — and approving one of those approves a revision +nobody is merging, while the live one still waits. + +The approval job is therefore under a `concurrency` group keyed to the pull +request, with `cancel-in-progress`. A push cancels the pending request and +replaces it with one for the new head. + +That is what retires a *pending* request. An approval that already landed is a +different matter — see above: it is remembered against the change it approved, +and only a change to that change asks again. + +**A cancelled run never touches the pull request.** The verdict job still runs +when its approval is cancelled, and without a guard it would overwrite the live +revision's comment with *"the approval did not complete"* — true of the dead run, +misleading about the live one. + +Two signals, and the order matters. A **cancelled approval** is the broad one: +nothing cancels it except the concurrency group replacing the run or a human +doing it by hand, and neither is a decision to report. A **changed head** is the +narrower one, for anything that slipped past the first. + +The cancelled check cannot be dropped in favour of the head comparison, because +a run can be replaced without the head moving at all: editing the description +fires `edited`, which queues a new run on the very same commit. + ## Fails closed Detection failing is not "no breaking changes" — it is "we do not know", and an @@ -298,8 +458,24 @@ ends as the record of who decided what: > 👉 **[Review the pending deployment](#)** — *Review deployments* → > `breaking-approval`. -The `breaking-change` label is applied while it waits and removed once resolved — -cosmetic, and a repository that never declared it still gets its gate. +Two labels, and they expire at different times. `breaking-change` states a fact +about the commits and **survives the approval** — the break does not stop being a +break because somebody agreed to it, and a search for that label months later +should still find this pull request. It comes off only when the breaking commits +are gone. `awaiting-breaking-approval` states that a decision is pending, and +comes off the moment one is made. + +Both are cosmetic: a repository that never declared them still gets its gate. +Declare them with [`labels-sync`](labels-sync.md), or pass `label: ''` / +`pending_label: ''` to turn them off. + +> **Behaviour change.** `label` used to be removed once the change was approved +> or rejected. If you set it as a temporary waiting marker, move that value to +> `pending_label` and set `label: ''` — otherwise the marker now stays on the +> pull request after the decision. +> +> Callers on the default need nothing: `breaking-change` staying put is the +> point, and `awaiting-breaking-approval` takes over the transient role. ## Inputs @@ -316,9 +492,12 @@ cosmetic, and a repository that never declared it still gets its gate. | `author_acknowledgement` | string | the `pr-validation` line | Shown to the reviewer as context. Never substitutes for the approval. | | `close_on_rejection` | boolean | `true` | Close the pull request when a reviewer rejects. | | `approval_reviewers` | string | *(empty)* | Who may answer the gate, as comma-separated `team:` or `user:`. Empty takes the repository's CODEOWNERS. Used only to create the Environment when it does not exist. | +| `fallback_reviewers` | string | *(empty)* | Who answers when `approval_reviewers` is empty **and** CODEOWNERS yields nobody. Through `pr-validation` this defaults to the `BREAKING_APPROVAL_FALLBACK_REVIEWERS` organisation variable. Empty refuses rather than inventing a reviewer. | | `create_approval_environment` | boolean | `true` | Create `breaking-approval` when missing. Needs a token with repository administration rights. | | `require_protected_environment` | boolean | `true` | Refuse to gate when `breaking-approval` exists without required reviewers. Leave on. | -| `label` | string | `breaking-change` | Applied while waiting, removed once resolved. Cosmetic. | +| `label` | string | `breaking-change` | States that the change is breaking. A fact, so it survives the decision and comes off only when the breaking commits are gone. Cosmetic. | +| `pending_label` | string | `awaiting-breaking-approval` | States that a decision is pending. A state, so it comes off once approved or rejected. Cosmetic. | +| `enable_approval` | boolean | `true` | Hold breaking changes at the Environment. `false` keeps the gate reporting and labelling but never asks for a decision — the author acknowledgement is a separate control the caller still enforces, and the comment carrying the line to paste comes from here. Not the same as not calling the workflow. | | `enable_comment` | boolean | `true` | Post and keep the comment updated. | | `dry_run` | boolean | `false` | Report what would be gated without commenting, labelling, requesting an approval, or failing. | | `runner_type` | string | `blacksmith-4vcpu-ubuntu-2404` | Runner label. Overridden by `vars.GENERAL_RUNNERS`. | @@ -330,7 +509,7 @@ Whitespace around commas is tolerated. | Output | Description | |--------|-------------| | `in_scope` | Whether the pull request was gated at all (`true`/`false`). | -| `has_breaking_changes` | Whether a breaking commit was found (`true`/`false`). Always `false` out of scope — nothing was scanned. | +| `has_breaking_changes` | Whether a breaking commit was found (`true`/`false`). Reports the detection out of scope too — not gating a pull request is not the same as it carrying no break. Gate on `in_scope` alongside it for the earlier reading. | | `approved` | Whether a reviewer approved through the Environment (`true`/`false`). | | `result` | Gate verdict (`success`/`failure`). | @@ -339,6 +518,63 @@ Through the umbrellas these surface as `breaking_change_approval_result` `skipped` is reported rather than folded into `success`: a gate that never ran and a gate that passed are not the same fact. +## Behaviour changes since v1.84 + +Two behaviours changed after the gate's first release in `v1.84.0`. Both were +deliberate, and neither is classified as breaking — for different reasons, +which are worth keeping apart: + +- **The `label` input.** None of the umbrellas — `pr-validation`, + `go-pr-validation`, `js-pr-validation` — expose an input for it; they pass + `force_labels` and `skip_labels` and nothing else. A caller on that path + never had a custom label to lose. Only a standalone caller that set one + inside the release window can notice. +- **The `has_breaking_changes` output.** This one is about reading, not + configuring, so the argument above does not reach it. What does: the + umbrellas do not surface *this* output. Their `has_breaking_changes` comes + from the `breaking-change-guard` job, which is unchanged; the only gate + outputs they publish are `breaking_change_approval_result` and + `breaking_change_approved_by_reviewer`. So again only a standalone caller + can see it — one that branches on the output, whether or not it sets a + label. + +Written down anyway — an unannounced behaviour change is worse than a +documented one, whatever the version number says. + +**`label` survives the decision.** It used to come off on approval. It is a +fact about the change — approving a break does not make it stop being one, and +somebody filtering for `breaking-change` three months from now wants this pull +request in the results. It is removed only when the breaking commits are gone. + +The waiting marker moved to its own input: + +```yaml +# before — one label doing both jobs +with: + label: waiting-for-review + +# after — the fact and the state, separately +with: + label: '' # or breaking-change, the default + pending_label: waiting-for-review # comes off on approval or rejection +``` + +A caller that left `label` at its default needs no change. + +**`has_breaking_changes` reports detection out of scope.** It used to be forced +to `false` whenever the gate did not apply, which conflated "not gated here" +with "carries no break". It now passes through what the caller detected. To get +the old reading, gate on `in_scope`: + +```yaml +if: >- + needs.gate.outputs.in_scope == 'true' + && needs.gate.outputs.has_breaking_changes == 'true' +``` + +Nothing supplied is still `false` — the gate does not scan out of scope, so +there is no answer to report. + ## Secrets | Secret | Required | Description | @@ -384,8 +620,13 @@ correct fix, since the commit message is what the release reads. ## Local test +All five run in self CI under **Breaking Change Guard Detector Tests**. Three +of them did not until recently — 76 cases, including the output-forgery and +fingerprint ones, that a change could walk past without anything going red. + ```bash bash src/validate/pr-gate-scope/test.sh +bash src/validate/breaking-change-commits/test.sh bash src/validate/codeowners-reviewers/test.sh bash src/validate/breaking-change-guard/test.sh python3 src/validate/breaking-change-guard/test-workflow.py @@ -395,6 +636,11 @@ python3 src/validate/breaking-change-guard/test-workflow.py their globs, the merge semantics of `additional-scope-rules`, both label overrides and their precedence, the three bot spellings, and every input that must fail closed. No network, no repository fixture. +- **`breaking-change-commits/test.sh`** — 22 cases: both breaking forms, the + lookalikes that must not count, parity with the detector on case and on scopes + containing parens, the fingerprint holding still for an ordinary commit while + changing for a breaking one, and the output-injection attack a commit subject + can otherwise mount. - **`codeowners-reviewers/test.sh`** — 22 cases: both owner forms, comments, last-rule-wins, Enterprise section headers, the three file locations, the reviewer cap, and every shape that yields nothing. diff --git a/docs/go-pr-validation.md b/docs/go-pr-validation.md index bd1df4ba..1bd344d8 100644 --- a/docs/go-pr-validation.md +++ b/docs/go-pr-validation.md @@ -71,6 +71,7 @@ This distinguishes the two skips that used to look identical: a pipeline the cal | `breaking_change_close_on_rejection` | Close the PR when a reviewer rejects the breaking change | boolean | `true` | | `breaking_change_require_protected_environment` | Refuse to gate when `breaking-approval` has no required reviewers | boolean | `true` | | `breaking_change_approval_reviewers` | Who may answer the gate, as `team:` or `user:`. Empty takes the repository's CODEOWNERS | string | *(empty)* | +| `breaking_change_fallback_reviewers` | Who answers when the above is empty **and** CODEOWNERS yields nobody. Defaults to the `BREAKING_APPROVAL_FALLBACK_REVIEWERS` organisation variable | string | *(empty)* | | `breaking_change_create_environment` | Create `breaking-approval` when missing. Needs a `MANAGE_TOKEN` with administration rights | boolean | `true` | | `go_version` | Go version | string | `1.23` | | `golangci_lint_version` | GolangCI-Lint version | string | `v1.62.2` | diff --git a/docs/js-pr-validation.md b/docs/js-pr-validation.md index f21bb6f3..7a24e401 100644 --- a/docs/js-pr-validation.md +++ b/docs/js-pr-validation.md @@ -61,6 +61,7 @@ The `Go Analysis` and `Lib Version` stubs are unaffected: they are not flag-guar | `breaking_change_close_on_rejection` | Close the PR when a reviewer rejects the breaking change | boolean | `true` | | `breaking_change_require_protected_environment` | Refuse to gate when `breaking-approval` has no required reviewers | boolean | `true` | | `breaking_change_approval_reviewers` | Who may answer the gate, as `team:` or `user:`. Empty takes the repository's CODEOWNERS | string | *(empty)* | +| `breaking_change_fallback_reviewers` | Who answers when the above is empty **and** CODEOWNERS yields nobody. Defaults to the `BREAKING_APPROVAL_FALLBACK_REVIEWERS` organisation variable | string | *(empty)* | | `breaking_change_create_environment` | Create `breaking-approval` when missing. Needs a `MANAGE_TOKEN` with administration rights | boolean | `true` | | `node_version` | Node.js version | string | `22` | | `package_manager` | Package manager (`npm`, `yarn`, `pnpm`) | string | `npm` | diff --git a/docs/pr-validation.md b/docs/pr-validation.md index 99703145..d7fcdf75 100644 --- a/docs/pr-validation.md +++ b/docs/pr-validation.md @@ -182,6 +182,7 @@ jobs: | `breaking_change_close_on_rejection` | boolean | `true` | Close the PR when a reviewer rejects the breaking change | | `breaking_change_require_protected_environment` | boolean | `true` | Refuse to gate when `breaking-approval` has no required reviewers, instead of silently passing | | `breaking_change_approval_reviewers` | string | *(empty)* | Who may answer the gate, as comma-separated `team:` or `user:`. Empty takes the repository's CODEOWNERS | +| `breaking_change_fallback_reviewers` | string | *(empty)* | Who answers when the above is empty **and** CODEOWNERS yields nobody — the common case, since most CODEOWNERS have no catch-all `*` rule. Defaults to the `BREAKING_APPROVAL_FALLBACK_REVIEWERS` organisation variable | | `breaking_change_create_environment` | boolean | `true` | Create `breaking-approval` when missing. Needs a `MANAGE_TOKEN` with repository administration rights | The breaking change guard has no enable input, target-branch input, acknowledgement input, or opt-out. It applies to every caller and every PR target branch. `dry_run: true` remains a global preview mode without guard enforcement; it is not a guard-specific opt-out and does not change normal `dry_run: false` operation. Existing callers must migrate their `pull_request.types` list to include both `edited` and `ready_for_review`; otherwise body edits and draft-to-ready transitions do not rerun validation. @@ -210,14 +211,14 @@ The guard is mandatory on every target branch. There is no opt-out. The action s ### Feedback comments -For every non-draft live run, including detector failure or cancellation, the workflow uses `github.token` to manage one best-effort comment owned only when the author is exactly `github-actions[bot]` and the body starts with the exact marker line: +The guard posts nothing of its own. Everything an author needs to read about a +breaking change — including the exact acknowledgement line to paste — lives in +the single comment described under [One comment, not two](#one-comment-not-two), +written by the breaking-change gate. -- A breaking change creates or updates the comment with `Author acknowledged` or `Awaiting author acknowledgement` state and the exact acknowledgement. -- Detection or guard-job failure replaces stale acknowledgement with one `Detection failed` comment and deletes duplicate owned comments. -- Duplicate owned marker comments are deleted. -- When breaking changes disappear, all owned guard comments are deleted. -- Before any comment mutation, the workflow fetches the current PR and stops if its current head SHA differs from the event head SHA. -- Fork token write restrictions and comment API failures never affect enforcement. The workflow never falls back to another bot identity. +Enforcement never depends on that comment. A comment API failure leaves the +verdict exactly as it was — passing or failing on the acknowledgement alone, +which is what `Blocking Checks` decides either way. ### Default PR Title Types @@ -225,6 +226,31 @@ For every non-draft live run, including detector failure or cancellation, the wo feat fix docs style refactor perf test chore ci build revert ``` +### Advisory checks always run + +PR Size, Auto Labels and PR Metadata are advisory — none of them can block a +merge. They are therefore **not** gated on the blocking tier passing. + +They used to be. The 2-tier fail-fast model skipped them whenever a blocking +check failed, which saved a runner at the cost of the pull request that most +needed the help: a wrong title or a missing signature also meant no size label, +no auto-labels and no assignee. A pull request already in trouble is exactly +where that information is worth having. + +They still skip on drafts, and on anything that is not a `pull_request` event. + +## One comment, not two + +The acknowledgement guard used to post its own comment. It no longer does: the +breaking-change gate's comment carries both controls, because two bot comments +about the same breaking change read as a contradiction — one headed by a ✅ for +the author's acknowledgement, one asking for a decision. The ✅ gets taken for +the approval. + +The gate now comments wherever a breaking change is found, including on branches +outside its approval scope. There it reports only the acknowledgement and the +exact line to paste, since that is the only thing being enforced. + ## Breaking change approval The author-acknowledgement guard above asks the author to declare the break. The @@ -271,9 +297,8 @@ Full detail: [`breaking-change-approval.md`](breaking-change-approval.md). |-----|------|------------|-----------| | `breaking-change-guard` | mandatory detection | `breaking-change-guard@v1` | always, including drafts | | `blocking-checks` | 1 (fail-fast) | guard enforcement, `pr-source-branch`, `pr-title`, `pr-description`, `pr-commit-signatures` | always; drafts run guard enforcement only | -| `advisory-checks` | 2 (informational) | `pr-metadata`, `pr-size`, `pr-labels` | non-draft, blocking-checks passed | +| `advisory-checks` | 2 (informational) | `pr-metadata`, `pr-size`, `pr-labels` | non-draft, regardless of the blocking result | | `pr-checks-summary` | — | `pr-checks-summary` | always (writes to step summary) | -| `breaking-change-comment` | — | `actions/github-script` | every non-draft live run, including detector failure or cancellation | | `pr-validation-report` | — | `pr-validation-reporter` | non-draft (upserts single PR comment) | | `notify` | — | `slack-notify.yml` | non-draft, `!dry_run` | @@ -282,7 +307,7 @@ Full detail: [`breaking-change-approval.md`](breaking-change-approval.md). - Always enforce both the guard job state and its normalized output, including on drafts - Skip existing source branch, title, description, commit signature, and collector steps on drafts; their non-draft behavior is unchanged - On non-drafts, every validation runs even if one fails (`continue-on-error` per step) -- Job fails if the guard job, normalized guard output, or **any** existing blocking check fails, preventing advisory checks from running +- Job fails if the guard job, normalized guard output, or **any** existing blocking check fails. Advisory checks run either way - In dry-run mode, guard state is logged but does not fail `Blocking Checks` ### Fail-closed detection @@ -325,7 +350,9 @@ The step runs with read-only permissions and emits no commit metadata beyond wha ### Advisory checks (Tier 2) - Share a single `checkout` with `fetch-depth: 0` -- Only run if all blocking checks passed +- Run on every non-draft pull request, **regardless of the blocking result** — a + pull request that failed a blocking check still gets its size label, its + auto-labels and its assignee - Never block merge — informational only ## Dry Run Behavior diff --git a/src/notify/breaking-change-approval-reporter/README.md b/src/notify/breaking-change-approval-reporter/README.md index 516ff460..45cd2152 100644 --- a/src/notify/breaking-change-approval-reporter/README.md +++ b/src/notify/breaking-change-approval-reporter/README.md @@ -20,13 +20,88 @@ comment, and it ends as the record of who decided what — nothing is deleted. | `state` | What the comment says | |---|---| | `waiting` | A breaking change was found; here is the link to the pending deployment, and what approving or rejecting will do | +| `acknowledgement` | A breaking change was found on a branch outside the approval scope — no decision is needed, only the author's acknowledgement, with the exact line to paste | | `approved` | Approved by whom, and that the next release will be a major version | | `rejected` | Rejected by whom, the reason they gave, and what to do next | | `unresolved` | The approval ended without a recorded decision — open the run | +| `blocked` | The gate could not reach a verdict at all — detection or the request stage failed, and the check fails closed. Deliberately neutral about which: `blocked` is reached from both. The only state that does **not** apply the fact label, because nothing was established — labels already on the pull request are left alone | Any other value fails the step. A typo in `state` must not produce a confidently worded comment about the wrong thing. +## The button is a badge, and the alt text is the fallback + +Markdown has no button, so the call to action is a [shields.io](https://shields.io) +badge wrapped in the link. That is a third party in the path of a release +control, which is worth stating plainly — so the alt text carries the entire +call to action: + +```markdown +[![Review the pending deployment](https://img.shields.io/badge/...)](run-url) +``` + +A reader whose network blocks the image, or a day when shields is down, sees a +described link rather than a broken rectangle. Setting `badge-url` to empty +falls back to a plain `### ➡️ [Review the pending deployment](…)` heading, with +no external dependency at all. + +## It reads as information, not as a block + +A breaking change is often deliberate, and a comment that opens with a siren +teaches people to scroll past it. The waiting comment states what was found, +links straight to the decision, and names who can make it: + +> ### Breaking change — needs a decision +> +> [`f8d5506`](https://github.com/LerianStudio/example/commit/f8d5506) carries a +> breaking change, so merging makes the next release a **major** version. That is +> often deliberate — this just asks someone to confirm it. +> +> ### ➡️ [Approve or reject](https://github.com/LerianStudio/example/actions/runs/1) +> +> Who can: +> [@LerianStudio/g_github_devops](https://github.com/orgs/LerianStudio/teams/g_github_devops) + +Everything else — the scope rule, the acknowledgement state, how to undo the +break — is one `` line at the bottom, present for whoever needs it and out +of the way of whoever does not. + +**Reviewers are rendered as links, never as @mentions.** The pending deployment +already notifies exactly these people; a mention would ping the whole team a +second time for the same thing. + +**The undo example uses this pull request's own commits.** It shows the real +subjects in a `diff` block instead of a made-up `feat(scope)!: subject`. Pass +`subjects` and it uses that set — the caller that ran `breaking-change-commits` +to build the fingerprint already has it, and reading the commits a second time +costs an API call and lets this comment disagree with the set the gate judged. +Leave it empty and the composite reads them itself, so a standalone caller +needs no resolver. Only the `!` form is matched either way, because that is the +form the example illustrates — it is a view, never a verdict. The canonical +rule lives in +[`breaking-change-guard`](../../validate/breaking-change-guard/README.md); when +the two disagree the detector wins and this falls back to generic wording. + +**`actual-reviewers` wins over `reviewers`.** An Environment that already existed +may be gated by different people than the caller configured, and naming the wrong +ones is worse than naming none — with neither available, the line is omitted +rather than guessed. + +## Two labels, because they expire at different times + +`breaking-change` is a **fact about the commits**. Approving does not make the +break go away, and somebody filtering for that label three months from now wants +this pull request in the results. It is applied in every state this composite +handles, and only the caller removes it — at the one moment the fact stops being +true, when the breaking commits are gone. + +`awaiting-breaking-approval` is a **state**. It goes on while a decision is +pending and comes off the moment one is made, either way. + +Collapsing the two is tempting and wrong: removing `breaking-change` on approval +erases the record exactly when it starts to matter, because now the break is +both real and agreed. + ## What it does not do It never closes, merges or approves anything. `close-on-rejection` only changes @@ -41,16 +116,26 @@ side effect in the caller is what makes this safe to run with | `github-token` | Token used to comment and label. Needs `pull-requests: write`. | Yes | — | | `pr-number` | Pull request to report on. | Yes | — | | `repository` | Repository owning it, as `owner/name`. | No | `${{ github.repository }}` | -| `state` | `waiting`, `approved`, `rejected` or `unresolved`. | Yes | — | +| `state` | `waiting`, `acknowledgement`, `approved`, `rejected`, `unresolved` or `blocked`. | Yes | — | | `reason` | Why the pull request is gated, from `pr-gate-scope`. | No | `''` | | `head-sha` | Head commit the report refers to. | No | `''` | | `acknowledged` | Whether the author acknowledgement line is present. | No | `false` | +| `author-acknowledgement` | The exact line the author must add when it is missing. | No | `''` | +| `subjects` | Breaking commit subjects, newline-separated, as resolved by `breaking-change-commits`. Empty and the composite reads them itself. | No | `''` | +| `fingerprint` | Digest of the breaking-change commit set. Recorded when the state is `approved`, with the run that carried the approval. | No | `''` | +| `run-id` | Run recorded alongside the fingerprint, so a later run can verify the approval against the Environment log. | No | `${{ github.run_id }}` | | `run-url` | Link to the run carrying the pending deployment. | No | `''` | +| `reviewers` | Who can answer, as a `team:` / `user:` list. | No | `''` | +| `actual-reviewers` | Reviewers read from the Environment itself. Preferred over `reviewers` when present. | No | `''` | +| `server-url` | Base URL used to build commit links. | No | `${{ github.server_url }}` | +| `badge-url` | Image used as the call-to-action button. Empty falls back to a plain heading link. | No | `https://img.shields.io/badge/%E2%9E%A1%EF%B8%8F%20Review%20pending%20deployment-1f6feb?style=for-the-badge` | +| `docs-url` | Link to the explanation of how the gate works. | No | `https://github.com/LerianStudio/github-actions-shared-workflows/blob/main/docs/breaking-change-approval.md` | | `approvers` | Comma-separated logins that approved. | No | `''` | | `rejecters` | Comma-separated logins that rejected. | No | `''` | | `rejection-comment` | Comment left with the rejection. | No | `''` | | `close-on-rejection` | Whether the caller closes on rejection. Wording only. | No | `true` | -| `label` | Applied while waiting, removed once resolved. Cosmetic. | No | `''` | +| `label` | States that the PR carries a breaking change. A fact — stays once applied. Cosmetic. | No | `''` | +| `pending-label` | States that the breaking change is waiting on a decision. Removed once approved or rejected. | No | `''` | | `enabled` | `false` skips commenting while still managing the label. | No | `true` | ## Outputs diff --git a/src/notify/breaking-change-approval-reporter/action.yml b/src/notify/breaking-change-approval-reporter/action.yml index 19b33ac0..386bf4d5 100644 --- a/src/notify/breaking-change-approval-reporter/action.yml +++ b/src/notify/breaking-change-approval-reporter/action.yml @@ -13,7 +13,7 @@ inputs: required: false default: ${{ github.repository }} state: - description: "What to report — `waiting`, `approved`, `rejected`, or `unresolved` when the approval ended without a recorded decision. Any other value fails the step." + description: "What to report — `waiting`, `approved`, `rejected`, `unresolved` when the approval ended without a recorded decision, `acknowledgement` where no approval is required and only the author's acknowledgement is at stake, or `blocked` when the gate could not reach a verdict at all. `blocked` does not apply the fact label: nothing was established. Any other value fails the step." required: true reason: description: Why the pull request is gated, as resolved by `pr-gate-scope`. @@ -24,13 +24,54 @@ inputs: required: false default: '' acknowledged: - description: Whether the author acknowledgement line is present in the description (true/false). Shown to the reviewer as context. + description: 'Whether the author acknowledgement line is present in the description — `true`, `false`, or empty when nobody evaluated it. Empty omits the row entirely: reporting "missing" for something unchecked sends the author after a line no check is asking for.' required: false default: 'false' + author-acknowledgement: + description: 'The exact line the author must add to the description. Shown when it is missing — a check that fails without saying what to add is a check people route around.' + required: false + default: '' run-url: description: Link to the run carrying the pending deployment. required: false default: '' + reviewers: + description: 'Who can answer, as a `team:` / `user:` list. Rendered as links, never as @mentions — the pending deployment already notifies these people, so a mention would ping them twice for the same thing.' + required: false + default: '' + actual-reviewers: + description: 'Reviewers read from the Environment itself, in the same format. Preferred over `reviewers` when present: an Environment that already existed may be gated by different people than the caller configured, and naming the wrong ones is worse than naming none.' + required: false + default: '' + server-url: + description: Base URL used to build commit links. + required: false + default: ${{ github.server_url }} + badge-url: + description: 'Image used as the call-to-action button. Markdown has no button, so this is a badge image wrapped in the link. Its alt text carries the full call to action, so a reader whose network blocks the image still sees a described link. Set to empty for a plain text link instead.' + required: false + default: 'https://img.shields.io/badge/%E2%9E%A1%EF%B8%8F%20Review%20pending%20deployment-1f6feb?style=for-the-badge' + subjects: + description: >- + The breaking commit subjects, newline-separated, as resolved by + `breaking-change-commits`. Shown in the "how to undo it" example so it + names this pull request's own commits. Leave empty and the composite + reads them itself — a standalone caller does not have to run the + resolver first. + required: false + default: '' + fingerprint: + description: 'Digest of the breaking-change commit set, from `breaking-change-commits`. Recorded in the comment when the state is `approved`, alongside the run that carried the approval, so a later run can verify it against the Environment log rather than trusting the text. Previous records are preserved.' + required: false + default: '' + run-id: + description: Run recorded with the approval, so the next run can verify that this run's Environment approval actually happened. + required: false + default: ${{ github.run_id }} + docs-url: + description: Link to the explanation of how the gate works. + required: false + default: 'https://github.com/LerianStudio/github-actions-shared-workflows/blob/main/docs/breaking-change-approval.md' approvers: description: Comma-separated logins that approved. required: false @@ -48,7 +89,11 @@ inputs: required: false default: 'true' label: - description: Label applied while waiting and removed once resolved. Cosmetic — failing to apply it never fails the step. Empty disables it. + description: 'Label stating that this pull request carries a breaking change. A fact about the change, so it stays once applied — approving does not make the break go away. The caller removes it when the breaking commits are gone. Cosmetic; failing to apply it never fails the step. Empty disables it. Behaviour change: it used to be removed on a decision — a caller using it as a temporary waiting marker should move that value to `pending-label` and clear this one.' + required: false + default: '' + pending-label: + description: 'Label stating that the breaking change is waiting on a decision. A state, so it is removed once approved or rejected. Kept separate from `label` because "this breaks things" and "nobody has decided yet" stop being true at different moments. Empty disables it.' required: false default: '' enabled: @@ -75,20 +120,29 @@ runs: REASON: ${{ inputs.reason }} HEAD_SHA: ${{ inputs.head-sha }} ACKNOWLEDGED: ${{ inputs.acknowledged }} + SUBJECTS: ${{ inputs.subjects }} + ACK_LINE: ${{ inputs.author-acknowledgement }} RUN_URL: ${{ inputs.run-url }} + REVIEWERS: ${{ inputs.actual-reviewers != '' && inputs.actual-reviewers || inputs.reviewers }} + SERVER_URL: ${{ inputs.server-url }} + BADGE_URL: ${{ inputs.badge-url }} + FINGERPRINT: ${{ inputs.fingerprint }} + RUN_ID: ${{ inputs.run-id }} + DOCS_URL: ${{ inputs.docs-url }} APPROVERS: ${{ inputs.approvers }} REJECTERS: ${{ inputs.rejecters }} REJECTION_COMMENT: ${{ inputs.rejection-comment }} CLOSE_ON_REJECTION: ${{ inputs.close-on-rejection }} LABEL: ${{ inputs.label }} + PENDING_LABEL: ${{ inputs.pending-label }} ENABLED: ${{ inputs.enabled }} run: | set -euo pipefail case "$STATE" in - waiting | approved | rejected | unresolved ) ;; + waiting | approved | rejected | unresolved | acknowledgement | blocked ) ;; * ) - echo "state must be one of: waiting, approved, rejected, unresolved; got '${STATE}'." >&2 + echo "state must be one of: waiting, approved, rejected, unresolved, acknowledgement, blocked; got '${STATE}'." >&2 exit 1 ;; esac @@ -96,58 +150,251 @@ runs: body_file=$(mktemp) trap 'rm -f "$body_file"' EXIT INT TERM + owner=${REPO%%/*} + + # `team:x,user:y` -> linked names. The reader's next question is always + # "who can unblock this", and the answer is sitting in the Environment + # where nobody thinks to look. + # + # Links, deliberately not @mentions: the pending deployment already + # notifies these very people, and a mention would ping the whole team a + # second time for the same thing. + mentions="" + while IFS= read -r entry; do + [ -z "$entry" ] && continue + case "$entry" in + team:*) + slug=${entry#team:} + mentions="${mentions:+$mentions, }[@${owner}/${slug}](${SERVER_URL}/orgs/${owner}/teams/${slug})" + ;; + *) + login=${entry#user:} + mentions="${mentions:+$mentions, }[@${login}](${SERVER_URL}/${login})" + ;; + esac + done < <(tr ',' '\n' <<< "$REVIEWERS" | xargs -n1 2>/dev/null) + + # The subjects that actually tripped the gate, so the "how to undo it" + # example shows this pull request's own commits rather than a made-up + # `feat(scope)!: subject`. + # + # Only the `!` form is matched here, because that is the form the diff + # below illustrates. This is a view, never a verdict: the canonical rule + # lives in the breaking-change-guard composite, and when the two disagree + # the detector wins — this just falls back to generic wording. + # + # The caller usually has them already — `breaking-change-commits` ran + # over the same pull request to produce the fingerprint, and its + # `subjects` output went unused while this listed them a second time. + # Reading them twice costs an API call and lets the comment disagree + # with the set the gate actually judged. Supplied wins; empty still + # reads them, so a standalone caller needs no resolver. + # + # Commit subjects are written by whoever opens the pull request and are + # rendered into this comment. A subject carrying an approval marker + # would be preserved by the record-carrying code below as if the gate + # had written it — the author approving their own break by choosing a + # commit message. `/--\>/g' \ + || true + ) + else + breaking_subjects=$( + gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits" --paginate \ + --jq '.[] | .commit.message | split("\n")[0]' 2>/dev/null \ + | grep -E '^[A-Za-z0-9_]*(\([^)]*\))?!: ' \ + | sed 's//--\>/g' \ + || true + ) + fi + fi + + # `carol` -> `[@carol](https://github.com/carol)`. Linked, not a bare + # mention: the person being named is the one who just acted, so a + # notification tells them what they already know. Same reasoning as the + # reviewer list above. + link_people() { + local out="" person + while IFS= read -r person; do + person=$(printf '%s' "$person" | tr -d ' ') + [ -z "$person" ] && continue + out="${out:+$out, }[@${person}](${SERVER_URL}/${person})" + done < <(tr ',' '\n' <<< "$1") + printf '%s' "$out" + } + + linked_approvers=$(link_people "$APPROVERS") + linked_rejecters=$(link_people "$REJECTERS") + + commit_link="" + if [ -n "$HEAD_SHA" ]; then + commit_link="[\`${HEAD_SHA:0:7}\`](${SERVER_URL}/${REPO}/commit/${HEAD_SHA})" + fi + + # Blocking Checks fails on a missing acknowledgement whether or not an + # approval is pending — and whether or not one was given. Approving a + # break is a reviewer saying yes to it; it is not the author declaring + # it in the description, which is a separate, mandatory control. + # + # This used to live inside `waiting` alone, so the approval rewrote the + # comment and took the only copy of the line with it. The author was + # then left with a red Blocking Checks and nothing saying what to add. + ack_followup() { + [ "$ACKNOWLEDGED" = "false" ] && [ -n "$ACK_LINE" ] || return 0 + printf '
\nBlocking Checks also needs the author acknowledgement\n\n' + printf 'Add this exact line to the pull request description:\n\n' + printf '```\n%s\n```\n\n' "$ACK_LINE" + printf '
\n\n' + } + # shellcheck disable=SC2016 # the backticks below are Markdown code spans, not command substitutions { printf '\n\n' case "$STATE" in waiting) - printf '### 🚨 Breaking change — approval required\n\n' - printf 'A breaking-change commit was found on this branch, so merging it will force the next release to a **major** version.\n\n' - printf 'This pull request is held until a reviewer answers one question: **is this breaking change necessary?**\n\n' - [ -n "$RUN_URL" ] && printf '👉 **[Review the pending deployment](%s)** — *Review deployments* → `breaking-approval`.\n\n' "$RUN_URL" + printf '> [!IMPORTANT]\n' + printf '> ## 💥 Breaking change detected\n' + printf '>\n' + printf '> **This pull request needs a decision.** Merging it makes the next release a **major** version — often deliberate, so this just asks someone to confirm it before it ships.\n\n' + + # A shields.io badge renders as an actual button, which nothing in + # GitHub-flavoured Markdown does on its own. The alt text carries + # the full call to action on purpose: if the image fails to load — + # shields down, or a network that blocks it — the reader still sees + # a described link rather than a broken rectangle. + if [ -n "$RUN_URL" ] && [ -n "$BADGE_URL" ]; then + printf '[![Review the pending deployment](%s)](%s)\n\n' "$BADGE_URL" "$RUN_URL" + printf 'Actions → **Review deployments** → `breaking-approval`\n\n' + elif [ -n "$RUN_URL" ]; then + printf '### ➡️ [Review the pending deployment](%s)\n\n' "$RUN_URL" + fi + + printf '| | |\n|:--|:--|\n' + [ -n "$commit_link" ] && printf '| 💥 **Breaking commit** | %s |\n' "$commit_link" + [ -n "$mentions" ] && printf '| 👤 **Who can decide** | %s |\n' "$mentions" + # Empty means nobody evaluated it — only the caller that enforces + # the acknowledgement can say. Printing "missing" there would send + # the author after a line no check is asking for. + if [ -n "$ACKNOWLEDGED" ]; then + printf '| %s **Author acknowledgement** | %s |\n' \ + "$([ "$ACKNOWLEDGED" = "true" ] && echo "✅" || echo "⬜")" \ + "$([ "$ACKNOWLEDGED" = "true" ] && echo "present" || echo "missing")" + fi + printf '\n' + + ack_followup ;; approved) - printf '### ✅ Breaking change approved\n\n' - printf 'Approved by **%s** — the break is considered necessary and the next release will be a major version.\n\n' \ - "${APPROVERS:-a reviewer}" + printf '> [!NOTE]\n' + printf '> ## ✅ Breaking change approved\n' + printf '>\n' + printf '> Approved by %s. 💥 The next release will be a major version.\n\n' \ + "${linked_approvers:-a reviewer}" + ack_followup ;; rejected) - printf '### ⛔ Breaking change rejected\n\n' - printf 'Rejected by **%s** — the break is not considered necessary.\n\n' "${REJECTERS:-a reviewer}" - [ -n "$REJECTION_COMMENT" ] && printf '> %s\n\n' "$REJECTION_COMMENT" + printf '> [!CAUTION]\n' + printf '> ## ⛔ Breaking change rejected\n' + printf '>\n' + printf '> Rejected by %s' "${linked_rejecters:-a reviewer}" + [ -n "$REJECTION_COMMENT" ] && printf ' — *%s*' "$REJECTION_COMMENT" + printf '.\n' + if [ "$CLOSE_ON_REJECTION" = "true" ]; then + printf '>\n' + printf '> This pull request was closed. The branch is untouched — rework it without the break and reopen.\n' + fi + printf '\n' + ;; + acknowledgement) + # No approval is required here — only the author's own + # acknowledgement, which Blocking Checks enforces everywhere. + if [ "$ACKNOWLEDGED" = "true" ]; then + printf '> [!NOTE]\n' + printf '> ## 💥 Breaking change acknowledged\n' + printf '>\n' + printf '> The author has declared this break in the description. No approval is required on this branch.\n\n' + elif [ -n "$ACK_LINE" ]; then + printf '> [!IMPORTANT]\n' + printf '> ## 💥 Breaking change — acknowledgement missing\n' + printf '>\n' + printf '> This pull request carries a breaking change, so the next release becomes a **major** version. Add this exact line to the description:\n' + printf '>\n' + printf '> ```\n> %s\n> ```\n\n' "$ACK_LINE" + else + # No line to paste means nobody configured one — a direct + # caller on the composite's defaults. "Add this exact line" + # followed by an empty code block is worse than saying less. + printf '> [!IMPORTANT]\n' + printf '> ## 💥 Breaking change — acknowledgement missing\n' + printf '>\n' + printf '> This pull request carries a breaking change, so the next release becomes a **major** version. Declare it in the description.\n\n' + fi ;; unresolved) - printf '### 🚨 Breaking change — approval required\n\n' - printf 'The approval did not complete.' - [ -n "$RUN_URL" ] && printf ' [Open the run](%s) and answer the pending deployment.' "$RUN_URL" + printf '> [!WARNING]\n' + printf '> ## 💥 Breaking change — decision not recorded\n' + printf '>\n' + printf '> The approval did not complete.' + [ -n "$RUN_URL" ] && printf ' [Open the run](%s) to answer it.' "$RUN_URL" printf '\n\n' ;; + blocked) + # The gate could not reach a verdict, so it fails closed. That + # used to be silent on the pull request — a red check with no + # comment explaining it, which reads as a flake and gets re-run + # rather than looked at. + # + # Deliberately neutral about which stage failed. `blocked` is + # also reached when detection succeeded and the request stage + # did not — an Environment preflight refusing an unprotected + # `breaking-approval`, say — and blaming detection there sends + # the reader to the wrong log. + printf '> [!CAUTION]\n' + printf '> ## 💥 Breaking change gate could not run\n' + printf '>\n' + printf '> The gate did not reach a verdict, so this check fails closed — an unknown is not the same as "no breaking changes".' + [ -n "$RUN_URL" ] && printf ' [Open the run](%s) to see which step failed.' "$RUN_URL" + printf '\n\n' + # `ack_followup` is a no-op unless the acknowledgement was + # actually evaluated: the gate sends an empty value, not + # `false`, when nothing read the description. + ack_followup + ;; esac - printf '| | |\n|---|---|\n' - [ -n "$REASON" ] && printf '| Gated via | `%s` |\n' "$REASON" - [ -n "$HEAD_SHA" ] && printf '| Head commit | `%s` |\n' "${HEAD_SHA:0:7}" - printf '| Author acknowledgement in the description | %s |\n' \ - "$([ "$ACKNOWLEDGED" = "true" ] && echo "present" || echo "missing")" - printf '\n' + # The escape hatch is folded away: it matters enormously to whoever + # broke the contract by accident, and not at all to whoever meant to. + if [ "$STATE" = "waiting" ]; then + printf '
\n' + printf 'Not intentional? — how to clear this without an approval\n\n' + if [ -n "$breaking_subjects" ]; then + printf 'Drop the `!` from the commit subject, then push again. The gate re-evaluates and clears itself.\n\n' + printf '```diff\n' + while IFS= read -r subject; do + [ -z "$subject" ] && continue + printf -- '- %s\n' "$subject" + printf -- '+ %s\n' "$(sed -E 's/^([A-Za-z0-9_]*(\([^)]*\))?)!:/\1:/' <<< "$subject")" + done <<< "$breaking_subjects" + printf '```\n\n' + printf 'Also remove any `BREAKING CHANGE:` footer from the commit bodies, if there is one.\n\n' + else + printf 'Remove the `BREAKING CHANGE:` footer from the commit bodies, and drop any `!` after the type in the subjects. Push again and the gate re-evaluates.\n\n' + fi + printf '
\n\n' + fi - case "$STATE" in - waiting) - printf '**Approving** records that the approvers consider the break necessary, and releases the merge.\n\n' - if [ "$CLOSE_ON_REJECTION" = "true" ]; then - printf '**Rejecting** records that it is not, and **closes this pull request** so the change can be reworked without the break. Reopening is one click and the branch is untouched.\n\n' - else - printf '**Rejecting** records that it is not, and fails this check.\n\n' - fi - printf 'If the break was unintentional, reword the commits — drop the `!` after the type and remove any `BREAKING CHANGE:` footer — and push again. No approval is needed then.\n' - ;; - rejected) - if [ "$CLOSE_ON_REJECTION" = "true" ]; then - printf 'This pull request was closed. Rework the change so it does not break the contract — drop the `!` and any `BREAKING CHANGE:` footer once it no longer applies — then reopen it. The branch is untouched.\n' - fi - ;; - esac + context="" + [ -n "$REASON" ] && context="Gated by \`${REASON}\`" + context="${context:+$context · }[How this gate works](${DOCS_URL})" + printf '%s\n' "$context" } > "$body_file" comment_id="" @@ -158,6 +405,76 @@ runs: existing=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \ --jq "[.[] | select(.body | contains(\"${marker}\")) | .id] | first // empty") + # The approval record outlives the comment body. Every run rewrites + # the body, so without carrying these forward an approval would be + # forgotten by the next push — which is the whole thing the record + # exists to prevent. + previous_records="" + if [ -n "$existing" ]; then + # A failed read must not look like "there were none". `|| true` + # here would hand back an empty set, the PATCH below would rewrite + # the comment without the records, and every approval on this pull + # request would be silently erased by a transient API error — the + # reviewers asked again for changes they had already cleared. + if ! current_body=$(gh api "repos/${REPO}/issues/comments/${existing}" --jq '.body'); then + echo "Could not read the existing comment (${existing}) to carry its approval records forward." >&2 + echo "Refusing to rewrite it: that would drop approvals already given." >&2 + exit 1 + fi + + # A successful read with no markers is a legitimately empty set. + # Only from outside fenced blocks. The undo example renders commit + # subjects inside ```diff, and a marker that arrived that way is + # the author's text, not the gate's record — defanged above, and + # refused here too, because one guard for a privilege escalation + # is one guard too few. + previous_records=$( + awk '/^```/ { fenced = !fenced; next } !fenced' <<< "$current_body" \ + | grep -oE '' \ + || true + ) + fi + + if [ "$STATE" = "approved" ] && [ -n "$FINGERPRINT" ]; then + # The run is recorded alongside the fingerprint so the next run can + # verify the approval against the Environment's log rather than + # taking this text at its word. + previous_records=$(printf '%s\n' \ + "$previous_records" "$FINGERPRINT" "${RUN_ID}") + fi + + if [ -n "$(printf '%s' "$previous_records" | tr -d '[:space:]')" ]; then + { + printf '\n' + printf '%s\n' "$previous_records" | awk 'NF' | sort -u + } >> "$body_file" + fi + + # The comment this one replaced. `breaking-change-comment` used to + # own `` and retired it when the + # acknowledgement arrived or the break went away; removing that job + # removed its cleanup too, so a pull request opened before the + # changeover keeps an "awaiting acknowledgement" notice forever — + # beside this comment, contradicting it. + # + # Deliberately narrow. Only a comment the marker *opens*, which is + # how that job wrote them, so quoting the marker in a human comment + # is not enough to get it deleted. The records live in this + # comment's own body and are never read from there. + # + # Transitional: delete this block once no open pull request predates + # the changeover. + legacy_marker='' + gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \ + --jq "[.[] + | select(.user.type == \"Bot\") + | select(.body | startswith(\"${legacy_marker}\")) + | .id] | .[]" 2>/dev/null \ + | while IFS= read -r stale; do + [ -n "$stale" ] || continue + gh api --method DELETE "repos/${REPO}/issues/comments/${stale}" >/dev/null 2>&1 || true + done + if [ -n "$existing" ]; then gh api --method PATCH "repos/${REPO}/issues/comments/${existing}" \ -F body=@"$body_file" >/dev/null @@ -169,14 +486,34 @@ runs: fi # Cosmetic and deliberately last — a repository that never declared the - # label still gets its gate. - if [ -n "$LABEL" ]; then + # labels still gets its gate. + # + # Two of them, because they stop being true at different moments. The + # breaking change is a property of the commits: approving it does not + # make the break go away, and somebody filtering for `breaking-change` + # three months from now wants this pull request in the results. Waiting + # on a decision is a state, and it ends. + # + # Every state reaching this composite means a breaking change is + # present — the caller skips it entirely when there is none — with one + # exception. `blocked` means the gate never established anything, so + # labelling the pull request `breaking-change` there asserts exactly + # what the comment beside it says is unknown, and a transient detection + # failure would brand a clean pull request. Labels already on it stay: + # removing them would be the same mistake inverted. + if [ -n "$LABEL" ] && [ "$STATE" != "blocked" ]; then + if ! gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$LABEL" >/dev/null 2>&1; then + echo "::notice::Label '${LABEL}' not applied — it is not declared in ${REPO}. Cosmetic only." + fi + fi + + if [ -n "$PENDING_LABEL" ]; then if [ "$STATE" = "waiting" ]; then - if ! gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$LABEL" >/dev/null 2>&1; then - echo "::notice::Label '${LABEL}' not applied — it is not declared in ${REPO}. Cosmetic only." + if ! gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$PENDING_LABEL" >/dev/null 2>&1; then + echo "::notice::Label '${PENDING_LABEL}' not applied — it is not declared in ${REPO}. Cosmetic only." fi else - gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$LABEL" >/dev/null 2>&1 || true + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$PENDING_LABEL" >/dev/null 2>&1 || true fi fi diff --git a/src/security/codeql-analyze/action.yml b/src/security/codeql-analyze/action.yml index 494227be..a123de52 100644 --- a/src/security/codeql-analyze/action.yml +++ b/src/security/codeql-analyze/action.yml @@ -18,7 +18,7 @@ runs: using: composite steps: - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: ${{ inputs.category }} output: ${{ inputs.output }} diff --git a/src/security/codeql-init/action.yml b/src/security/codeql-init/action.yml index 32a236d1..4c8a530e 100644 --- a/src/security/codeql-init/action.yml +++ b/src/security/codeql-init/action.yml @@ -18,7 +18,7 @@ runs: using: composite steps: - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: ${{ inputs.languages }} queries: ${{ inputs.queries }} diff --git a/src/validate/breaking-change-commits/README.md b/src/validate/breaking-change-commits/README.md new file mode 100644 index 00000000..996f04b0 --- /dev/null +++ b/src/validate/breaking-change-commits/README.md @@ -0,0 +1,163 @@ + + + + + +
Lerian

Breaking Change Commits

+ +> **Unreleased.** Until this ships in a release, reference it as `@develop` — +> `@v1` is the previous release and does not contain it yet. + +Lists the commits in a pull request that carry a breaking change, and +fingerprints the set. + +## Why a fingerprint + +An approval is given to a specific breaking change, not to a pull request. Push +anything afterwards and the question becomes: *is this still the thing that was +approved?* + +Without an answer, the only safe default is to ask again on every push — so a +reviewer who approved a breaking change gets asked the identical question +minutes later because the author fixed a typo. That is how a gate turns into a +formality people click through. + +The fingerprint is built from the **SHAs** of the breaking commits, so: + +| The author… | Fingerprint | +|---|---| +| fixes a typo in an unrelated file | unchanged | +| adds an ordinary commit | unchanged | +| rewords the breaking commit | changes — it is a different commit now | +| adds another breaking commit | changes | + +Rewording counts as a change on purpose: the approval was given to the text that +was read, and the subject is what the release parser acts on. + +## Which commits count + +The same two forms the +[`breaking-change-guard`](../breaking-change-guard/README.md) detector accepts, +and no others: + +- a subject with `!` after the type — `feat(scope)!: …` +- a message line that is a `BREAKING CHANGE` / `BREAKING CHANGES` footer, + optionally prefixed by whitespace, `*` or `|` + +`BREAKING-CHANGE` with a hyphen does not count, and neither does prose that +mentions the phrase mid-sentence — because the release parser treats neither as +breaking. + +## Pull requests above 250 commits + +`/pulls/{n}/commits` caps at 250. The action compares what the endpoint +returned against the `commits` count the pull request declares, and when the +two disagree it **exits non-zero** instead of reporting on a partial list — a +fingerprint built from the first 250 of 300 commits would miss a break living +in the tail and say "already approved" about a change nobody saw. + +So a pull request over 250 commits fails the step rather than passing it. What +that costs depends on who is asking, and the two cases are not the same: + +- **Detecting here** — nothing else looked, so the gate reads the failure as + `unknown` and blocks. The way through is to split the pull request, not to + widen the cap: the endpoint has no page beyond it. +- **Detection supplied by the caller** — `pr-validation` and the other + umbrellas run the detector over the real commit range and pass the answer in. + That answer stands, and the gate is not blocked by this step failing. What is + lost is the fingerprint, so no earlier approval is reused and the reviewer is + asked for a fresh decision. Nothing to split. + +The same refusal fires if the declared count cannot be read at all. + +## Commit subjects are untrusted text + +The subjects go into `GITHUB_OUTPUT` as a multiline value, and whoever opens the +pull request writes them. With a fixed delimiter, a commit whose subject *is* +that delimiter closes the block early, and every line after it is read as a new +output assignment — `fingerprint=` included, which is what decides whether a +breaking change counts as already approved. + +So the delimiter is random per run and checked against the subjects before +anything is written. The test suite includes the attack: two commits, one +closing the block and one assigning a forged fingerprint. + +**This is a view, not a verdict.** The detector runs over the real commit range +and decides; this reads the API because the jobs that need it have no checkout. +They must agree, which is why the patterns are kept identical — and the tests +here cover the same negative cases. + +## Resolving at a past revision + +`at-sha` (with `base-ref`) resolves the set as it stood at that revision +instead of at the pull request's current head, by comparing `base-ref...at-sha` +rather than reading the pull request's commits — which always report the +current state. + +The gate uses it for one question: *what did that approval approve?* The +fingerprint written in the approval record answers a different one — what the +writer claims it covered — and anybody who can read the repository can write a +record. Since the fingerprint is derived from public commit SHAs it is also +computable in advance, so a record naming a real approved run could be posted +before the breaking commit it claims to cover even exists. Resolving from the +approving run's own head closes that: the head is not writable. + +The 250 cap applies here too, measured against `total_commits`, and is refused +the same way. + +**This path needs `contents: read`.** The comparison API is repository +contents, not pull request data, so a token carrying only `pull-requests: read` +fails every comparison — and a failed comparison reads as "cannot confirm what +that approval covered", which means the approval is not reused and the reviewer +is asked again on every push. Nothing says why, so grant the scope. +`breaking-change-approval.yml` already does for its own `request` job; this +applies to anything calling the composite directly. + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-token` | Token used to read the commits. Needs `pull-requests: read` — and `contents: read` as well when `at-sha` is given. | Yes | — | +| `pr-number` | Pull request whose commits are listed. | Yes | — | +| `repository` | Repository owning it, as `owner/name`. | No | `${{ github.repository }}` | +| `at-sha` | Resolve the set as it stood at this revision instead of at the current head. Requires `base-ref`. | No | `''` | +| `base-ref` | Base of the comparison when `at-sha` is given. | No | `''` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `count` | How many commits carry a breaking change. | +| `shas` | Comma-separated SHAs, in commit order. | +| `subjects` | Their subjects, one per line. | +| `fingerprint` | Short digest of the SHA set. Empty when there is none. | + +## Usage + +Testing, while this is unreleased — `@develop` is the only ref that resolves +today. After it ships, pin a tier channel (`@tier-0` / `@tier-1` / `@tier-2`) +and never `@develop`, which moves under the caller. + +```yaml +- name: List the breaking-change commits + id: breaking + uses: LerianStudio/github-actions-shared-workflows/src/validate/breaking-change-commits@develop + with: + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + pr-number: ${{ github.event.pull_request.number }} + +- name: Ask only if this is not what was already approved + if: steps.breaking.outputs.fingerprint != steps.previous.outputs.approved_fingerprint + run: echo "needs a decision" +``` + +## Local test + +```bash +bash src/validate/breaking-change-commits/test.sh +``` + +20 cases against a stubbed `gh`: both breaking forms, the two lookalikes that +must not count, ordering, the fingerprint staying put when an ordinary commit is +added while changing when a breaking one is, parity with the detector on case +and on scopes containing parens, and the output-injection attack. diff --git a/src/validate/breaking-change-commits/action.yml b/src/validate/breaking-change-commits/action.yml new file mode 100644 index 00000000..1967029f --- /dev/null +++ b/src/validate/breaking-change-commits/action.yml @@ -0,0 +1,62 @@ +name: Breaking Change Commits +description: "Lists the commits in a pull request that carry a breaking change, and fingerprints the set so an approval can be matched against what it approved." + +inputs: + github-token: + description: >- + Token used to read the commits. Needs `pull-requests: read`, and + `contents: read` as well when `at-sha` is given — that path reads the + comparison API, which is repository contents rather than pull request + data. Granting only the first makes every comparison fail, and the gate + reads a failed comparison as "cannot confirm", so it asks the reviewer + again on every push without saying why. + required: true + pr-number: + description: Pull request whose commits are listed. + required: true + repository: + description: 'Repository owning the pull request, as `owner/name`.' + required: false + default: ${{ github.repository }} + at-sha: + description: >- + Resolve the set as it stood at this revision instead of at the pull + request's current head. Requires `base-ref`, and a token carrying + `contents: read`. The gate uses it to ask what an approval actually + approved, rather than believing a fingerprint written in a comment. + required: false + default: '' + base-ref: + description: 'Base of the comparison when `at-sha` is given — the pull request''s base branch.' + required: false + default: '' + +outputs: + count: + description: "How many commits carry a breaking change." + value: ${{ steps.resolve.outputs.count }} + shas: + description: "Comma-separated SHAs of those commits, in commit order." + value: ${{ steps.resolve.outputs.shas }} + subjects: + description: "Their subjects, one per line, for showing the author their own commits." + value: ${{ steps.resolve.outputs.subjects }} + fingerprint: + description: "Short digest of the SHA set. Equal fingerprints mean the same breaking change; empty when there is none. Use it to tell an approval that still applies from one that does not." + value: ${{ steps.resolve.outputs.fingerprint }} + +runs: + using: composite + steps: + - name: List the breaking-change commits + id: resolve + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + REPO: ${{ inputs.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + AT_SHA: ${{ inputs.at-sha }} + BASE_REF: ${{ inputs.base-ref }} + run: | + set -euo pipefail + bash "${GITHUB_ACTION_PATH}/resolve.sh" >> "${GITHUB_OUTPUT}" diff --git a/src/validate/breaking-change-commits/resolve.sh b/src/validate/breaking-change-commits/resolve.sh new file mode 100755 index 00000000..7d332825 --- /dev/null +++ b/src/validate/breaking-change-commits/resolve.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash + +# Lists the commits in a pull request that carry a breaking change, and +# fingerprints the set. +# +# Two consumers need this and for different reasons: +# +# the comment, to show the author their own subjects rather than a made-up +# example +# +# the gate, to tell "the approved breaking change is still the same one" from +# "something new arrived". Without that, every push expires an approval given +# minutes earlier for identical content, and a reviewer is asked the same +# question again — which is how a gate becomes a formality. +# +# Read through the API rather than Git, because the jobs that need it have no +# checkout. The canonical detector remains `breaking-change-guard`, which runs +# over the real commit range; this must agree with it, and the patterns below +# are kept identical to the ones pinned there. + +set -euo pipefail + +GH_TOKEN=${GH_TOKEN:-} +REPO=${REPO:-} +PR_NUMBER=${PR_NUMBER:-} +# Optional. Resolves the set as it stood at a given revision instead of at the +# pull request's current head — which is what lets the gate ask the question it +# actually needs answered: what did that approval approve? Reading the +# fingerprint out of a comment answers a different one, and a comment is +# writable by anybody who can read the repository. +BASE_REF=${BASE_REF:-} +AT_SHA=${AT_SHA:-} + +export GH_TOKEN + +if [[ -z "${REPO}" || -z "${PR_NUMBER}" ]]; then + printf 'Breaking Change Commits: REPO and PR_NUMBER are required.\n' >&2 + exit 1 +fi + +for binary in gh jq; do + if ! command -v "${binary}" >/dev/null 2>&1; then + printf 'Breaking Change Commits: %s is not available.\n' "${binary}" >&2 + exit 1 + fi +done + +if [[ -n "${AT_SHA}" ]]; then + if [[ -z "${BASE_REF}" ]]; then + printf 'Breaking Change Commits: BASE_REF is required with AT_SHA.\n' >&2 + exit 1 + fi + + # `compare` answers for a revision that is no longer the head. The pull + # request's own commits endpoint cannot: it always reports the current state. + if ! comparison=$(gh api "repos/${REPO}/compare/${BASE_REF}...${AT_SHA}" 2>/dev/null); then + printf 'Breaking Change Commits: could not compare %s...%s in %s.\n' \ + "${BASE_REF}" "${AT_SHA}" "${REPO}" >&2 + exit 1 + fi + + # Same cap, same refusal. `compare` returns at most 250 commits and declares + # the real total separately, so a truncated answer is detectable — and + # answering from one would be worse here than anywhere else, since this + # decides whether an approval is reused. + declared_total=$(jq -r '.total_commits // 0' <<< "${comparison}") + returned_total=$(jq -r '.commits | length' <<< "${comparison}") + if [[ "${declared_total}" -ne "${returned_total}" ]]; then + printf 'Breaking Change Commits: %s...%s has %s commits but the comparison returned %s.\n' \ + "${BASE_REF}" "${AT_SHA}" "${declared_total}" "${returned_total}" >&2 + exit 1 + fi + + commits=$(jq -r '.commits[] | [.sha, .commit.message] | @base64' <<< "${comparison}") +elif ! commits=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits" --paginate \ + --jq '.[] | [.sha, .commit.message] | @base64' 2>/dev/null); then + printf 'Breaking Change Commits: could not read the commits of PR #%s in %s.\n' \ + "${PR_NUMBER}" "${REPO}" >&2 + exit 1 +fi + +# The commits endpoint returns at most 250, pagination or not. GitHub documents +# this, and answering from a truncated list is worse than not answering: a break +# living past the cap reads as "no breaking change", and — with reuse — an +# approved break inside the window plus an omitted one outside it leaves the +# fingerprint unchanged, so an approval is honoured for a change it never +# covered. +# +# Refuse instead. A gate that cannot see the whole change says so. +# +# Only for the pull-request path: the comparison above does its own version of +# this check, against `total_commits`, because it has no pull request to ask. +returned=$(grep -c . <<< "${commits}" || true) +[[ -z "${commits}" ]] && returned=0 + +if [[ -n "${AT_SHA}" ]]; then + declared="${returned}" +elif ! declared=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.commits' 2>/dev/null); then + printf 'Breaking Change Commits: could not read the commit count of PR #%s in %s.\n' \ + "${PR_NUMBER}" "${REPO}" >&2 + exit 1 +fi + +if [[ "${returned}" -ne "${declared}" ]]; then + printf 'Breaking Change Commits: PR #%s declares %s commits but the API returned %s.\n' \ + "${PR_NUMBER}" "${declared}" "${returned}" >&2 + printf 'The commits endpoint caps at 250, so this enumeration is incomplete and a verdict from it would be a guess.\n' >&2 + exit 1 +fi + +shas='' +subjects='' +count=0 + +# `!` after the type, or a `BREAKING CHANGE` footer. Same two forms the +# detector accepts, and deliberately no others: `BREAKING-CHANGE` and `-` +# bullets are not breaking to the release parser either. +# Transcribed from the detector's awk, which lowercases the line first and then +# applies: +# +# subject ^[a-z0-9_]*(\(.*\))?!: .*$ +# footer ^[[:space:]|*]*breaking changes?(:|[[:space:]])+ +# +# Held in variables because a bare `|` inside `[[ =~ ]]` is parsed as an +# operator, and because the two must be read side by side with the detector to +# stay honest. `\(.*\)` for the scope, not `\([^)]*\)`: a scope containing a +# closing paren matches there, so it has to match here. +BANG_SUBJECT_RE='^[a-z0-9_]*(\(.*\))?!: ' +# `[[:space:]|*]` — inside a bracket expression a backslash is a literal +# backslash, so escaping `|` and `*` there would quietly widen the class to +# accept a leading `\`. +FOOTER_RE='^[[:space:]|*]*breaking changes?(:|[[:space:]])' + +is_breaking() { + local message=$1 subject line lowered + + subject=$(printf '%s' "${message}" | head -n1 | tr '[:upper:]' '[:lower:]') + if [[ "${subject}" =~ ${BANG_SUBJECT_RE} ]]; then + return 0 + fi + + while IFS= read -r line; do + line=${line%$'\r'} + lowered=$(printf '%s' "${line}" | tr '[:upper:]' '[:lower:]') + if [[ "${lowered}" =~ ${FOOTER_RE} ]]; then + return 0 + fi + done <<< "${message}" + + return 1 +} + +while IFS= read -r encoded; do + [[ -z "${encoded}" ]] && continue + + entry=$(base64 --decode <<< "${encoded}" 2>/dev/null || true) + [[ -z "${entry}" ]] && continue + + sha=$(jq -r '.[0]' <<< "${entry}" 2>/dev/null || true) + message=$(jq -r '.[1]' <<< "${entry}" 2>/dev/null || true) + [[ -z "${sha}" ]] && continue + + if is_breaking "${message}"; then + shas="${shas:+${shas},}${sha}" + subjects="${subjects}$(head -n1 <<< "${message}")"$'\n' + count=$((count + 1)) + fi +done <<< "${commits}" + +# The fingerprint answers one question: is this the same set that was approved? +# Built from the SHAs, so rewording or reordering produces a different one — +# the commit is not the same commit, and the approval was for the old text. +fingerprint='' +if [[ -n "${shas}" ]]; then + fingerprint=$(printf '%s' "${shas}" | shasum -a 256 2>/dev/null | cut -c1-16) \ + || fingerprint=$(printf '%s' "${shas}" | sha256sum | cut -c1-16) +fi + +printf 'count=%s\n' "${count}" +printf 'shas=%s\n' "${shas}" +printf 'fingerprint=%s\n' "${fingerprint}" +# The subjects are commit-controlled text going into GITHUB_OUTPUT. With a +# fixed delimiter, a commit whose subject IS that delimiter closes the block +# early and every line after it becomes a new output assignment — including +# `fingerprint=`, which is what decides whether this breaking change was +# already approved. That is a gate bypass written by whoever opens the pull +# request, so the delimiter is random per run and verified not to appear. +delimiter="BREAKING_SUBJECTS_$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')" +while grep -Fqx -- "${delimiter}" <<< "${subjects}"; do + delimiter="BREAKING_SUBJECTS_$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')" +done + +{ + printf 'subjects<<%s\n' "${delimiter}" + printf '%s' "${subjects}" + printf '%s\n' "${delimiter}" +} diff --git a/src/validate/breaking-change-commits/test.sh b/src/validate/breaking-change-commits/test.sh new file mode 100755 index 00000000..529b56a6 --- /dev/null +++ b/src/validate/breaking-change-commits/test.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash + +# Exercises resolve.sh against a stubbed `gh` serving a canned commit list. + +set -euo pipefail + +CDPATH='' +SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) +RESOLVER="${SCRIPT_DIR}/resolve.sh" +TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/breaking-change-commits.XXXXXX") +PASSED=0 +FAILED=0 + +cleanup() { rm -rf "${TEST_ROOT}"; } +trap cleanup EXIT INT TERM + +mkdir -p "${TEST_ROOT}/bin" +cat > "${TEST_ROOT}/bin/gh" <<'STUB' +#!/usr/bin/env bash +case "$*" in + # The historical path: the breaking set as it stood at a given revision, + # which is how the gate asks what an approval actually approved. COMPARE_JSON + # is the canned payload; COMPARE_TOTAL overrides `total_commits` to simulate + # the same 250 cap the pull-request path has. + *"/compare/"*) + printf '%s' "${COMPARE_JSON:-{\"total_commits\":0,\"commits\":[]\}}" ;; + *"/commits"*) printf '%s\n' ${COMMITS_B64} ;; + # The declared total. DECLARED_COMMITS overrides it to simulate the 250 cap, + # where the endpoint returns fewer commits than the pull request has. + *"/pulls/"*) + if [ -n "${DECLARED_COMMITS:-}" ]; then + printf '%s\n' "${DECLARED_COMMITS}" + else + printf '%s\n' ${COMMITS_B64} | grep -c . || true + fi ;; + *) exit 1 ;; +esac +STUB +chmod +x "${TEST_ROOT}/bin/gh" +PATH="${TEST_ROOT}/bin:${PATH}" + +# encode +encode() { + jq -cn --arg s "$1" --arg m "$2" '[$s, $m]' | base64 | tr -d '\n' +} + +# `|| true` so a resolver that exits non-zero is reported as a failing case +# rather than aborting the suite at the assignment, which under `set -e` would +# take the summary line with it and never name the case that broke. +run() { + env GH_TOKEN=stub REPO=LerianStudio/example PR_NUMBER=42 COMMITS_B64="$1" \ + bash "${RESOLVER}" 2>/dev/null || true +} + +check() { + local description=$1 commits=$2 expected_count=$3 expected_shas=$4 + local output count shas + output=$(run "${commits}") + count=$(sed -n 's/^count=//p' <<< "${output}") + shas=$(sed -n 's/^shas=//p' <<< "${output}") + + if [[ "${count}" == "${expected_count}" && "${shas}" == "${expected_shas}" ]]; then + PASSED=$((PASSED + 1)) + printf 'ok - %s\n' "${description}" + else + FAILED=$((FAILED + 1)) + printf 'not ok - %s (count=%s shas=%s; wanted %s / %s)\n' \ + "${description}" "${count}" "${shas}" "${expected_count}" "${expected_shas}" + fi +} + +# compare_payload [ ...] +compare_payload() { + local total=$1; shift + local entries='' + while [[ $# -gt 0 ]]; do + entries="${entries:+${entries},}$(jq -cn --arg s "$1" --arg m "$2" \ + '{sha: $s, commit: {message: $m}}')" + shift 2 + done + printf '{"total_commits":%s,"commits":[%s]}' "${total}" "${entries}" +} + +run_at_sha() { + env GH_TOKEN=stub REPO=LerianStudio/example PR_NUMBER=42 \ + BASE_REF=develop AT_SHA="$1" COMPARE_JSON="$2" COMMITS_B64='' \ + bash "${RESOLVER}" 2>/dev/null || true +} + +check_at_sha() { + local description=$1 payload=$2 expected_count=$3 + local output count + output=$(run_at_sha aaaa111 "${payload}") + count=$(sed -n 's/^count=//p' <<< "${output}") + + if [[ "${count}" == "${expected_count}" ]]; then + PASSED=$((PASSED + 1)) + printf 'ok - %s\n' "${description}" + else + FAILED=$((FAILED + 1)) + printf 'not ok - %s (count=%s; wanted %s)\n' \ + "${description}" "${count}" "${expected_count}" + fi +} + +BANG=$(encode aaa1111 'feat(scope)!: breaks the contract') +PLAIN=$(encode bbb2222 'fix(scope): ordinary work') +FOOTER=$(encode ccc3333 'feat(scope): looks ordinary + +BREAKING CHANGE: the field was removed') +BULLET=$(encode ddd4444 'refactor: cleanup + +* BREAKING CHANGE: moved') +HYPHEN=$(encode eee5555 'refactor: cleanup + +BREAKING-CHANGE: moved') +PROSE=$(encode fff6666 'docs: explain the policy + +We document how a BREAKING CHANGE is handled here.') +NOTYPE=$(encode aaa7777 '!: no type at all') + +check 'a bang subject is breaking' "${BANG}" 1 aaa1111 +check 'an ordinary commit is not' "${PLAIN}" 0 '' +check 'a BREAKING CHANGE footer is breaking' "${FOOTER}" 1 ccc3333 +check 'a bullet-prefixed footer is breaking' "${BULLET}" 1 ddd4444 +check 'BREAKING-CHANGE with a hyphen is not' "${HYPHEN}" 0 '' +check 'prose mentioning the phrase mid-line is not' "${PROSE}" 0 '' +check 'a bang with no type is breaking' "${NOTYPE}" 1 aaa7777 +check 'several breaking commits are all listed' "${BANG} ${PLAIN} ${FOOTER}" 2 aaa1111,ccc3333 +check 'order follows the commit list' "${FOOTER} ${BANG}" 2 ccc3333,aaa1111 + +# ----------------- Parity with the detector ----------------- +# The detector lowercases before matching, and allows any scope content. These +# are the forms where a transcription slip would make this view disagree with +# the verdict. +LOWER=$(encode aab1111 'feat(scope): looks ordinary + +breaking change: lowercase footer') +MIXED=$(encode aab2222 'feat(scope): looks ordinary + +Breaking Change: mixed case footer') +PLURAL=$(encode aab3333 'feat(scope): looks ordinary + +BREAKING CHANGES: plural') +SPACED=$(encode aab4444 'feat(scope): looks ordinary + +BREAKING CHANGE the separator is a space') +NESTED=$(encode aab5555 'feat(a)b)!: a scope containing a closing paren') +UPPER_SUBJECT=$(encode aab6666 'FEAT(SCOPE)!: an uppercase subject') + +check 'a lowercase footer is breaking' "${LOWER}" 1 aab1111 +check 'a mixed-case footer is breaking' "${MIXED}" 1 aab2222 +check 'the plural form is breaking' "${PLURAL}" 1 aab3333 +check 'a space separator is breaking' "${SPACED}" 1 aab4444 +check 'a scope containing a paren still matches' "${NESTED}" 1 aab5555 +check 'an uppercase subject is breaking' "${UPPER_SUBJECT}" 1 aab6666 + +# ----------------- Output injection ----------------- +# The subjects are commit-controlled and go into GITHUB_OUTPUT as a multiline +# value. A fixed delimiter would let a commit close the block and write its own +# assignments — `fingerprint=` among them, which decides whether this change +# counts as already approved. +INJECT_CLOSE=$(encode aac1111 'BREAKING_SUBJECTS_EOF + +BREAKING CHANGE: closes the block above') +INJECT_VALUE=$(encode aac2222 'fingerprint=deadbeefdeadbeef + +BREAKING CHANGE: and assigns a forged one') + +# Parsed the way the runner parses GITHUB_OUTPUT: lines inside a multiline +# block are a value, not assignments. Grepping the raw text would find the +# forged line sitting harmlessly inside the block and call it a breach. +single_line_assignments() { + local line delimiter='' in_block=0 + while IFS= read -r line; do + if [[ ${in_block} -eq 1 ]]; then + [[ "${line}" == "${delimiter}" ]] && in_block=0 + continue + fi + if [[ "${line}" == *'<<'* ]]; then + delimiter=${line#*<<} + in_block=1 + continue + fi + printf '%s\n' "${line}" + done +} + +injected=$(run "${INJECT_CLOSE} ${INJECT_VALUE}") +forged=$(single_line_assignments <<< "${injected}" \ + | grep -c '^fingerprint=deadbeefdeadbeef$' || true) +if [[ "${forged}" == '0' ]]; then + PASSED=$((PASSED + 1)) + printf 'ok - a commit subject cannot close the output block and forge a value\n' +else + FAILED=$((FAILED + 1)) + printf 'not ok - a commit subject forged an output assignment\n' +fi + +# ----------------- Fingerprint ----------------- +fingerprint_of() { run "$1" | sed -n 's/^fingerprint=//p'; } + +same_a=$(fingerprint_of "${BANG} ${PLAIN}") +same_b=$(fingerprint_of "${BANG} ${PLAIN}") +added=$(fingerprint_of "${BANG} ${PLAIN} ${FOOTER}") +ordinary_added=$(fingerprint_of "${BANG} ${PLAIN} ${PLAIN}") +none=$(fingerprint_of "${PLAIN}") + +assert() { + if [[ "$2" == "$3" ]]; then + PASSED=$((PASSED + 1)); printf 'ok - %s\n' "$1" + else + FAILED=$((FAILED + 1)); printf 'not ok - %s (%s vs %s)\n' "$1" "$2" "$3" + fi +} +refute() { + if [[ "$2" != "$3" ]]; then + PASSED=$((PASSED + 1)); printf 'ok - %s\n' "$1" + else + FAILED=$((FAILED + 1)); printf 'not ok - %s (both %s)\n' "$1" "$2" + fi +} + +assert 'the same set fingerprints the same' "${same_a}" "${same_b}" +assert 'adding an ordinary commit does not change it' "${same_a}" "${ordinary_added}" +refute 'adding a breaking commit changes it' "${same_a}" "${added}" +assert 'no breaking commits means no fingerprint' "${none}" '' + +# ----------------- The 250-commit cap ----------------- +# The endpoint returns at most 250 commits however much you paginate. Answering +# from a truncated list reports "no breaking change" for a break living past the +# cap — and, with approval reuse, leaves the fingerprint unchanged while the +# change gained a break nobody approved. +# `|| capped_status=$?` on the assignment itself: under `set -e` a command +# substitution that exits non-zero takes the script down before the next line +# can read `$?`. This is the same trap the `run` helper above already sidesteps. +capped_status=0 +capped=$( + env GH_TOKEN=stub REPO=LerianStudio/example PR_NUMBER=42 \ + COMMITS_B64="${PLAIN}" DECLARED_COMMITS=300 \ + bash "${RESOLVER}" 2>/dev/null +) || capped_status=$? +if [[ ${capped_status} -ne 0 && -z "${capped}" ]]; then + PASSED=$((PASSED + 1)) + printf 'ok - an incomplete enumeration is refused, not answered\n' +else + FAILED=$((FAILED + 1)) + printf 'not ok - a truncated commit list produced a verdict (%s)\n' "${capped}" +fi + +complete=$( + env GH_TOKEN=stub REPO=LerianStudio/example PR_NUMBER=42 \ + COMMITS_B64="${BANG} ${PLAIN}" DECLARED_COMMITS=2 \ + bash "${RESOLVER}" 2>/dev/null | sed -n 's/^count=//p' +) +if [[ "${complete}" == '1' ]]; then + PASSED=$((PASSED + 1)) + printf 'ok - a complete enumeration is answered normally\n' +else + FAILED=$((FAILED + 1)) + printf 'not ok - a complete enumeration was refused (count=%s)\n' "${complete}" +fi + +# --- resolving at a past revision ------------------------------------------- +# The gate asks "what did that approval approve?" and the only honest answer +# comes from the approving run's own head. Believing the fingerprint written in +# a comment is what let a member reuse somebody else's approval. +check_at_sha 'a past revision is resolved from the comparison' \ + "$(compare_payload 2 aaa1111 'feat(scope)!: breaks the contract' \ + bbb2222 'fix(scope): ordinary work')" 1 +check_at_sha 'a past revision with nothing breaking' \ + "$(compare_payload 1 bbb2222 'fix(scope): ordinary work')" 0 +check_at_sha 'the footer form counts there too' \ + "$(compare_payload 1 ccc3333 'feat(scope): looks ordinary + +BREAKING CHANGE: the field was removed')" 1 + +# The same cap, and the same refusal. Answering from a truncated comparison +# would let an omitted breaking commit leave the fingerprint unchanged. +truncated=$(run_at_sha aaaa111 "$(compare_payload 300 aaa1111 'feat(scope)!: breaks')") +if [[ -z "$(sed -n 's/^count=//p' <<< "${truncated}")" ]]; then + PASSED=$((PASSED + 1)) + printf 'ok - a truncated comparison is refused\n' +else + FAILED=$((FAILED + 1)) + printf 'not ok - a truncated comparison produced a verdict (%s)\n' "${truncated}" +fi + +# Two revisions of the same pull request: the fingerprint has to differ once a +# breaking commit is added, or reuse cannot tell them apart. +fp_before=$( + run_at_sha aaaa111 "$(compare_payload 1 aaa1111 'feat(scope)!: breaks')" \ + | sed -n 's/^fingerprint=//p' +) +fp_after=$( + run_at_sha bbbb222 "$(compare_payload 2 aaa1111 'feat(scope)!: breaks' \ + ddd4444 'feat(other)!: breaks too')" \ + | sed -n 's/^fingerprint=//p' +) +if [[ -n "${fp_before}" && -n "${fp_after}" && "${fp_before}" != "${fp_after}" ]]; then + PASSED=$((PASSED + 1)) + printf 'ok - adding a breaking commit changes the fingerprint\n' +else + FAILED=$((FAILED + 1)) + printf 'not ok - the fingerprint did not change (%s vs %s)\n' "${fp_before}" "${fp_after}" +fi + +# And AT_SHA without a base cannot silently fall back to the current head. +if ! env GH_TOKEN=stub REPO=LerianStudio/example PR_NUMBER=42 \ + AT_SHA=aaaa111 COMMITS_B64='' bash "${RESOLVER}" >/dev/null 2>&1; then + PASSED=$((PASSED + 1)) + printf 'ok - AT_SHA without BASE_REF is refused\n' +else + FAILED=$((FAILED + 1)) + printf 'not ok - AT_SHA without BASE_REF was accepted\n' +fi + +printf '\n%s passed, %s failed\n' "${PASSED}" "${FAILED}" +[[ ${FAILED} -eq 0 ]] diff --git a/src/validate/breaking-change-guard/test-workflow.py b/src/validate/breaking-change-guard/test-workflow.py index 3a1cdb77..1cb0b6fc 100644 --- a/src/validate/breaking-change-guard/test-workflow.py +++ b/src/validate/breaking-change-guard/test-workflow.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import json import os import re import subprocess @@ -8,6 +9,8 @@ import unittest from pathlib import Path +import yaml + REPO_ROOT = Path(__file__).resolve().parents[3] WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "pr-validation.yml" @@ -48,6 +51,7 @@ "src/validate/environment-approvals", "src/notify/breaking-change-approval-reporter", "src/validate/codeowners-reviewers", + "src/validate/breaking-change-commits", ) ENVIRONMENT_PROTECTION = ( REPO_ROOT / "src" / "validate" / "environment-protection" / "action.yml" @@ -70,6 +74,7 @@ "breaking_change_close_on_rejection", "breaking_change_require_protected_environment", "breaking_change_approval_reviewers", + "breaking_change_fallback_reviewers", "breaking_change_create_environment", } @@ -213,6 +218,423 @@ def execute_body(body, env): return result, parse_github_output(output) +def render_reporter(state, **overrides): + """Render the reporter's comment for one state and return the Markdown. + + Asserting against the composite's source only proves a string exists + somewhere in it — swapping two states' alerts would keep every string + present and every assertion green. Running the thing and reading what it + produced is the only check that ties a state to its own output. + """ + action = yaml.safe_load( + ( + REPO_ROOT / "src" / "notify" / "breaking-change-approval-reporter" + / "action.yml" + ).read_text(encoding="utf-8") + ) + # Everything up to the posting is the part that builds the Markdown. + body = action["runs"]["steps"][0]["run"].split('comment_id=""')[0] + + env = { + "REPO": "LerianStudio/example", + "PR_NUMBER": "42", + "STATE": state, + "REASON": "rule develop <- '*'", + "HEAD_SHA": "abc1234567", + "ACKNOWLEDGED": "false", + "ACK_LINE": "Breaking change acknowledged: I understand that this PR " + "intentionally introduces a breaking change and requires the next " + "release to be a major version.", + "RUN_URL": "https://github.com/LerianStudio/example/actions/runs/1", + "REVIEWERS": "team:devops", + "SERVER_URL": "https://github.com", + "BADGE_URL": "https://img.shields.io/badge/x-1f6feb", + "DOCS_URL": "https://example.invalid/docs", + "APPROVERS": "carol", + "REJECTERS": "dave", + "REJECTION_COMMENT": "", + "CLOSE_ON_REJECTION": "true", + "LABEL": "breaking-change", + "ENABLED": "true", + "GH_TOKEN": "stub", + # Empty: these fixtures exercise the standalone path, where the + # composite reads the commits itself and `MOCK_SUBJECTS` feeds the + # stubbed API. Pass `SUBJECTS` to exercise the forwarded set instead. + "SUBJECTS": "", + "MOCK_SUBJECTS": "", + } + env.update(overrides) + + with tempfile.TemporaryDirectory() as stub_dir: + stub = Path(stub_dir) / "gh" + stub.write_text( + "#!/usr/bin/env bash\n" + "case \"$*\" in\n" + " *\"/commits\"*) printf '%s\\n'" + ' "${MOCK_SUBJECTS:-feat(scope)!: a breaking subject}"' + " 'chore: an ordinary one' ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + stub.chmod(0o755) + env["PATH"] = f"{stub_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}" + result = subprocess.run( + ["bash", "-c", body + '\ncat "$body_file"'], + cwd=stub_dir, + env=env, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise AssertionError(f"reporter failed for state {state!r}: {result.stderr}") + return result.stdout + + +def run_verdict(**overrides): + """Run the gate's verdict step and return its outputs. + + Asserting that the cancellation branch contains `exit 0` would only prove + the characters are there. Running it with a recorded rejection in hand is + what proves the branch actually stops: without the exit, the rejection + downstream wins and the pull request gets closed. + """ + workflow = yaml.safe_load(APPROVAL_WORKFLOW) + step = next( + s + for s in workflow["jobs"]["outcome"]["steps"] + if s.get("name") == "Resolve the verdict" + ) + + env = { + "GH_TOKEN": "stub", + "REPO": "LerianStudio/example", + "HEAD_SHA": "abc123", + "DETECT_RESULT": "success", + "REQUEST_RESULT": "success", + "APPROVAL_RESULT": "success", + "IN_SCOPE": "true", + "REASON": "rule develop <- '*'", + "PR_NUMBER": "42", + "HAS_BREAKING_CHANGES": "true", + "DETECTION_SUCCEEDED": "true", + "APPROVERS": "", + "REJECTERS": "", + "API_READABLE": "true", + "DRY_RUN": "false", + "LIVE_SHA": "abc123", + "ALREADY_APPROVED": "false", + "HEAD_REF": "feat/x", + "RUN_ID": "100", + "MOCK_NEWER_RUNS": "0", + # Production always supplies these; the fixture did not, so the label + # cleanup expanded them under `set -u` and the step died with + # `LABEL: unbound variable` after emitting its outputs. Every caller + # read the outputs and ignored the exit code, so the suite stayed green + # on a step that had crashed. + "LABEL": "breaking-change", + "PENDING_LABEL": "awaiting-breaking-approval", + } + env.update(overrides) + + # `MOCK_NEWER_RUNS=N` is shorthand for N newer runs of this workflow, all + # belonging to this pull request. Pass `MOCK_RUNS_JSON` directly to say + # something the shorthand cannot — a run of another pull request, or a run + # from a fork with no `pull_requests` at all. + if "MOCK_RUNS_JSON" not in env: + run_id = int(env["RUN_ID"]) + pr_number = int(env["PR_NUMBER"] or 0) + env["MOCK_RUNS_JSON"] = json.dumps( + { + "workflow_runs": [ + { + "id": run_id + 1 + i, + "event": "pull_request", + "head_sha": env["HEAD_SHA"], + "pull_requests": [{"number": pr_number}], + } + for i in range(int(env["MOCK_NEWER_RUNS"])) + ] + } + ) + + with tempfile.TemporaryDirectory() as temp_dir: + stub = Path(temp_dir) / "gh" + # The stub runs the real `--jq` against a real payload. Returning the + # count directly was how the branch-wide query survived a whole round + # of review: the filter was never executed, so no test could see what + # it selected. Here the query is the thing under test. + stub.write_text( + "#!/usr/bin/env bash\n" + # Every invocation is recorded. Label behaviour was pinned by + # grepping the step for its loop, which is how a loop removing the + # wrong label stayed green — the assertion described the code + # rather than what it did. + 'printf \'%s\\n\' "$*" >> "$GH_CALLS"\n' + 'jq_query=""\n' + 'prev=""\n' + 'for arg in "$@"; do\n' + ' [ "$prev" = "--jq" ] && jq_query="$arg"\n' + ' prev="$arg"\n' + "done\n" + 'case "$*" in\n' + ' *headRefOid*) printf \'%s\\n\' "$LIVE_SHA" ;;\n' + # Only the workflow-scoped URL answers. A query against + # `actions/runs?branch=` — every workflow on the branch — gets + # nothing, so a regression to it reads as "no newer run" and fails + # the supersession cases rather than passing them silently. + ' *"/actions/workflows/"*"/runs?branch"*)' + ' printf \'%s\' "$MOCK_RUNS_JSON" | jq -r "$jq_query" ;;\n' + ' *"actions/runs/"*) printf \'%s\\n\' "4242" ;;\n' + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + stub.chmod(0o755) + output_path = Path(temp_dir) / "github-output" + output_path.touch() + calls_path = Path(temp_dir) / "gh-calls" + calls_path.touch() + env["GH_CALLS"] = str(calls_path) + env["GITHUB_OUTPUT"] = str(output_path) + env["GITHUB_STEP_SUMMARY"] = str(Path(temp_dir) / "summary") + env["PATH"] = f"{temp_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}" + result = subprocess.run( + ["bash", "-c", step["run"]], + cwd=temp_dir, + env=env, + text=True, + capture_output=True, + check=False, + ) + result.gh_calls = calls_path.read_text(encoding="utf-8").splitlines() + if result.returncode != 0: + raise AssertionError( + "the verdict step exited %d:\n%s" + % (result.returncode, result.stderr.strip()) + ) + return result, parse_github_output(output_path.read_text(encoding="utf-8")) + + +def run_detect_normalization(**overrides): + """Run the gate's own normalization step and return its outputs. + + The public `has_breaking_changes` output is this step's value passed + straight through, so the documented contract — what a caller reads out of + scope — lives here and nowhere else. + """ + workflow = yaml.safe_load(APPROVAL_WORKFLOW) + step = next( + s + for s in workflow["jobs"]["detect"]["steps"] + if s.get("name") == "Normalize detection result" + ) + + env = { + "IN_SCOPE": "true", + "SUPPLIED_HAS_BREAKING_CHANGES": "", + "SUPPLIED_ACKNOWLEDGED": "", + "COMMITS_OUTCOME": "success", + "BREAKING_COUNT": "0", + } + env.update(overrides) + + # Selection and defaults here, execution in `execute_body` — which already + # owns the temporary directory, `GITHUB_OUTPUT` and the parsing. This used + # to carry its own copy of all three. + _, outputs = execute_body(step["run"], env) + return outputs + + +def run_reporter_comment( + state, existing_body=None, read_fails=False, legacy_ids="", **overrides +): + """Run the reporter with commenting on, and report what it did to the comment. + + Returns (process, calls) where `calls` lists every `gh` invocation, so a + test can say whether a PATCH was issued and what body it carried. + + `render_reporter` stops before the comment handling and + `run_reporter_labels` turns commenting off, so the record-preservation + path — the one thing standing between a transient API error and every + approval on the pull request being erased — had no executable coverage at + all. It was pinned by grepping the composite for its own error message. + """ + action = yaml.safe_load( + ( + REPO_ROOT / "src" / "notify" / "breaking-change-approval-reporter" + / "action.yml" + ).read_text(encoding="utf-8") + ) + body = action["runs"]["steps"][0]["run"] + + env = { + "REPO": "LerianStudio/example", + "PR_NUMBER": "42", + "STATE": state, + "REASON": "rule develop <- '*'", + "HEAD_SHA": "abc1234567", + "ACKNOWLEDGED": "true", + "ACK_LINE": "Breaking change acknowledged: …", + "SUBJECTS": "", + "RUN_URL": "https://github.com/LerianStudio/example/actions/runs/1", + "REVIEWERS": "team:devops", + "SERVER_URL": "https://github.com", + "BADGE_URL": "https://img.shields.io/badge/x-1f6feb", + "DOCS_URL": "https://example.invalid/docs", + "APPROVERS": "carol", + "REJECTERS": "", + "REJECTION_COMMENT": "", + "CLOSE_ON_REJECTION": "true", + "LABEL": "breaking-change", + "PENDING_LABEL": "awaiting-breaking-approval", + "ENABLED": "true", + "FINGERPRINT": "", + "RUN_ID": "1", + "GH_TOKEN": "stub", + } + env.update(overrides) + + with tempfile.TemporaryDirectory() as temp_dir: + calls = Path(temp_dir) / "calls" + calls.touch() + bodies = Path(temp_dir) / "bodies" + bodies.touch() + existing = Path(temp_dir) / "existing-body" + existing.write_text(existing_body or "", encoding="utf-8") + + # `read_fails` is the whole point: the composite must refuse rather + # than treat an unreadable comment as one holding no records. + read_arm = ( + " *\"issues/comments/\"*) exit 1 ;;\n" + if read_fails + else f" *\"issues/comments/\"*) cat '{existing}' ;;\n" + ) + stub = Path(temp_dir) / "gh" + stub.write_text( + "#!/usr/bin/env bash\n" + f'printf \'%s\\n\' "$*" >> "{calls}"\n' + # The body goes out as `-F body=@` and the composite + # deletes that file on exit, so keep a copy while it still exists. + 'for arg in "$@"; do\n' + ' case "$arg" in\n' + f' body=@*) cat "${{arg#body=@}}" >> "{bodies}" ;;\n' + " esac\n" + "done\n" + 'case "$*" in\n' + " *\"pr edit\"*) exit 0 ;;\n" + " *\"/commits\"*) printf '%s\\n' 'feat(x)!: breaks' ;;\n" + # The listing is queried twice with different `--jq`: once for + # this composite's own comment, once for legacy ones to retire. + # Answer each by what it asked for. + " *\"issues/42/comments\"*)\n" + ' case "$*" in\n' + f" *lerian-breaking-change-guard*) printf '%s\\n' '{legacy_ids}' ;;\n" + " *) printf '%s\\n' '7' ;;\n" + " esac ;;\n" + + read_arm + + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + stub.chmod(0o755) + env["PATH"] = f"{temp_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}" + env["GITHUB_OUTPUT"] = str(Path(temp_dir) / "out") + env["GITHUB_STEP_SUMMARY"] = str(Path(temp_dir) / "summary") + Path(env["GITHUB_OUTPUT"]).touch() + result = subprocess.run( + ["bash", "-c", body], + cwd=temp_dir, + env=env, + text=True, + capture_output=True, + check=False, + ) + result.posted_body = bodies.read_text(encoding="utf-8") + return result, calls.read_text(encoding="utf-8").splitlines() + + +def run_reporter_labels(state, **overrides): + """Run the reporter end to end and return the `gh pr edit` calls it made. + + `render_reporter` stops before the label block, so asserting on the + composite's text was the only coverage the lifecycle had — and text cannot + tell which state made which call. Moving `--add-label "$LABEL"` into the + `waiting` branch would keep every string in place and break the behaviour. + """ + action = yaml.safe_load( + ( + REPO_ROOT / "src" / "notify" / "breaking-change-approval-reporter" + / "action.yml" + ).read_text(encoding="utf-8") + ) + body = action["runs"]["steps"][0]["run"] + + env = { + "REPO": "LerianStudio/example", + "PR_NUMBER": "42", + "STATE": state, + "REASON": "rule develop <- '*'", + "HEAD_SHA": "abc1234567", + "ACKNOWLEDGED": "false", + "ACK_LINE": "Breaking change acknowledged: …", + "RUN_URL": "https://github.com/LerianStudio/example/actions/runs/1", + "REVIEWERS": "team:devops", + "SERVER_URL": "https://github.com", + "BADGE_URL": "https://img.shields.io/badge/x-1f6feb", + "DOCS_URL": "https://example.invalid/docs", + "APPROVERS": "carol", + "REJECTERS": "dave", + "REJECTION_COMMENT": "", + "CLOSE_ON_REJECTION": "true", + "LABEL": "breaking-change", + "PENDING_LABEL": "awaiting-breaking-approval", + "ENABLED": "false", + "SUBJECTS": "", + "FINGERPRINT": "", + "RUN_ID": "1", + "GH_TOKEN": "stub", + } + env.update(overrides) + + with tempfile.TemporaryDirectory() as temp_dir: + calls = Path(temp_dir) / "calls" + calls.touch() + stub = Path(temp_dir) / "gh" + stub.write_text( + "#!/usr/bin/env bash\n" + 'case "$*" in\n' + " *\"pr edit\"*)" + " printf '%s\\n' \"$*\" | grep -oE '(add|remove)-label [a-z-]+'" + f' >> "{calls}" ;;\n' + " *\"/commits\"*) printf '%s\\n' 'feat(x)!: breaks' ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + stub.chmod(0o755) + env["PATH"] = f"{temp_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}" + env["GITHUB_OUTPUT"] = str(Path(temp_dir) / "out") + result = subprocess.run( + ["bash", "-c", body], + cwd=temp_dir, + env=env, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise AssertionError(f"reporter failed for {state!r}: {result.stderr}") + # `set -u` firing inside a command substitution kills the subshell and + # leaves the script running with exit 0. A fixture missing a variable + # then reads as "the step made no calls" — a green test on a step that + # half ran. The exit code cannot see it; stderr can. + if "unbound variable" in result.stderr: + raise AssertionError(f"incomplete fixture for {state!r}: {result.stderr}") + return calls.read_text(encoding="utf-8").split() + + def parse_github_output(output): values = {} for line in output.splitlines(): @@ -513,22 +935,30 @@ def test_guard_uses_fixed_author_acknowledgement_exact_mode_and_released_action( self.assertIn("does not grant maintainer permission", outputs) def test_detector_and_comment_acknowledgement_literals_are_identical(self): + # One literal tells the author what to paste, the other decides whether + # what they pasted counts. A drift between them leaves the gate teaching + # a line that fails the check it is explaining. detector_step = extract_step( extract_job(WORKFLOW, "breaking-change-guard"), "Detect breaking changes" ) - comment_job = extract_job(WORKFLOW, "breaking-change-comment") detector_literal = re.search( r"(?m)^\s*breaking-change-acknowledgement: '(?P[^']*)'$", detector_step, ) - comment_literal = re.search( - r"(?m)^\s*ACKNOWLEDGEMENT: '(?P[^']*)'$", comment_job - ) self.assertIsNotNone(detector_literal) - self.assertIsNotNone(comment_literal) self.assertEqual(ACKNOWLEDGEMENT, detector_literal.group("value")) + + # The gate is handed the same literal explicitly — relying on its + # default to coincide is what made these drift-prone in the first place. + gate_job = extract_job(WORKFLOW, "breaking-change-approval") + gate_literal = re.search( + r"(?m)^\s*author_acknowledgement: '(?P[^']*)'$", gate_job + ) + self.assertIsNotNone( + gate_literal, "pr-validation must pass the acknowledgement explicitly" + ) self.assertEqual( - detector_literal.group("value"), comment_literal.group("value") + detector_literal.group("value"), gate_literal.group("value") ) def test_guard_checkout_has_full_history_without_persisted_credentials(self): @@ -555,53 +985,6 @@ def test_draft_pull_requests_still_reach_guard_enforcement(self): self.assertRegex(enforcement, r"(?m)^ if: always\(\)$") self.assertIn("github.event.pull_request.draft != true", collection) - def test_comment_job_is_write_minimal_and_does_not_checkout(self): - job = extract_job(WORKFLOW, "breaking-change-comment") - permissions = re.search( - r"(?m)^ permissions:\n(?P(?: [^\n]+\n?)+)", job - ) - self.assertIsNotNone(permissions) - self.assertEqual( - {"pull-requests: read", "issues: write"}, - {line.strip() for line in permissions.group("body").splitlines()}, - ) - self.assertNotIn("contents:", permissions.group("body")) - self.assertNotIn("actions/checkout", job) - self.assertIn("github-token: ${{ github.token }}", job) - - def test_comment_job_owns_only_exact_bot_marker_comments(self): - job = extract_job(WORKFLOW, "breaking-change-comment") - self.assertIn("comment.user?.login === 'github-actions[bot]'", job) - self.assertIn("comment.body === marker", job) - self.assertIn("comment.body?.startsWith(`${marker}\\n`)", job) - self.assertNotIn("comment.body?.includes(marker)", job) - self.assertEqual(2, job.count("const body = `${marker}\\n")) - - def test_comment_job_uses_author_acknowledgement_not_authorization(self): - job = extract_job(WORKFLOW, "breaking-change-comment") - self.assertIn("'✅ Author acknowledged'", job) - self.assertIn("'🚫 Awaiting author acknowledgement'", job) - self.assertIn("does not grant maintainer permission", job) - self.assertNotIn("'✅ Approved'", job) - self.assertNotIn("'🚫 Awaiting approval'", job) - - def test_comment_job_rejects_stale_head_updates(self): - job = extract_job(WORKFLOW, "breaking-change-comment") - self.assertIn("EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}", job) - self.assertIn("github.rest.pulls.get", job) - self.assertIn("current.data.head.sha !== eventHeadSha", job) - - def test_comment_job_reconciles_detector_failure(self): - job = extract_job(WORKFLOW, "breaking-change-comment") - self.assertIn("if (!guardJobSucceeded || !detectionSucceeded)", job) - self.assertIn("**Status:** ⚠️ Detection failed", job) - self.assertIn("Blocking Checks failed closed.", job) - failure_branch = job.index("if (!guardJobSucceeded || !detectionSucceeded)") - no_breaking_branch = job.index("if (!hasBreakingChanges)") - self.assertIn( - "await upsertOwned(body);", job[failure_branch:no_breaking_branch] - ) - def test_reporter_and_summary_receive_guard_and_blocking_runtime_results(self): for job_id, step_name in ( ("pr-checks-summary", "PR Checks Summary"), @@ -711,6 +1094,73 @@ def test_analysis_workflows_expose_scoped_test_runner_overrides(self): ) +class CommentPermissionTests(unittest.TestCase): + def test_jobs_that_comment_on_a_pull_request_can_write_to_it(self): + # Commenting on a pull request needs `pull-requests: write`. The + # issue-comments endpoint is the one used, but GitHub authorises the + # subject as a pull request, so `issues: write` is not enough — it + # covers real issues only. The mistake fails as a 403 at runtime, and + # these steps are continue-on-error, so it reports success. + workflow = yaml.safe_load(WORKFLOW) + top_level = workflow.get("permissions") or {} + + for job_id, job in workflow["jobs"].items(): + if not isinstance(job, dict): + continue + body = yaml.safe_dump(job) + if not any( + marker in body + for marker in ("createComment", "updateComment", "issues/comments") + ): + continue + + with self.subTest(job=job_id): + # A job-level block replaces the workflow's, it does not merge, + # so an explicit block has to carry the permission itself. + effective = job.get("permissions") or top_level + self.assertEqual( + "write", + effective.get("pull-requests"), + f"{job_id} comments on a pull request and would take a 403", + ) + +class AdvisoryChecksTests(unittest.TestCase): + def test_advisory_checks_do_not_cascade_off_a_blocking_failure(self): + job = extract_job(WORKFLOW, "advisory-checks") + condition = next( + line for line in job.splitlines() if line.strip().startswith("if:") + ) + # Advisory by definition: a wrong title or a missing signature must not + # also cost the pull request its size label, its auto-labels and its + # assignee. Those are most useful precisely on a pull request that is + # already in trouble. + self.assertNotIn("needs.blocking-checks.result", condition) + self.assertIn("always()", condition) + self.assertIn("github.event.pull_request.draft != true", condition) + + def test_advisory_checks_cannot_become_a_new_blocker(self): + job = extract_job(WORKFLOW, "advisory-checks") + # Running them unconditionally is only safe while none of them can fail + # the job — otherwise this change would turn three advisory checks into + # a blocking one. + for step_name in ("Check PR metadata", "Check PR size", "Auto-label PR"): + with self.subTest(step=step_name): + self.assertIn("continue-on-error: true", extract_step(job, step_name)) + + def test_the_reports_survive_an_advisory_failure(self): + # The checkout in advisory-checks has no continue-on-error, so the job + # can still fail. Everything that reports must run anyway. + # + # Read from the parsed job, not from its text: `assertIn` over the whole + # job body is satisfied by a step, or even by this comment, while the + # job-level `needs` and `if` say something else entirely. + jobs = yaml.safe_load(WORKFLOW)["jobs"] + for job_id in ("pr-checks-summary", "pr-validation-report"): + with self.subTest(job=job_id): + self.assertIn("advisory-checks", jobs[job_id]["needs"]) + self.assertIn("always()", jobs[job_id]["if"]) + + class ApprovalGateStructureTests(unittest.TestCase): """The human approval gate — wiring that must not drift. @@ -810,16 +1260,1200 @@ def test_reviewers_come_from_codeowners_on_the_base_ref(self): # branch would let the author choose who approves their own change. self.assertIn("ref: ${{ needs.detect.outputs.base-ref }}", step) self.assertNotIn("head", step) - # Explicit reviewers win; CODEOWNERS is the fallback. - preflight = extract_step( + # Explicit reviewers win, then CODEOWNERS, then the fallback. Order + # matters: a caller that named its reviewers must not be overridden by + # a repository-wide default, and the default must not be reached while + # CODEOWNERS still has an answer. + workflow = yaml.safe_load(APPROVAL_WORKFLOW) + preflight = next( + s + for s in workflow["jobs"]["request"]["steps"] + if s.get("name") == "Verify the breaking-approval Environment is protected" + ) + expression = " ".join(preflight["with"]["reviewers"].split()) + self.assertEqual( + "${{ inputs.approval_reviewers != '' && inputs.approval_reviewers" + " || (steps.codeowners.outputs.reviewers != ''" + " && steps.codeowners.outputs.reviewers" + " || inputs.fallback_reviewers) }}", + expression, + ) + + def test_a_repository_without_a_catch_all_rule_still_has_a_reviewer(self): + # Measured across the consumers before adding this: four of five + # CODEOWNERS files carry no `*` rule, and `codeowners-reviewers` reads + # the catch-all by design — a repository-wide gate should ask the + # repository-wide owner. So those four resolved nobody, the Environment + # could not be created, and the first breaking change in each would + # have been blocked by a gate with no one able to answer it. + gate = yaml.safe_load(APPROVAL_WORKFLOW) + self.assertIn("fallback_reviewers", gate[True]["workflow_call"]["inputs"]) + # Empty by default: refusing is still better than inventing a reviewer. + self.assertEqual( + "", gate[True]["workflow_call"]["inputs"]["fallback_reviewers"]["default"] + ) + + # The umbrellas expose it, and `pr-validation` sources the default from + # an organisation variable rather than naming a team in the code. + validation = yaml.safe_load( + (REPO_ROOT / ".github" / "workflows" / "pr-validation.yml").read_text( + encoding="utf-8" + ) + ) + forwarded = validation["jobs"]["breaking-change-approval"]["with"][ + "fallback_reviewers" + ] + self.assertIn("vars.BREAKING_APPROVAL_FALLBACK_REVIEWERS", forwarded) + self.assertIn("inputs.breaking_change_fallback_reviewers", forwarded) + + for name in ("go-pr-validation", "js-pr-validation"): + umbrella = yaml.safe_load( + (REPO_ROOT / ".github" / "workflows" / f"{name}.yml").read_text( + encoding="utf-8" + ) + ) + self.assertIn( + "breaking_change_fallback_reviewers", + umbrella[True]["workflow_call"]["inputs"], + name, + ) + + # No team name in the code — the organisation variable is the source. + self.assertNotIn("G_Github_Devops", APPROVAL_WORKFLOW) + + def test_the_waiting_comment_links_instead_of_mentioning(self): + rendered = render_reporter("waiting") + # Links to the commit, the decision, the reviewers and the docs. + for fragment in ( + "](https://github.com/LerianStudio/example/commit/abc1234567)", + "[![Review the pending deployment](https://img.shields.io/badge/", + "](https://github.com/orgs/LerianStudio/teams/devops)", + "[How this gate works](https://example.invalid/docs)", + ): + with self.subTest(fragment=fragment): + self.assertIn(fragment, rendered) + + # The pending deployment already notifies the reviewers; a bare @mention + # would ping the whole team again for the same thing. + # + # Same technique as the decider test: delete the complete links, then + # require nothing to remain. This assertion used to check only that an + # `@` was preceded by `[`, which a bare `[@LerianStudio/devops]` with no + # target satisfies. + without_links = re.sub( + r"\[@LerianStudio/\w+\]\(https://github\.com/orgs/LerianStudio/teams/\w+\)", + "", + rendered, + ) + self.assertNotIn("@LerianStudio/", without_links) + + def test_only_one_approval_request_stays_open_per_pull_request(self): + job = extract_job(APPROVAL_WORKFLOW, "approval") + # Every push queues another pending deployment, and the old ones do not + # expire on their own: approving a superseded one approves a revision + # nobody is merging. Cancelling is also what expires an approval, so the + # next revision cannot inherit a yes given to different code. + self.assertIn("concurrency:", job) + self.assertIn( + "group: breaking-approval-${{ github.repository }}" + "-${{ needs.detect.outputs.pr-number }}", + job, + ) + self.assertIn("cancel-in-progress: true", job) + + def test_a_superseded_run_leaves_the_pull_request_alone(self): + body = extract_run_body( + extract_job(APPROVAL_WORKFLOW, "outcome"), "Resolve the verdict" + ) + # The cancelled run still reaches the verdict. Without this it would + # overwrite the live revision's comment with "did not complete". + self.assertIn("state=superseded", body) + self.assertIn('"$live_sha" != "$HEAD_SHA"', body) + # A changed head is not the only way to be superseded: `edited` queues a + # new run on the very same commit, so a cancelled approval has to count + # on its own. Observed on #906 — editing the description replaced the + # run, and the comparison above saw nothing wrong while the comment was + # overwritten with "decision not recorded". + # A cancellation is a reason to look, not a verdict. It passes only + # with supersession established — a newer run, or the head having moved + # on. Treating every cancellation as supersession reported success for + # revisions nobody approved, which is what Radagast caught on #926. + self.assertIn('[ "$APPROVAL_RESULT" = "cancelled" ]', body) + self.assertIn("actions/workflows/${workflow_id}/runs?branch=${HEAD_REF}", body) + # Scoped to this workflow, never to the branch at large: the sibling + # runs from the same push start later and carry higher ids, so the + # broad query answers "superseded" for every cancellation. + self.assertNotIn("/actions/runs?branch=", body) + _, stranded = run_verdict(APPROVAL_RESULT="cancelled", MOCK_NEWER_RUNS="0") + self.assertEqual("unresolved", stranded["state"]) + + def test_a_cancelled_approval_stops_before_anything_can_act_on_it(self): + # Run it, with a recorded rejection sitting right there. The branch has + # to stop: if it falls through, the rejection downstream wins, the + # verdict comes back `rejected`, and the pull request gets closed — + # because a concurrency cancellation raced a reviewer. + # With a newer run established, so the branch under test is the + # cancellation one. The guarantee is that it stops before the rejection + # downstream can close the pull request. + _, outputs = run_verdict( + APPROVAL_RESULT="cancelled", REJECTERS="dave", MOCK_NEWER_RUNS="1" + ) + self.assertEqual("superseded", outputs["state"]) + self.assertNotEqual("rejected", outputs["state"]) + + def test_a_standing_approval_skips_the_request_entirely(self): + step = extract_step( extract_job(APPROVAL_WORKFLOW, "request"), + "Request the approval on the pull request", + ) + # Observed on #920: with the approval already standing, the run still + # announced "needs a decision" and added the pending label, and the + # verdict took both back seconds later. A flicker in the timeline for a + # decision that was never in question. + self.assertIn("steps.reuse.outputs.already-approved != 'true'", step) + + def test_the_decider_is_linked_not_mentioned(self): + for state, env, who in ( + ("approved", {"APPROVERS": "carol"}, "carol"), + ("rejected", {"REJECTERS": "dave"}, "dave"), + ): + with self.subTest(state=state): + rendered = render_reporter(state, **env) + self.assertIn(f"[@{who}](https://github.com/{who})", rendered) + + # Linked, not mentioned: the person named is the one who just + # acted, so a notification tells them what they already know. + # + # Checked by deleting the complete links and requiring nothing + # to remain. "preceded by `[`" would accept a bare `[@carol]` + # with no target — present, bracketed, and not a link. + without_links = re.sub( + rf"\[@{who}\]\(https://github\.com/{who}\)", "", rendered + ) + self.assertNotIn(f"@{who}", without_links) + + def test_an_approval_already_given_is_not_asked_for_again(self): + # The reviewer said yes to a breaking change. A push that leaves that + # change alone must not put the same question back in their queue — + # asking repeatedly for unchanged content is how a gate stops being + # read. The approval job is skipped, so there is no Environment + # decision; the verdict comes from the record on the pull request. + _, outputs = run_verdict(ALREADY_APPROVED="true", APPROVAL_RESULT="skipped") + self.assertEqual("approved", outputs["state"]) + self.assertEqual("success", outputs["result"]) + + job = extract_job(APPROVAL_WORKFLOW, "approval") + self.assertIn( + "needs.request.outputs.already-approved != 'true'", + job, + "the Environment gate must be skipped when the change was already approved", + ) + + def test_environment_work_stays_inside_its_scope(self): + workflow = yaml.safe_load(APPROVAL_WORKFLOW) + # Out of scope no approval is required, so nothing that touches the + # Environment may run there. #918 removed the only thing enforcing this + # when it started preserving the caller's real detection. + self.assertIn( + "needs.detect.outputs.in-scope == 'true'", + str(workflow["jobs"]["approval"]["if"]), + ) + gated = { + "Check for a prior approval of the same change", + "Derive the reviewers from CODEOWNERS", "Verify the breaking-approval Environment is protected", + "Request the approval on the pull request", + } + for step in workflow["jobs"]["request"]["steps"]: + name = step.get("name") + if name in gated: + with self.subTest(step=name): + self.assertIn("in-scope == 'true'", str(step.get("if", ""))) + elif name == "Report the acknowledgement requirement": + # The one that must run outside scope: the acknowledgement is + # enforced everywhere, so the author still needs the line. + self.assertIn("in-scope != 'true'", str(step.get("if", ""))) + + def test_a_cancellation_passes_only_when_superseded(self): + body = extract_run_body( + extract_job(APPROVAL_WORKFLOW, "outcome"), "Resolve the verdict" + ) + # Run it: cancelled with nothing replacing it must not pass. + _, outputs = run_verdict(APPROVAL_RESULT="cancelled", MOCK_NEWER_RUNS="0") + self.assertEqual("unresolved", outputs["state"]) + self.assertNotEqual("superseded", outputs["state"]) + + # And the two ways supersession is genuinely established. + _, by_run = run_verdict(APPROVAL_RESULT="cancelled", MOCK_NEWER_RUNS="2") + self.assertEqual("superseded", by_run["state"]) + _, by_head = run_verdict(APPROVAL_RESULT="cancelled", LIVE_SHA="def456") + self.assertEqual("superseded", by_head["state"]) + + self.assertIn("actions/workflows/${workflow_id}/runs?branch=${HEAD_REF}", body) + + def test_the_public_breaking_output_reports_detection_out_of_scope(self): + # A deliberate behaviour change, documented rather than versioned. The + # reason is specific to this output and not the one that covers the + # `label` input: the umbrellas do not surface it. Their + # `has_breaking_changes` comes from the `breaking-change-guard` job, + # which is unchanged, so only a standalone caller reads this value. + # + # It used to be forced to `false` out of scope; it now reports what the + # caller detected, because a pull request this workflow does not gate + # can still carry a break and the caller asking deserves the answer. + out_of_scope = run_detect_normalization( + IN_SCOPE="false", SUPPLIED_HAS_BREAKING_CHANGES="true" + ) + self.assertEqual("true", out_of_scope["has-breaking-changes"]) + self.assertEqual("true", out_of_scope["detection-succeeded"]) + + # Nothing supplied is still `false` — there is no answer to report, and + # the gate never scans out of scope. + nothing_supplied = run_detect_normalization(IN_SCOPE="false") + self.assertEqual("false", nothing_supplied["has-breaking-changes"]) + + # And the description must not promise the old value back. Documenting + # `false` while emitting `true` is the defect Radagast caught on #926. + workflow = yaml.safe_load(APPROVAL_WORKFLOW) + described = workflow[True]["workflow_call"]["outputs"]["has_breaking_changes"][ + "description" + ] + self.assertNotIn("Always false when out of scope", described) + self.assertIn("Behaviour change", described) + + def test_a_failed_enumeration_is_unknown_not_a_clean_bill(self): + # The step that turns the commit list into a verdict was only ever run + # out of scope. Radagast proved the gap by mutation: flipping the + # failed-enumeration branch to report success left the suite green, + # and that mutation turns "we could not look" into "nothing found" — + # the one reading the gate exists to refuse. + # + # Reached when the pull request is above the 250-commit cap, when the + # API is unreadable, or when the composite refuses for any other + # reason. In every one of them nobody has seen the whole change. + for description, overrides in ( + ("the composite failed", {"COMMITS_OUTCOME": "failure", "BREAKING_COUNT": ""}), + ("it succeeded but emitted no count", {"BREAKING_COUNT": ""}), + ("it failed with a stale count", {"COMMITS_OUTCOME": "failure"}), + ): + with self.subTest(description): + outputs = run_detect_normalization(**overrides) + self.assertEqual("false", outputs["detection-succeeded"], description) + self.assertEqual("false", outputs["has-breaking-changes"], description) + + # And the verdict downstream has to block on it rather than pass. + _, verdict = run_verdict(DETECTION_SUCCEEDED="false") + self.assertEqual("blocked", verdict["state"]) + self.assertEqual("failure", verdict["result"]) + self.assertEqual("false", verdict["approved"]) + + # A caller that did its own detection is not blocked by the local + # enumeration failing — it supplied the answer. The fingerprint is + # empty, so no approval is reused, and a fresh decision is asked for. + supplied = run_detect_normalization( + COMMITS_OUTCOME="failure", + BREAKING_COUNT="", + SUPPLIED_HAS_BREAKING_CHANGES="true", + ) + self.assertEqual("true", supplied["detection-succeeded"]) + self.assertEqual("true", supplied["has-breaking-changes"]) + + def test_a_newer_run_of_another_pull_request_is_not_supersession(self): + # One head branch can carry two pull requests — same branch, different + # bases. Scoping the query to this workflow leaves that open: the other + # pull request's run is newer, on this branch, of this workflow, and + # without the `.pull_requests` filter it would carry a decision for a + # pull request it knows nothing about. + runs = { + "workflow_runs": [ + {"id": 101, "event": "pull_request", "pull_requests": [{"number": 77}]}, + {"id": 102, "event": "pull_request", "pull_requests": [{"number": 77}]}, + ] + } + _, other_pr = run_verdict( + APPROVAL_RESULT="cancelled", MOCK_RUNS_JSON=json.dumps(runs) + ) + self.assertEqual("unresolved", other_pr["state"]) + + # Mixed: the same two, plus one that is actually ours. + runs["workflow_runs"].append( + {"id": 103, "event": "pull_request", "pull_requests": [{"number": 42}]} + ) + _, ours = run_verdict( + APPROVAL_RESULT="cancelled", MOCK_RUNS_JSON=json.dumps(runs) + ) + self.assertEqual("superseded", ours["state"]) + + def test_a_run_from_a_fork_is_matched_on_the_head_not_the_branch(self): + # Runs from forks come back with `pull_requests: []`. Dropping them + # would make every cancellation on a fork unresolved; accepting any run + # that has none is how a dispatched run passed for a pull request it + # had nothing to do with. They are matched on the head instead, which + # ties the run to this revision. + same_head = { + "workflow_runs": [ + { + "id": 101, + "event": "pull_request", + "head_sha": "abc123", + "pull_requests": [], + } + ] + } + _, fork = run_verdict( + APPROVAL_RESULT="cancelled", MOCK_RUNS_JSON=json.dumps(same_head) + ) + self.assertEqual("superseded", fork["state"]) + + other_head = json.loads(json.dumps(same_head)) + other_head["workflow_runs"][0]["head_sha"] = "def456" + _, unrelated = run_verdict( + APPROVAL_RESULT="cancelled", MOCK_RUNS_JSON=json.dumps(other_head) + ) + self.assertEqual("unresolved", unrelated["state"]) + + def test_a_dispatched_run_is_not_supersession(self): + # Reported by @fredcamaral and reproduced: with no event filter, any + # newer run of this workflow on the branch counted. Dispatch one by + # hand, cancel the pending approval, and the verdict reads + # `superseded` — which exits 0. A green gate with no approval, for + # anybody with write access. + # + # `self-pr-validation.yml` enables `workflow_dispatch` and skips + # validation on it, so the dispatched run does not even have to do + # anything. + for description, run in ( + ( + "dispatched, no pull request attached", + {"id": 101, "event": "workflow_dispatch", "head_sha": "abc123", + "pull_requests": []}, + ), + ( + "dispatched, naming this pull request", + {"id": 101, "event": "workflow_dispatch", "head_sha": "abc123", + "pull_requests": [{"number": 42}]}, + ), + ( + "a scheduled run on the same branch", + {"id": 101, "event": "schedule", "head_sha": "abc123", + "pull_requests": []}, + ), + ): + with self.subTest(description): + _, outputs = run_verdict( + APPROVAL_RESULT="cancelled", + MOCK_RUNS_JSON=json.dumps({"workflow_runs": [run]}), + ) + self.assertEqual("unresolved", outputs["state"], description) + + # And `unresolved` must not pass, which is the half that makes the + # filter matter. + body = extract_run_body( + extract_job(APPROVAL_WORKFLOW, "outcome"), "Record the verdict" + ) + self.assertIn('[ "$STATE" = "superseded" ]', body) + self.assertNotIn('[ "$STATE" = "unresolved" ]', body) + + def test_a_reused_approval_must_belong_to_this_pull_request(self): + step = extract_step( + extract_job(APPROVAL_WORKFLOW, "request"), + "Check for a prior approval of the same change", + ) + # Confirming the named run carried *an* approval proves nothing: every + # approved breaking change in the repository has such a run, and a + # record naming one is a sentence anybody with write access can type. + # The run has to be this pull request's. + self.assertIn("HEAD_REF:", step) + self.assertIn('"$run_branch" != "$HEAD_REF"', step) + # Branch, not `pull_requests`: that field comes back empty on real runs. + self.assertIn(".head_branch", step) + + detect = extract_job(APPROVAL_WORKFLOW, "detect") + self.assertIn("head-ref: ${{ steps.scope.outputs.head-ref }}", detect) + + def test_a_commit_subject_cannot_smuggle_an_approval_record(self): + reporter = ( + REPO_ROOT / "src" / "notify" / "breaking-change-approval-reporter" + / "action.yml" + ).read_text(encoding="utf-8") + # Subjects are author-controlled and get rendered into this comment. + # One carrying a marker would be collected by the record preserver as + # though the gate had written it — the author approving their own break + # by choosing a commit message. Two independent guards, because one + # guard for a privilege escalation is one too few. + self.assertIn("s/", + ) + self.assertNotIn("" + ) + self.assertNotIn("` + # and retired it when the acknowledgement arrived. Removing that job + # removed its cleanup, so a pull request opened before the changeover + # keeps an "awaiting acknowledgement" notice forever, beside this + # comment and contradicting it. + _, calls = run_reporter_comment( + "waiting", existing_body="\n", + legacy_ids="3", + ) + deletes = [call for call in calls if "DELETE" in call] + self.assertEqual(1, len(deletes), calls) + self.assertIn("issues/comments/3", deletes[0]) + + # Nothing to retire, nothing deleted. + _, clean = run_reporter_comment( + "waiting", existing_body="\n" + ) + self.assertFalse([call for call in clean if "DELETE" in call]) + + # Narrow on purpose: only a comment the marker opens, and only one + # written by a bot. Quoting the marker must not get a comment deleted. + action = ( + REPO_ROOT / "src" / "notify" / "breaking-change-approval-reporter" + / "action.yml" + ).read_text(encoding="utf-8") + self.assertIn('select(.user.type == \\"Bot\\")', action) + self.assertIn("startswith", action) + + def test_the_records_are_never_rewritten_blind(self): + # A failed read must not look like "there were none": the rewrite would + # erase every approval already given on this pull request, and the + # reviewers would be asked again for changes they had cleared. + # + # This was pinned by grepping the composite for its own error message, + # which proves the words exist and nothing about what runs. A branch + # that printed them and issued the PATCH anyway would have passed. + failed, calls = run_reporter_comment("waiting", read_fails=True) + self.assertEqual(1, failed.returncode) + self.assertIn("Refusing to rewrite it", failed.stderr) + self.assertFalse( + [call for call in calls if "PATCH" in call], + "the comment was rewritten after the records could not be read", + ) + + # And the successful path actually carries them forward — the half + # that gives the refusal its point. + previous = ( + "\n\nold body\n\n" + "\n" + ) + kept, calls = run_reporter_comment("waiting", existing_body=previous) + self.assertEqual(0, kept.returncode) + self.assertTrue([call for call in calls if "PATCH" in call]) + self.assertIn("breaking-change-approved: abc123 run=99", kept.posted_body) + + # A marker inside a fenced block is the author's text, not the gate's + # record — the undo example renders commit subjects in ```diff. + fenced = ( + "\n\n```diff\n" + "\n```\n" + ) + smuggled, _ = run_reporter_comment("waiting", existing_body=fenced) + self.assertNotIn("breaking-change-approved: dead", smuggled.posted_body) + + def test_the_approval_record_survives_the_next_run(self): + reporter = ( + REPO_ROOT / "src" / "notify" / "breaking-change-approval-reporter" + / "action.yml" + ).read_text(encoding="utf-8") + # Every run rewrites the comment body. Without carrying the records + # forward, the approval would be forgotten by the very next push, which + # is the one thing the record exists to prevent. + self.assertIn("previous_records", reporter) + self.assertIn("breaking-change-approved: [a-f0-9]+ run=[0-9]+", reporter) + # Only an approval writes a new record. + self.assertIn('if [ "$STATE" = "approved" ] && [ -n "$FINGERPRINT" ]', reporter) + + def test_the_gate_checks_out_nothing(self): + # CodeQL flagged `actions/untrusted-checkout` here, and it was right: + # a non-privileged workflow was putting a contributor's tree on disk + # beside MANAGE_TOKEN. Nothing executed it, but nothing needed it — + # the commits are readable through the API. + self.assertNotIn("actions/checkout", APPROVAL_WORKFLOW) + + def test_an_unevaluated_acknowledgement_is_not_reported_as_missing(self): + # The detector that reads the description lives in pr-validation. Where + # it did not run, the state is unknown — and "missing" would send the + # author after a line no check is asking for. + rendered = render_reporter("waiting", ACKNOWLEDGED="") + self.assertNotIn("Author acknowledgement", rendered) + rendered = render_reporter("waiting", ACKNOWLEDGED="false") + self.assertIn("Author acknowledgement", rendered) + self.assertIn("missing", rendered) + + def test_the_fact_label_outlives_the_decision(self): + # Run it, per state, and read the `gh pr edit` calls. Asserting on the + # composite's text cannot tell which state made which call: moving + # `--add-label "$LABEL"` into the `waiting` branch would leave every + # string in place and stop the fact label from surviving a decision. + for state in ("waiting", "approved", "rejected", "acknowledgement"): + with self.subTest(state=state): + calls = run_reporter_labels(state) + pairs = list(zip(calls[::2], calls[1::2])) + + self.assertIn( + ("add-label", "breaking-change"), + pairs, + "the fact label belongs on every state the gate reports", + ) + self.assertNotIn( + ("remove-label", "breaking-change"), + pairs, + "approving does not make the break go away", + ) + + expected = "add-label" if state == "waiting" else "remove-label" + self.assertIn((expected, "awaiting-breaking-approval"), pairs) + + def test_the_two_labels_can_be_turned_off_independently(self): + # Cosmetic throughout: a repository that declared neither still gets + # its gate, and one that wants only the fact can say so. + self.assertEqual([], run_reporter_labels("waiting", LABEL="", PENDING_LABEL="")) + self.assertEqual( + ["add-label", "breaking-change"], + run_reporter_labels("waiting", PENDING_LABEL=""), + ) + + def test_the_fact_label_goes_only_when_the_break_does(self): + # `state=none` is reached two ways and they are not the same fact. The + # previous assertion pinned the loop that removed both labels in both + # cases — describing the code, which is how a loop taking off a true + # label stayed green. Run it instead, and read the calls. + def labels_removed(**overrides): + result, outputs = run_verdict( + LABEL="breaking-change", + PENDING_LABEL="awaiting-breaking-approval", + **overrides, + ) + self.assertEqual("none", outputs["state"]) + return { + call.rsplit(" ", 1)[-1] + for call in result.gh_calls + if "--remove-label" in call + } + + # Out of scope with the break still there. The acknowledgement reporter + # has just applied `breaking-change` because the break is real and the + # author still has to declare it — taking it back off here was the gate + # undoing its own correct labelling. + self.assertEqual( + {"awaiting-breaking-approval"}, + labels_removed(IN_SCOPE="false", HAS_BREAKING_CHANGES="true"), + ) + + # The break actually gone — a reword dropping the `!`. Now the fact + # stopped being true, so both come off. + self.assertEqual( + {"awaiting-breaking-approval", "breaking-change"}, + labels_removed(IN_SCOPE="true", HAS_BREAKING_CHANGES="false"), + ) + + def test_the_acknowledgement_line_survives_the_decision(self): + # The approval and the acknowledgement are different controls. A + # reviewer saying yes to a break is not the author declaring it, and + # Blocking Checks keeps failing until the line is in the description. + # + # The instructions used to live in `waiting` alone, so the approval + # rewrote the comment and took the only copy with it: red check, no + # instructions, nothing on the pull request saying what to paste. + line = "Breaking change acknowledged: I understand ..." + for state in ("waiting", "approved", "blocked"): + rendered = render_reporter(state, ACKNOWLEDGED="false", ACK_LINE=line) + self.assertIn("Add this exact line", rendered, state) + self.assertIn(line, rendered, state) + + # And it goes away once the line is there — it is remediation, not + # decoration. + done = render_reporter("approved", ACKNOWLEDGED="true", ACK_LINE=line) + self.assertNotIn("Add this exact line", done) + + # The outcome job has to forward the literal, or there is nothing to + # print no matter what the renderer does. + workflow = yaml.safe_load(APPROVAL_WORKFLOW) + step = next( + s + for s in workflow["jobs"]["outcome"]["steps"] + if s.get("name") == "Report the outcome on the pull request" + ) + self.assertIn("author-acknowledgement", step["with"]) + + def test_no_instruction_is_better_than_an_empty_one(self): + # `author-acknowledgement` is empty by default, so a direct caller on + # the composite's defaults was told to "add this exact line" above an + # empty code block. The waiting branch already checked; this one did + # not. + rendered = render_reporter("acknowledgement", ACKNOWLEDGED="false", ACK_LINE="") + self.assertNotIn("Add this exact line", rendered) + self.assertNotIn("```", rendered) + # The break is still announced — only the unusable instruction is gone. + self.assertIn("acknowledgement missing", rendered) + + def test_a_blocked_gate_says_so_on_the_pull_request(self): + # Detection failing fails the check. It used to do that silently: a red + # check with no comment reads as a flake and gets re-run rather than + # looked at. + rendered = render_reporter("blocked", ACKNOWLEDGED="") + self.assertIn("could not run", rendered) + self.assertIn("fails closed", rendered) + + workflow = yaml.safe_load(APPROVAL_WORKFLOW) + step = next( + s + for s in workflow["jobs"]["outcome"]["steps"] + if s.get("name") == "Report the outcome on the pull request" + ) + self.assertIn("'blocked'", step["if"]) + + def test_disabling_approval_keeps_the_acknowledgement_feedback(self): + # `enable_breaking_change_approval: false` turns off the human + # approval, not the acknowledgement — Blocking Checks still fails + # without the line. Skipping the whole job took the only comment + # carrying that line with it. + validation = yaml.safe_load( + (REPO_ROOT / ".github" / "workflows" / "pr-validation.yml").read_text( + encoding="utf-8" + ) + ) + job = validation["jobs"]["breaking-change-approval"] + self.assertNotIn("inputs.enable_breaking_change_approval", job["if"]) + self.assertEqual( + "${{ inputs.enable_breaking_change_approval }}", + job["with"]["enable_approval"], + ) + + # The gate treats it as out of scope for the approval and nothing more, + # so no Environment is ever touched. + approval = yaml.safe_load(APPROVAL_WORKFLOW) + in_scope = approval["jobs"]["detect"]["outputs"]["in-scope"] + self.assertIn("inputs.enable_approval", in_scope) + + # And the published result still says `skipped`, not `success`: the job + # runs now, but nothing was approved. + result = validation[True]["workflow_call"]["outputs"][ + "breaking_change_approval_result" + ]["value"] + self.assertIn("!inputs.enable_breaking_change_approval && 'skipped'", result) + + def test_the_historical_approver_survives_a_reused_approval(self): + # Reuse skips the approval job, so the current run holds no Environment + # decision and `decisions` comes back empty. Without the fallback the + # comment degrades to "Approved by a reviewer" on the next push, losing + # the name of whoever actually decided. + workflow = yaml.safe_load(APPROVAL_WORKFLOW) + request = workflow["jobs"]["request"] + self.assertIn("approved-by", request["outputs"]) + + step = next( + s + for s in workflow["jobs"]["outcome"]["steps"] + if s.get("name") == "Report the outcome on the pull request" ) self.assertIn( - "inputs.approval_reviewers != '' && inputs.approval_reviewers" - " || steps.codeowners.outputs.reviewers", - preflight, + "needs.request.outputs.approved-by", step["with"]["approvers"] + ) + # And it is the verified identity, not one read from the comment: the + # lookup only sets it after the Environment log confirms the run. + lookup = extract_step( + extract_job(APPROVAL_WORKFLOW, "request"), + "Check for a prior approval of the same change", + ) + verified_at = lookup.index('approved_by="$approvers"') + guard_at = lookup.index('select(.state == "approved")') + self.assertLess(guard_at, verified_at) + + def test_the_fingerprint_comes_from_the_breaking_commits(self): + detect = extract_job(APPROVAL_WORKFLOW, "detect") + self.assertIn("src/validate/breaking-change-commits@", detect) + self.assertIn("fingerprint: ${{ steps.commits.outputs.fingerprint }}", detect) + + def test_the_verdict_states_are_reachable_as_documented(self): + # The whole table in one place, run rather than read. + for description, env, expected in ( + ("approved", {"APPROVAL_RESULT": "success"}, "approved"), + ( + "rejected", + {"APPROVAL_RESULT": "failure", "REJECTERS": "dave"}, + "rejected", + ), + ( + "cancelled, same head, a newer run carries it", + {"APPROVAL_RESULT": "cancelled", "MOCK_NEWER_RUNS": "1"}, + "superseded", + ), + ( + "cancelled, same head, nothing replacing it", + {"APPROVAL_RESULT": "cancelled", "MOCK_NEWER_RUNS": "0"}, + "unresolved", + ), + ( + "head moved under us", + {"LIVE_SHA": "def456"}, + "superseded", + ), + ( + "failed with no recorded decision", + {"APPROVAL_RESULT": "failure"}, + "unresolved", + ), + ("nothing to approve", {"HAS_BREAKING_CHANGES": "false"}, "none"), + ( + "detection never completed", + {"DETECTION_SUCCEEDED": "false"}, + "blocked", + ), + ): + with self.subTest(case=description): + _, outputs = run_verdict(**env) + self.assertEqual(expected, outputs["state"]) + # And `superseded` must drive neither the comment nor the close. + for step_name in ( + "Report the outcome on the pull request", + "Close the pull request on rejection", + ): + with self.subTest(step=step_name): + step = extract_step(extract_job(APPROVAL_WORKFLOW, "outcome"), step_name) + self.assertNotIn("superseded", step) + + def test_the_environment_is_read_exactly_once(self): + # Two reads would allow "rules fetched, reviewers not" — and an empty + # reviewer list is indistinguishable from "this Environment has none", + # so a caller would fall back to whoever it had configured and name the + # wrong people. One read, both answers, no such state. + reads = ENVIRONMENT_PROTECTION.count( + 'gh api "repos/${REPO}/environments/${ENVIRONMENT}"' ) + self.assertEqual(1, reads, "the Environment must be read exactly once") + self.assertIn("reviewers_of()", ENVIRONMENT_PROTECTION) + self.assertIn("rules_of()", ENVIRONMENT_PROTECTION) + + def test_the_button_degrades_when_the_badge_cannot_load(self): + reporter = ( + REPO_ROOT / "src" / "notify" / "breaking-change-approval-reporter" + / "action.yml" + ).read_text(encoding="utf-8") + # The badge is an external image. Its alt text carries the whole call to + # action, so a blocked image leaves a described link, not a broken box. + self.assertIn("[![Review the pending deployment]", reporter) + # And an empty badge-url falls back to a plain heading link. + self.assertIn('elif [ -n "$RUN_URL" ]; then', reporter) + + # GitHub alerts colour the block and supply the icon; the emoji marks what + # the block is about, so the state is readable from the timeline without + # opening anything. + STATE_MARKS = ( + ("waiting", "[!IMPORTANT]", "## 💥 Breaking change detected"), + ("approved", "[!NOTE]", "## ✅ Breaking change approved"), + ("rejected", "[!CAUTION]", "## ⛔ Breaking change rejected"), + ("unresolved", "[!WARNING]", "## 💥 Breaking change — decision not recorded"), + ) + + def test_each_state_renders_its_own_alert_and_mark(self): + # Rendered, not grepped. Asserting against the source only proves a + # string exists somewhere in it, so swapping two states' alerts would + # keep every assertion green while every comment said the wrong thing. + for state, alert, mark in self.STATE_MARKS: + with self.subTest(state=state): + rendered = render_reporter(state) + # As adjacent blockquote lines, not as two strings that happen + # to be somewhere: a GitHub alert only renders inside `>`, so + # dropping the quote would leave both assertions green and the + # comment looking like plain text. + self.assertIn(f"> {alert}\n> {mark}", rendered) + + # Exclusivity is what actually catches a swap. + for other_state, other_alert, other_mark in self.STATE_MARKS: + if other_state == state: + continue + self.assertNotIn(other_alert, rendered) + self.assertNotIn(other_mark, rendered) + + def test_the_undo_example_uses_the_real_commit_subjects(self): + reporter = ( + REPO_ROOT / "src" / "notify" / "breaking-change-approval-reporter" + / "action.yml" + ).read_text(encoding="utf-8") + self.assertIn('repos/${REPO}/pulls/${PR_NUMBER}/commits', reporter) + self.assertIn("```diff", reporter) + # A view, never a verdict: with no subject matched it must fall back to + # generic wording rather than invent an example. + self.assertIn('if [ -n "$breaking_subjects" ]; then', reporter) def test_a_missing_environment_is_told_apart_from_an_unreadable_one(self): # Creating on top of a 403 would be guessing; only a 404 is ours to fix. diff --git a/src/validate/environment-protection/README.md b/src/validate/environment-protection/README.md index a34557ab..b2a0a1af 100644 --- a/src/validate/environment-protection/README.md +++ b/src/validate/environment-protection/README.md @@ -46,6 +46,15 @@ drop a wait timer or a deployment branch policy somebody set on purpose, so an existing Environment is reported and left untouched. Creating from nothing is a different act from overwriting somebody's configuration. +### One read, both answers + +The rules and the reviewers come from a single `GET`. Two calls would allow a +state of "protected, reviewers unknown" — and an empty reviewer list is +indistinguishable from *this Environment has none*, so a caller naming the +approvers to a human would quietly fall back to whoever it had configured and +name the wrong people. With one read there is no such state: either both +answers are known, or the step fails. + ### A 404 and a 403 are not the same Only "does not exist" is this composite's to fix. If the Environment merely @@ -90,6 +99,7 @@ Environments API takes numeric ids only. |--------|-------------| | `protected` | `true` when the rule is present. The step fails before it could report `false`. | | `created` | `true` when this run created the Environment. | +| `reviewers` | Who the Environment is actually gated by, as `team:`/`user:`. Read back from the Environment, so it reflects one that already existed with different reviewers than were asked for. | ## Usage diff --git a/src/validate/environment-protection/action.yml b/src/validate/environment-protection/action.yml index 47dd32c8..cfdbe555 100644 --- a/src/validate/environment-protection/action.yml +++ b/src/validate/environment-protection/action.yml @@ -36,6 +36,9 @@ outputs: created: description: "Whether this run created the Environment (true/false)." value: ${{ steps.ensure.outputs.created }} + reviewers: + description: "Reviewers the Environment actually carries, as `team:`/`user:`. Read back from the Environment, so it reflects an existing one that is gated by different people than the caller asked for." + value: ${{ steps.ensure.outputs.reviewers }} runs: using: composite @@ -57,9 +60,34 @@ runs: created=false owner=${REPO%%/*} - read_rules() { - gh api "repos/${REPO}/environments/${ENVIRONMENT}" \ - --jq '[.protection_rules[].type] | join(",")' 2>/dev/null + error_file=$(mktemp) + trap 'rm -f "$error_file"' EXIT INT TERM + environment_json='' + + # One read, both answers. Fetching the rules and the reviewers in + # separate calls would create a state where the first succeeded and the + # second did not — "protected, reviewers unknown" — and an empty + # reviewer list is indistinguishable from "this Environment has none". + # A caller would then fall back to whoever it had configured and name + # the wrong people. There is no such state with a single read. + read_environment() { + environment_json=$(gh api "repos/${REPO}/environments/${ENVIRONMENT}" 2>"$error_file") + } + + rules_of() { + jq -r '[.protection_rules[].type] | join(",")' <<< "$environment_json" + } + + # Who the Environment is actually gated by, which is not necessarily who + # the caller asked for: it may have existed already, or been edited since. + # Callers that name the reviewers to a human should prefer this. + reviewers_of() { + jq -r '[.protection_rules[] + | select(.type == "required_reviewers") + | .reviewers[]? + | if .type == "Team" then "team:" + .reviewer.slug + else "user:" + .reviewer.login end] + | join(",")' <<< "$environment_json" } # Resolves `team:` / `user:` / `` into the @@ -95,19 +123,18 @@ runs: printf '%s' "$resolved" } - if ! rules=$(read_rules); then + if ! read_environment; then # Distinguish "does not exist" from "cannot read". Only the first is # ours to fix; the second is a token problem, and creating on top of # it would be guessing. - if ! error=$(gh api "repos/${REPO}/environments/${ENVIRONMENT}" 2>&1 >/dev/null); then - case "$error" in - *"HTTP 404"* | *"Not Found"* ) ;; - * ) - echo "Could not read the '${ENVIRONMENT}' Environment in ${REPO}: ${error}" >&2 - exit 1 - ;; - esac - fi + error=$(cat "$error_file") + case "$error" in + *"HTTP 404"* | *"Not Found"* ) ;; + * ) + echo "Could not read the '${ENVIRONMENT}' Environment in ${REPO}: ${error}" >&2 + exit 1 + ;; + esac if [ "$CREATE_IF_MISSING" != "true" ]; then echo "The '${ENVIRONMENT}' Environment does not exist in ${REPO}, and create-if-missing is off." >&2 @@ -151,12 +178,14 @@ runs: created=true echo "::notice::Created the '${ENVIRONMENT}' Environment in ${REPO} with reviewers: ${REVIEWERS}" - if ! rules=$(read_rules); then + if ! read_environment; then echo "Created the '${ENVIRONMENT}' Environment but could not read it back." >&2 exit 1 fi fi + rules=$(rules_of) + # Reached whether the Environment was just created or already existed. # An existing one that is unprotected is NOT repaired here: a PUT # replaces the whole configuration, so it would silently drop a wait @@ -171,6 +200,7 @@ runs: { echo "protected=true" echo "created=$created" + echo "reviewers=$(reviewers_of)" } >> "$GITHUB_OUTPUT" if [ "$created" != "true" ]; then