Skip to content
101 changes: 101 additions & 0 deletions .github/workflows/issue-status.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: Issue status

# Moves issues across the project board as their work progresses. GitHub does
# not link an issue to a pull request based on dev, so the closing keywords are
# parsed out of the body here rather than read back from the API.
on:
push:
branches: ['issue-*']
pull_request:
types: [opened, reopened, ready_for_review, edited, closed]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

permissions: {}

env:
PROJECT_NUMBER: '1'
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}

jobs:
in-progress:
# A branch deletion after its pull request merges can also fire a push
# event, with no branch left to check out.
if: github.event_name == 'push' && github.event.deleted == false
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4

- name: Move the issue to In progress
env:
BRANCH: ${{ github.ref_name }}
run: |
set -uo pipefail
issue="$(printf '%s' "$BRANCH" | sed -nE 's/^issue-([0-9]+)-.*/\1/p')"
if [ -z "$issue" ]; then
echo "Branch $BRANCH does not name an issue - nothing to do."
exit 0
fi
# Todo or unset only, so a later push cannot pull it out of In review.
bash scripts/set-issue-status.sh "$issue" "In progress" Todo unset

pull-request:
# Fork runs get no secrets, so GH_TOKEN would be empty and every external
# contribution would show a red check it has no way to fix.
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4

- name: Move the referenced issues
env:
# Through the environment, never interpolated into the script: a body
# is attacker controllable text.
BODY: ${{ github.event.pull_request.body }}
MERGED: ${{ github.event.pull_request.merged }}
BASE: ${{ github.event.pull_request.base.ref }}
ACTION: ${{ github.event.action }}
run: |
set -uo pipefail
source scripts/issue-status.sh

if [ "$ACTION" = "closed" ]; then
if [ "$MERGED" != "true" ] || [ "$BASE" != "dev" ]; then
echo "Closed without merging into dev - nothing to do."
exit 0
fi
status="Ready for release"
# Every state except Done: a merge into dev always beats an
# earlier state, but a down-merge from master can repeat a
# Closes reference for an issue that already shipped, and that
# must not drag it backwards out of Done.
allowed=(Todo unset "In progress" "In review" "Ready for release")
else
status="In review"
# Guards an edit on an already merged pull request from pulling
# the issue back out of Ready for release or Done.
allowed=(Todo unset "In progress")
fi

refs="$(printf '%s' "$BODY" | closing_refs)"
if [ -z "$refs" ]; then
echo "No closing references in the body - nothing to do."
exit 0
fi

failures=0
while IFS= read -r issue; do
[ -n "$issue" ] || continue
if ! bash scripts/set-issue-status.sh "$issue" "$status" "${allowed[@]}"; then
printf 'FAILED: could not set #%s to %s\n' "$issue" "$status" >&2
failures=$((failures + 1))
fi
done <<< "$refs"

if [ "$failures" -gt 0 ]; then
exit 1
fi
36 changes: 36 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ concurrency:

permissions:
contents: write
issues: write
pull-requests: read

jobs:
release:
Expand Down Expand Up @@ -96,6 +98,17 @@ jobs:
echo "current=$CURRENT"
echo "next=$NEXT"

- name: Dry run - list the issues this release would close
if: steps.plan.outputs.release == 'true' && inputs.dry_run
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.plan.outputs.next }}
DRY_RUN: '1'
run: |
set -uo pipefail
PREV_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)" \
bash scripts/close-released-issues.sh

- name: Dry run - resolve BukkitDev game versions
if: steps.plan.outputs.release == 'true' && inputs.dry_run
env:
Expand All @@ -116,6 +129,17 @@ jobs:
end
'

# Must run before Commit and tag creates v$NEXT, or the range below is
# empty and nothing closes.
- name: Record the previous release tag
id: prev
if: steps.plan.outputs.release == 'true'
run: |
set -uo pipefail
tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Previous release tag: ${tag:-none}"

- name: Commit and tag
if: steps.plan.outputs.release == 'true' && !inputs.dry_run
env:
Expand Down Expand Up @@ -153,3 +177,15 @@ jobs:
gh release create "v$VERSION" "SetHomesTwo.V$VERSION.jar" \
--title "SetHomesTwo V$VERSION" \
--notes "$NOTES"

# An issue closes when the commit that fixed it is contained in the commit
# being released. That is what keeps work sitting on dev open when an
# immediate fix ships from master, and closes an immediate fix that never
# passed through dev.
- name: Close the issues this release contains
if: steps.plan.outputs.release == 'true' && !inputs.dry_run
env:
GH_TOKEN: ${{ github.token }}
PREV_TAG: ${{ steps.prev.outputs.tag }}
VERSION: ${{ steps.plan.outputs.next }}
run: bash scripts/close-released-issues.sh
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ jobs:
- name: Run the BukkitDev publish tests
run: bash scripts/test-publish-bukkitdev.sh

- name: Run the issue status parsing tests
run: bash scripts/test-issue-status.sh

# Rehearses the release on every pull request. The changelog heading went
# missing in a docs change, and nothing noticed until a release ran.
- name: Check a release could be applied
Expand Down
87 changes: 87 additions & 0 deletions scripts/close-released-issues.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Closes the issues contained in the commit being released.
#
# Reads PREV_TAG (may be empty on a first release) and VERSION from the
# environment. With DRY_RUN=1 it prints what it would close and closes nothing.
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./issue-status.sh
source "$SCRIPT_DIR/issue-status.sh"

VERSION="${VERSION:?VERSION must be set}"
PREV_TAG="${PREV_TAG:-}"
DRY_RUN="${DRY_RUN:-0}"

if [ -z "$PREV_TAG" ] && [ -n "$(git tag --list)" ]; then
echo "Error: PREV_TAG is empty but the repository already has tags - refusing to scan all of history." >&2
echo "This usually means the checkout is missing tags or full history. Fix the checkout rather than closing every referenced issue." >&2
exit 1
fi

range="HEAD"
if [ -n "$PREV_TAG" ]; then
range="$PREV_TAG..HEAD"
fi
printf 'Range: %s\n' "$range"

# Subjects only. A commit body may mention a pull request it did not come from.
prs="$(git log --format=%s "$range" | pr_numbers_from_log)"
if [ -z "$prs" ]; then
echo "No pull requests in the range - nothing to close."
exit 0
fi

issues=""
pr_count=0
pr_failures=0
while IFS= read -r pr; do
[ -n "$pr" ] || continue
pr_count=$((pr_count + 1))
if ! body="$(gh pr view "$pr" --json body --jq '.body')"; then
printf 'Warning: could not look up pull request #%s - skipping it.\n' "$pr" >&2
pr_failures=$((pr_failures + 1))
continue
fi
refs="$(printf '%s' "$body" | closing_refs)"
[ -n "$refs" ] || continue
issues="$(printf '%s\n%s' "$issues" "$refs")"
done <<< "$prs"

if [ "$pr_count" -gt 0 ] && [ "$pr_failures" -eq "$pr_count" ]; then
printf 'Error: all %d pull request lookups failed - cannot tell what this release closes.\n' "$pr_count" >&2
exit 1
fi

issues="$(printf '%s' "$issues" | grep -E '^[1-9][0-9]*$' | awk '!seen[$0]++')"
if [ -z "$issues" ]; then
echo "No closing references among those pull requests - nothing to close."
exit 0
fi

close_failures=0
while IFS= read -r issue; do
[ -n "$issue" ] || continue

state="$(gh issue view "$issue" --json state --jq '.state')" || continue
if [ "$state" != "OPEN" ]; then
printf '#%s is already %s - skipping.\n' "$issue" "$state"
continue
fi

if [ "$DRY_RUN" = "1" ]; then
printf 'Would close #%s (Released in v%s)\n' "$issue" "$VERSION"
continue
fi

if gh issue close "$issue" --reason completed --comment "Released in v$VERSION"; then
printf 'Closed #%s\n' "$issue"
else
printf 'Failed to close #%s - close it by hand.\n' "$issue" >&2
close_failures=$((close_failures + 1))
fi
done <<< "$issues"

if [ "$close_failures" -gt 0 ]; then
exit 1
fi
30 changes: 30 additions & 0 deletions scripts/issue-status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Parses issue and pull request references out of text.
#
# Pure text handling, no network, so the workflows that call it stay thin and
# the parsing is unit tested.

# GitHub's own closing keyword set. A bare #NN must not close anything: pull
# request bodies here routinely mention issues they do not resolve.
CLOSING_KEYWORDS='close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved'

# Reads text on stdin, writes the issue numbers it closes to stdout, one per
# line, first appearance order, deduplicated. Always exits 0.
closing_refs() {
grep -oiE "\\b(${CLOSING_KEYWORDS})[[:space:]]*:?[[:space:]]+#[0-9]+" \
| grep -oE '[0-9]+' \
| grep -E '^[1-9][0-9]*$' \
| awk '!seen[$0]++'
return 0
}

# Reads `git log --format=%s` output on stdin and writes the pull request
# numbers it contains to stdout, one per line, deduplicated. Subjects only:
# a body may mention a pull request the commit did not come from.
pr_numbers_from_log() {
grep -oE '(Merge pull request #[0-9]+|\(#[0-9]+\))' \
| grep -oE '[0-9]+' \
| grep -E '^[1-9][0-9]*$' \
| awk '!seen[$0]++'
return 0
}
Loading
Loading