diff --git a/README.md b/README.md index f11ca9c..16e2ae3 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,32 @@ Pin every use by full commit SHA. Works in any job image with a POSIX shell. Installs git with apk or apt-get if the image has none, and clones from the runner's internal Gitea address (`CI_SERVER_INTERNAL`) so CI traffic never crosses Cloudflare. + +## discourse-release + +Announces a published release on the community forum, in the repo's +Announcements category. Add to a project repo as `.gitea/workflows/announce.yml`: + +```yaml +on: + release: + types: [published] +jobs: + announce: + runs-on: light + steps: + - uses: coffey-labs/actions/discourse-release@ + with: + api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }} + discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} # optional +``` + +- The repo must be listed in `discourse-release/release-map.json`, the one + place that maps repos to forum categories. +- `DISCOURSE_RELEASE_KEY` is an org-level secret (coffey-labs and inbuxa) + holding release-bot's key, which can only create posts. +- Re-running is safe: each repo+tag gets one topic, enforced by the forum. +- Release notes are passed to the forum through `jq` only, so quotes, + backticks and multi-line Markdown arrive exactly as written. +- To turn it off for a repo, delete its `announce.yml`; for everyone, remove + the org secret. diff --git a/discourse-release/action.yml b/discourse-release/action.yml new file mode 100644 index 0000000..8609ce1 --- /dev/null +++ b/discourse-release/action.yml @@ -0,0 +1,37 @@ +name: discourse-release +description: >- + Announce a published Gitea release on the Coffey Labs community forum, in the + repo's Announcements category (release-map.json). Safe to re-run: a release + is announced at most once. +inputs: + api-key: + description: release-bot's forum API key (scope topics:write only) + required: true + discord-webhook: + description: optional Discord webhook URL; when set, the announcement is also posted there + required: false + default: "" + forum: + description: forum base URL + required: false + default: https://community.coffeylabs.org +runs: + using: composite + steps: + - shell: sh + env: + API_KEY: ${{ inputs.api-key }} + DISCORD_WEBHOOK: ${{ inputs.discord-webhook }} + FORUM: ${{ inputs.forum }} + REPO: ${{ github.repository }} + EVENT_PATH: ${{ github.event_path }} + ACTION_PATH: ${{ github.action_path }} + MAP: ${{ github.action_path }}/release-map.json + run: | + set -eu + # The job image may be bare; the script needs bash, curl and jq. + if ! command -v curl >/dev/null || ! command -v jq >/dev/null || ! command -v bash >/dev/null; then + if command -v apk >/dev/null; then apk add --no-cache -q bash curl jq ca-certificates >/dev/null + else apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends bash curl jq ca-certificates >/dev/null; fi + fi + exec bash "$ACTION_PATH/announce.sh" diff --git a/discourse-release/announce.sh b/discourse-release/announce.sh new file mode 100755 index 0000000..be31168 --- /dev/null +++ b/discourse-release/announce.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Called by action.yml. Everything user-written (release name, notes) comes in +# through the event JSON file and is only ever handled by jq -- never spliced +# into a command line or a JSON string by the shell. +set -euo pipefail + +category_path=$(jq -r --arg r "$REPO" '.[$r] // empty' "$MAP") +if [ -z "$category_path" ]; then + echo "::error::$REPO is not in discourse-release/release-map.json" + exit 1 +fi + +tag=$(jq -r '.release.tag_name' "$EVENT_PATH") +# The category id, looked up anonymously (the category is public); the +# release-bot key is scoped to creating posts and nothing else. +category_id=$(curl -fsS "$FORUM/c/$category_path/find_by_slug.json" | jq -r '.category.id') + +# One topic per repo+tag, ever: Discourse enforces external_id uniqueness. +external_id="rel-$(printf '%s@%s' "$REPO" "$tag" | sha1sum | cut -c1-40)" + +payload=$(jq -n --arg repo "$REPO" --argjson category "$category_id" --arg ext "$external_id" \ + --slurpfile ev "$EVENT_PATH" ' + ($ev[0].release) as $r + | ($repo | split("/") | last) as $name + | { + # repo + tag, not the release name: names vary by project (some repeat + # the tag, some carry a product name in capitals), tags do not. + title: "\($name) \($r.tag_name) released", + category: $category, + external_id: $ext, + raw: ( + "**\($name) \($r.tag_name)** is out.\n\n" + + (if ($r.body // "") == "" then "" else "\($r.body)\n\n" end) + + "---\n[Release on Gitea](\($r.html_url))" + ) + }') + +status=$(curl -sS -o /tmp/discourse-release.out -w '%{http_code}' -X POST "$FORUM/posts.json" \ + -H "Api-Key: $API_KEY" -H "Api-Username: release-bot" \ + -H "Content-Type: application/json" --data-binary "$payload") +body=$(cat /tmp/discourse-release.out) +case "$status" in + 200) + url="$FORUM/t/$(jq -r '.topic_slug' <<<"$body")/$(jq -r '.topic_id' <<<"$body")" + echo "announced: $url" ;; + 422) + if jq -e '.errors | tostring | test("External ID"; "i")' <<<"$body" >/dev/null; then + echo "already announced (external_id $external_id); nothing to do" + exit 0 + fi + echo "::error::forum refused the post: $body"; exit 1 ;; + *) + echo "::error::forum returned HTTP $status: $body"; exit 1 ;; +esac + +if [ -n "${DISCORD_WEBHOOK:-}" ]; then + jq -n --arg u "$url" --slurpfile ev "$EVENT_PATH" --arg repo "$REPO" ' + ($ev[0].release) as $r + | {content: ("**\($repo | split("/") | last) \($r.tag_name)** released\n\($u)")[0:1900]}' | + curl -fsS -X POST "$DISCORD_WEBHOOK" -H "Content-Type: application/json" --data-binary @- >/dev/null \ + && echo "posted to Discord" \ + || echo "::warning::Discord webhook failed; the forum post stands" +fi diff --git a/discourse-release/release-map.json b/discourse-release/release-map.json new file mode 100644 index 0000000..5802493 --- /dev/null +++ b/discourse-release/release-map.json @@ -0,0 +1,14 @@ +{ + "_comment": "Gitea repo -> forum Announcements category (parent/child slug). The one place this mapping lives; adding a project is one line here.", + "inbuxa/inbuxa-server": "inbuxa/announcements", + "inbuxa/ihasmail-inbuxa": "inbuxa/announcements", + "inbuxa/inbuxa-admin": "inbuxa/announcements", + "inbuxa/inbuxa-installer": "inbuxa/announcements", + "coffey-labs/ihasmail": "ihasmail/announcements", + "coffey-labs/ihasmail-oneshot": "ihasmail/announcements", + "coffey-labs/ihasvpn": "ihasvpn/announcements", + "coffey-labs/cairnobs": "cairn-obs/announcements", + "coffey-labs/stalwart-migrator": "coffey-labs/announcements", + "coffey-labs/SysAdminAutomation": "coffey-labs/announcements", + "coffey-labs/ubuntu2mint": "coffey-labs/announcements" +}