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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions .github/workflows/empty-pr-guard.yml
Original file line number Diff line number Diff line change
@@ -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"
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.<guard>.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
Expand All @@ -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 `<emoji> <label> — <status>`. May contain Slack emoji shortcodes. |
| `color` | no | `''` | Attachment bar color (hex) overriding the result-derived green/red. |
| `status` | no | `''` | When set, the first field renders as **Status** with this text in place of the **Version** field. |
| `channel` | no | `slack` | Chat channel; only `slack` is implemented today. |
| `github-token` | no | `${{ github.token }}` | Token used to read the PR/commit. The default job token covers a same-repo deploy; for a cross-org deploy pass a token minted in the caller from a GitHub App with read access to `target-repo`. |
| `target-repo` | no | `${{ github.repository }}` | `owner/repo` whose PR/commit the card describes. Override for a cross-repo deploy. |
Expand Down
49 changes: 43 additions & 6 deletions actions/notify/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ inputs:
description: Version string shown in the message.
required: false
default: ""
header:
description: >-
Full header line, verbatim. When set, overrides the default
"<emoji> <label> — <succeeded|failed>" header — for alert cards that need
a custom title (e.g. a revert notice). May contain Slack emoji shortcodes.
required: false
default: ""
color:
description: >-
Attachment bar color (hex, e.g. "#F9A825"). When set, overrides the
result-derived green/red — lets alert callers signal a state that isn't a
clean pass/fail.
required: false
default: ""
status:
description: >-
When set, the first card field renders as "*Status*\n<status>" in place of
"*Version*\n<version>". For alert cards where a version is meaningless.
required: false
default: ""
channel:
description: "Chat channel: slack (only slack implemented today)."
required: false
Expand Down Expand Up @@ -58,6 +78,9 @@ runs:
RESULT: ${{ inputs.result }}
LABEL: ${{ inputs.label }}
VERSION: ${{ inputs.version }}
HEADER_IN: ${{ inputs.header }}
COLOR_IN: ${{ inputs.color }}
STATUS_IN: ${{ inputs.status }}
WEBHOOK: ${{ inputs.webhook-url }}
TARGET_REPO: ${{ inputs.target-repo }}
TARGET_SHA: ${{ inputs.target-sha }}
Expand All @@ -78,6 +101,18 @@ runs:
version="${VERSION:-}"; [ -z "$version" ] && version="n/a"
short_sha="${TARGET_SHA:0:7}"

# Card chrome. Alert callers can override the header line and color bar,
# and swap the first field from Version to Status. With none of these
# inputs set, the result-derived defaults reproduce the original card
# verbatim, so existing deploy callers are unaffected.
header="${HEADER_IN:-${emoji} ${LABEL} — ${status}}"
[ -n "${COLOR_IN:-}" ] && color="$COLOR_IN"
if [ -n "${STATUS_IN:-}" ]; then
field1_label="Status"; field1_value="$STATUS_IN"
else
field1_label="Version"; field1_value="$version"
fi

# Build date in UTC (YYYY.MM.DD), shown on the card so the deploy date is
# unambiguous rather than inferred from Slack's local-timezone timestamp.
built_utc="$(date -u +%Y.%m.%d)"
Expand All @@ -95,15 +130,16 @@ runs:
pr_url="$(jq -r '.html_url // ""' <<<"$sel")"
pr_user="$(jq -r '.user.login // "unknown"' <<<"$sel")"
payload="$(jq -n \
--arg color "$color" --arg header "${emoji} ${LABEL} — ${status}" \
--arg version "$version" --arg prlabel "${TARGET_REPO}#${pr_num}" --arg title "$pr_title" \
--arg color "$color" --arg header "$header" \
--arg f1label "$field1_label" --arg f1value "$field1_value" \
--arg prlabel "${TARGET_REPO}#${pr_num}" --arg title "$pr_title" \
--arg user "$pr_user" --arg runurl "$RUN_URL" --arg prurl "$pr_url" \
--arg built "$built_utc" '
def esc: gsub("&";"&amp;") | gsub("<";"&lt;") | gsub(">";"&gt;");
{ attachments: [{ color: $color, blocks: [
{ type:"header", text:{ type:"plain_text", text:$header, emoji:true } },
{ type:"section", fields:[
{ type:"mrkdwn", text:("*Version*\n" + $version) },
{ type:"mrkdwn", text:("*" + $f1label + "*\n" + $f1value) },
{ type:"mrkdwn", text:("*PR*\n" + (if $prurl != "" then "<" + $prurl + "|" + $prlabel + ">" else $prlabel end)) } ]},
{ type:"section", text:{ type:"mrkdwn", text:("*" + ($title|esc) + "*") } },
{ type:"context", elements:[
Expand All @@ -119,15 +155,16 @@ runs:
if [ -n "$c_subject" ]; then
# Commit card: subject + short SHA (linked) + author.
payload="$(jq -n \
--arg color "$color" --arg header "${emoji} ${LABEL} — ${status}" \
--arg version "$version" --arg short "$short_sha" --arg cmurl "$c_url" \
--arg color "$color" --arg header "$header" \
--arg f1label "$field1_label" --arg f1value "$field1_value" \
--arg short "$short_sha" --arg cmurl "$c_url" \
--arg subject "$c_subject" --arg user "$c_author" --arg runurl "$RUN_URL" \
--arg built "$built_utc" '
def esc: gsub("&";"&amp;") | gsub("<";"&lt;") | gsub(">";"&gt;");
{ attachments: [{ color: $color, blocks: [
{ type:"header", text:{ type:"plain_text", text:$header, emoji:true } },
{ type:"section", fields:[
{ type:"mrkdwn", text:("*Version*\n" + $version) },
{ type:"mrkdwn", text:("*" + $f1label + "*\n" + $f1value) },
{ type:"mrkdwn", text:("*Commit*\n" + (if $cmurl != "" then "<" + $cmurl + "|" + $short + ">" else "`" + $short + "`" end)) } ]},
{ type:"section", text:{ type:"mrkdwn", text:("*" + ($subject|esc) + "*") } },
{ type:"context", elements:[
Expand Down