diff --git a/.github/workflows/empty-pr-guard.yml b/.github/workflows/empty-pr-guard.yml new file mode 100644 index 0000000..5b11cd8 --- /dev/null +++ b/.github/workflows/empty-pr-guard.yml @@ -0,0 +1,175 @@ +name: Empty PR-description guard + +# Reusable merge-time gate. On a push to a protected branch, find the PR behind +# HEAD and, if it merged with an empty (or whitespace-only) description, revert +# the merge, edit the PR to explain, and emit block=true so the caller can stand +# its release/build/deploy jobs down (we don't ship code we're reverting). This +# is the merge-time backstop for enforcing PR descriptions. +# +# The caller decides *when* this runs (its own `if:`, e.g. only on dev pushes) +# and grants the token scopes; this workflow operates on whatever branch the +# push targeted (github.ref_name) and resolves the PR from github.sha. Only +# GITHUB_TOKEN is needed (revert + PR edit) — no secrets to pass, so this is +# safe to call across an org boundary. +# +# What gets reverted depends on how the PR landed: +# • merge commit (>=2 parents) → `git revert -m 1` undoes the whole PR. +# • squash (1 commit) / rebase (N commits) → revert every commit this push +# introduced (`before..HEAD`), so a multi-commit rebase is fully undone. +# +# Fail behaviour: +# • PR-fetch API error → fail SAFE: skip (block=false), never false-revert. +# • revert push rejected / conflicts → block stays true, outcome reports +# "revert by hand", and the caller (which gates on this workflow's result) +# stands down. +# +# Slack: this workflow does NOT post to Slack. The webhook is typically an +# environment secret, and `secrets: inherit` can't deliver an env secret to a +# reusable workflow across an org boundary. Instead it emits the alert content +# (outcome/header/color/reason) as outputs; the caller renders the card from a +# job that declares the environment (see the caller's notify job). + +on: + workflow_call: + inputs: + label: + description: Short name shown in the alert header the caller renders (e.g. "Backend", "Web"). + required: false + type: string + default: Service + outputs: + block: + description: "'true' when the merge was reverted and the caller's release must stand down." + value: ${{ jobs.guard.outputs.block }} + outcome: + description: "Empty when nothing was done; else reverted | push_failed | conflict." + value: ${{ jobs.guard.outputs.outcome }} + header: + description: "Alert header text for the caller's Slack card (empty unless the guard acted)." + value: ${{ jobs.guard.outputs.header }} + color: + description: "Alert colour bar for the caller's Slack card." + value: ${{ jobs.guard.outputs.color }} + reason: + description: "Alert status line for the caller's Slack card." + value: ${{ jobs.guard.outputs.reason }} + +permissions: {} + +jobs: + guard: + name: Gate + runs-on: ubuntu-latest + permissions: + contents: write # push the revert commit + pull-requests: write # edit the reverted PR description + outputs: + block: ${{ steps.check.outputs.block }} + outcome: ${{ steps.check.outputs.outcome }} + header: ${{ steps.check.outputs.header }} + color: ${{ steps.check.outputs.color }} + reason: ${{ steps.check.outputs.reason }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 # full history so the merge commit is revertable + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Revert empty-description merge + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + BEFORE: ${{ github.event.before }} + BASE_REF: ${{ github.ref_name }} + LABEL: ${{ inputs.label }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + block=false + + # Find the PR that introduced this commit. Empty for a direct push + # (no PR) — that's branch-protection's job, not ours. + pr_json="$(gh api "repos/$REPO/commits/$SHA/pulls" \ + -H "Accept: application/vnd.github+json" 2>/dev/null || echo '[]')" + pr_number="$(jq -r 'map(select(.merged_at != null)) | (.[0].number // empty)' <<<"$pr_json")" + + if [ -z "$pr_number" ]; then + echo "No merged PR associated with $SHA (direct push?); nothing to enforce." + else + # Fetch the PR body in one call. Fail SAFE: if the API call fails + # (rate limit, network), do NOT treat it as an empty description — + # that would revert a legitimate merge. Skip the guard and proceed. + pr="$(gh pr view "$pr_number" --repo "$REPO" --json body 2>/dev/null)" || { + echo "::warning::Could not fetch PR #$pr_number; skipping description guard for $SHA." + echo "block=false" >> "$GITHUB_OUTPUT" + exit 0 + } + pr_body="$(jq -r '.body // ""' <<<"$pr")" + + if [ -n "$(printf '%s' "$pr_body" | tr -d '[:space:]')" ]; then + echo "PR #$pr_number has a description; proceeding." + else + echo "PR #$pr_number merged with an empty description — reverting $SHA." + block=true + # Persist block early so a later failure in this branch can't fail + # open and let the reverted code ship (last write to the file wins). + echo "block=true" >> "$GITHUB_OUTPUT" + + # merge commit (>=3 tokens: self + 2 parents) → revert via -m 1; + # squash/rebase → revert the whole before..HEAD range. + parents="$(git rev-list --parents -n 1 "$SHA" | wc -w)" + if [ "$parents" -ge 3 ]; then + revert_target="$SHA"; margs="-m 1" + else + revert_target="${BEFORE}..${SHA}"; margs="" + fi + + outcome="" + # shellcheck disable=SC2086 + if git revert --no-edit $margs $revert_target; then + if git push origin "HEAD:$BASE_REF"; then + outcome="reverted" + rsha="$(git rev-parse HEAD)" + # Write the revert notice into the PR description itself (not a + # comment) so it's visible at the top of the PR. The body was + # empty (that's what tripped the guard), so setting it is safe — + # nothing to preserve. Editing a merged PR's body is allowed. + gh pr edit "$pr_number" --repo "$REPO" \ + --body "> ⚠️ **Auto-reverted on \`$BASE_REF\` (${rsha:0:7})** — this PR merged with an empty description, so the release was stood down and the merge reverted. Add a summary of *what* changed and *why*, then open a new PR." || true + echo "::warning::Reverted $SHA on $BASE_REF as ${rsha:0:7}; skipping this release." + else + outcome="push_failed" + echo "::error::Revert commit created but push to $BASE_REF was rejected (branch protection?); manual revert needed." + fi + else + git revert --abort || true + outcome="conflict" + echo "::error::Auto-revert of $SHA conflicts with later commits; manual revert needed." + fi + + # Per-outcome alert content, emitted as outputs for the caller to + # render (it holds the env-scoped webhook). The shared notify action + # resolves the PR from github.sha for the title/link/author, so we + # only supply the header, color bar, and status text here. + case "$outcome" in + reverted) color="#F9A825"; header=":leftwards_arrow_with_hook: ${LABEL} — merge reverted (empty description)"; reason="Merge reverted on \`${BASE_REF}\` (${rsha:0:7}); release skipped" ;; + push_failed) color="#D32F2F"; header=":rotating_light: ${LABEL} — revert blocked (empty description)"; reason="Revert push to \`${BASE_REF}\` rejected (branch protection); revert by hand" ;; + conflict) color="#D32F2F"; header=":rotating_light: ${LABEL} — MANUAL revert needed (empty description)"; reason="Auto-revert conflicted on \`${BASE_REF}\`; revert by hand" ;; + *) color="#D32F2F"; header=":rotating_light: ${LABEL} — empty-description merge"; reason="Revert outcome unknown; check the run" ;; + esac + + { + echo "outcome=$outcome" + echo "color=$color" + echo "header=$header" + echo "reason=$reason" + } >> "$GITHUB_OUTPUT" + fi + fi + + echo "block=$block" >> "$GITHUB_OUTPUT" diff --git a/README.md b/README.md index d130119..ed212e3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ behind a runner contract you control (or pass inline via `run`). | Path | Type | Function | |------|------|----------| | `.github/workflows/version.yml` | Reusable workflow | Cut a SemVer release — detects RC line, cuts the release, and creates the next RC baseline automatically | +| `.github/workflows/empty-pr-guard.yml` | Reusable workflow | Merge-time gate — revert a merge whose PR had an empty description and emit `block` (+ alert content) so the caller stands its release down and posts the alert | | `actions/auth` | Action | Obtain cloud credentials (OIDC) | | `actions/setup` | Action | Install runtime + deps (+ EAS login) | | `actions/verify` | Action | Lint / type-check / test | @@ -274,6 +275,74 @@ version: changelog-on-rc: true ``` +## empty-pr-guard.yml — inputs & outputs + +Merge-time gate that enforces non-empty PR descriptions. Call it as the **first +job** of your pipeline, gated to the branch you protect. On a push whose merge +commit traces to a PR with an empty (or whitespace-only) body, it reverts the +merge on that branch, edits the PR to explain, and sets `block=true`; wire your +downstream jobs to stand down when `needs..outputs.block == 'true'`. A +PR-fetch API error fails **safe** (skips, never false-reverts); a rejected push +or a revert conflict keeps `block=true` and reports "revert by hand". + +It reverts a merge commit via `git revert -m 1` and a squash/rebase merge via +the `before..HEAD` range, so all three GitHub merge styles are fully undone. + +The workflow does **not** post to Slack itself: the webhook is an environment +secret, and `secrets: inherit` can't deliver an env secret to a reusable +workflow across an org boundary. It needs **no secrets** (revert + PR edit use +`GITHUB_TOKEN`), and instead **emits the alert content** as outputs — render the +card in a caller job that declares the environment (see the example). + +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `label` | string | no | `Service` | Short name shown in the alert header the caller renders (e.g. `Backend`, `Web`). | + +| Output | Description | +|--------|-------------| +| `block` | `'true'` when the merge was reverted and the caller's release must stand down. | +| `outcome` | Empty when nothing was done; else `reverted` \| `push_failed` \| `conflict`. Gate the notify job on `outcome != ''`. | +| `header` | Alert header line for the caller's Slack card. | +| `color` | Alert colour bar (hex) for the caller's Slack card. | +| `reason` | Alert status line for the caller's Slack card. | + +Grant `contents: write` + `pull-requests: write` on the guard job. To surface +the alert, add a job that declares the environment holding `SLACK_WEBHOOK_URL` +and renders the card via `notify` from the guard's outputs. + +```yaml +jobs: + guard: + if: github.event_name == 'push' && github.ref_name == 'dev' + permissions: { contents: write, pull-requests: write } + uses: nurdsoft/ci-workflows/.github/workflows/empty-pr-guard.yml@v3 + with: + label: Backend + + guard-notify: # renders the Slack card the guard can't + needs: [guard] + if: ${{ always() && needs.guard.outputs.outcome != '' }} + runs-on: ubuntu-latest + environment: dev # so ${{ secrets.SLACK_WEBHOOK_URL }} resolves + permissions: { contents: read, pull-requests: read } + steps: + - uses: nurdsoft/ci-workflows/actions/notify@v3 + with: + result: failure + webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + label: Backend + header: ${{ needs.guard.outputs.header }} + color: ${{ needs.guard.outputs.color }} + status: ${{ needs.guard.outputs.reason }} + + release: + needs: [guard] + # !cancelled() so a skipped guard (e.g. non-dev push) doesn't skip release; + # block=true stands it down; a guard that *failed* fails closed. + if: ${{ !cancelled() && needs.guard.result != 'failure' && needs.guard.outputs.block != 'true' }} + # ... +``` + ## notify — inputs Posts a rich Slack card for a pipeline result: a green/red header @@ -287,12 +356,22 @@ local-timezone message timestamp. Best-effort: an empty `webhook-url` is a no-op that still succeeds, and a failed post never fails the pipeline. +The `header`, `color`, and `status` inputs turn the same card into a general +**alert** (e.g. a revert or guard notice) instead of a deploy result: supply a +custom title and color bar, and the first field becomes **Status** instead of +**Version**. The PR/commit resolution, body, and context line are unchanged, so +the card still shows the PR (or commit) that `target-sha` traces to. Omit all +three and the card is exactly the deploy-result card above. + | Input | Required | Default | Description | |-------|----------|---------|-------------| | `result` | yes | — | `success` / `failure` — e.g. a deploy job's `result`. Anything other than `success` renders as failed. | | `webhook-url` | no | `''` | Slack incoming webhook URL. Empty makes the action a no-op. | | `label` | no | `Deployment` | Short label for the card header (app / component name). | | `version` | no | `''` | Version string shown in the Version field (`n/a` if empty). | +| `header` | no | `''` | Full header line, verbatim, overriding the default `