Skip to content
Merged
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
101 changes: 101 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ behind a runner contract you control (or pass inline via `run`).
| `actions/plan` | Action | Preview an infrastructure change |
| `actions/apply` | Action | Apply an infrastructure change |
| `actions/notify` | Action | Post the pipeline result as a rich Slack card |
| `actions/enforce` | Action | Merge-time gate — revert a merge whose PR had an empty description and return `block` (+ alert content) so the caller stands its release down |

## Design

Expand Down Expand Up @@ -274,6 +275,96 @@ version:
changelog-on-rc: true
```

## enforce — inputs & outputs

Merge-time gate that enforces non-empty PR descriptions. Call it as the **first
step** of a job at the top 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 returns
`block=true`; downstream jobs read `needs.<guard-job>.outputs.block != 'true'`
and stand down. 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.

Because it's a composite action, the alert is posted in the **same job**: the
Slack webhook (typically an environment-scoped secret) resolves inline, so no
separate output-threaded alert job is needed. It emits the alert content as
step outputs; feed those directly into a `notify` step in the same job.

### Why a composite action rather than a reusable workflow?

The guard needs to fire before any release/build/deploy job and, when it fires,
stand those jobs down. Both packaging choices can do that; the composite ships
with less caller boilerplate:

- **One job, not two.** A reusable-workflow guard forces the caller to add a
second `*-notify` job so the environment-scoped Slack webhook resolves. A
composite runs inside the caller's job, so the guard and its alert share the
same `environment:` block.
- **Step outputs, not cross-job outputs.** The alert step reads
`steps.<id>.outputs.header` directly; no `needs.<x>.outputs.*` plumbing.
- **One node in the pipeline graph, not two.** The visual dependency tree
stays compact.

Same underlying revert/edit script; only the packaging differs.

| Input | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `label` | string | no | `Service` | Short name shown in the alert header the caller renders (e.g. `Backend`, `Web`). |
| `github-token` | string | no | `${{ github.token }}` | Token used to read the PR, push the revert, and edit the PR body. Override with a GitHub-App token for cross-org. |

| 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 step 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 calling job. To surface
the alert on Slack, add a follow-up step in the same job (the environment-scoped
webhook resolves directly there) that renders the card via `notify` from the
guard's outputs. Expose `block` as a job-level output so downstream jobs can
gate on it.

```yaml
jobs:
guard:
if: github.event_name == 'push' && github.ref_name == 'dev'
runs-on: ubuntu-latest
environment: dev # so ${{ secrets.SLACK_WEBHOOK_URL }} resolves
permissions: { contents: write, pull-requests: write }
outputs:
block: ${{ steps.g.outputs.block }}
steps:
- id: g
uses: nurdsoft/ci-workflows/actions/enforce@v3
with:
label: Backend

# Alert on Slack when the guard acted; on a clean merge this step skips.
# Best-effort — a missing/failed webhook never fails the job.
- if: ${{ steps.g.outputs.outcome != '' }}
uses: nurdsoft/ci-workflows/actions/notify@v3
with:
result: failure
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
label: Backend
header: ${{ steps.g.outputs.header }}
color: ${{ steps.g.outputs.color }}
status: ${{ steps.g.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 +378,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
169 changes: 169 additions & 0 deletions actions/enforce/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: Enforce PR description
description: >-
Merge-time gate. On a push, resolves the PR behind github.sha and, if its
body is empty/whitespace-only, reverts the merge, edits the PR to explain,
and returns 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 to invoke this action (its own step-level `if:`,
e.g. only on protected-branch pushes) and grants the token scopes; the
action operates on whatever branch the push targeted (github.ref_name)
and resolves the PR from github.sha. Only a GitHub token with contents
and pull-requests write scopes is needed (revert + PR edit) — no external
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 action's outputs)
stands down.

Slack: this action does NOT post to Slack. It emits the alert content
(outcome/header/color/reason) as outputs; the caller renders the card in
a subsequent step within the same job (typically via actions/notify),
where any environment-scoped webhook is already resolved.

inputs:
label:
description: >-
Short name shown in the alert output the caller renders
(e.g. "Backend", "Web").
required: false
default: Service
github-token:
description: >-
Token used to read the PR, push the revert, and edit the PR body.
Defaults to the calling workflow's github.token; for a cross-org call
pass a token minted in the caller from a GitHub App with the
corresponding write scopes on the target repo.
required: false
default: ${{ github.token }}

outputs:
block:
description: "'true' when the merge was reverted and the caller's release must stand down."
value: ${{ steps.check.outputs.block }}
outcome:
description: "Empty when nothing was done; else reverted | push_failed | conflict."
value: ${{ steps.check.outputs.outcome }}
header:
description: "Alert header text for the caller's Slack card (empty unless the guard acted)."
value: ${{ steps.check.outputs.header }}
color:
description: "Alert colour bar (hex) for the caller's Slack card."
value: ${{ steps.check.outputs.color }}
reason:
description: "Alert status line for the caller's Slack card."
value: ${{ steps.check.outputs.reason }}

runs:
using: composite
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.ref_name }}
fetch-depth: 0 # full history so the merge commit is revertable

- id: check
shell: bash
env:
GH_TOKEN: ${{ inputs.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"
Loading