diff --git a/.github/workflows/cmsend-notify.yml b/.github/workflows/cmsend-notify.yml new file mode 100644 index 0000000..e2c40ae --- /dev/null +++ b/.github/workflows/cmsend-notify.yml @@ -0,0 +1,93 @@ +name: cmsend-notify +# Reusable workflow to notify a chatmail group about workflow result state changes +# via the [`cmsend`](https://github.com/chatmail/cmsend) CLI, needs to be paired with `cmsend-sync.yml`, see doc/cmsend-notify/README.md. + +on: + workflow_call: + inputs: + result: { type: string, required: true } # needs..result + alert-tag: { type: string, default: "LOG" } + secrets: + cmsend-account: { required: true } # base64 seed, see README.md +env: + # TEMPORARY: --sync is unreleased, swap back to "cmsend@" pin after merge + CMSEND_SPEC: "git+https://github.com/chatmail/cmsend@ci-sync-and-name" +permissions: + actions: read # read the previous run's conclusion (for change-only alerts) +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Check notification necessity + id: gate + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + BRANCH: ${{ github.ref_name }} + RESULT: ${{ inputs.result }} + run: | + set -euo pipefail + WF_ID=$(gh api "repos/$REPO/actions/runs/$RUN_ID" --jq .workflow_id) + PREV=$(gh api \ + "repos/$REPO/actions/workflows/$WF_ID/runs?branch=$BRANCH&status=completed&per_page=1" \ + --jq '.workflow_runs[0].conclusion // "none"') + case "$RESULT:$PREV" in + success:failure) echo "verb=RECOVERED" >> "$GITHUB_OUTPUT" ;; + failure:failure) echo "no change (still failing), staying quiet" ;; + failure:*) echo "verb=FAILED" >> "$GITHUB_OUTPUT" ;; + success:*) echo "no change (still passing), staying quiet" ;; + *) echo "result=$RESULT: no notification" ;; + esac + + - if: steps.gate.outputs.verb + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - name: Restore cmsend profile + if: steps.gate.outputs.verb + # Prefer the nightly-refreshed cache, seed is fallback + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + id: cache + with: + path: ~/cmsend-profile.enc + key: cmsend-profile-${{ github.run_id }} + restore-keys: cmsend-profile- + + - name: Unpack cmsend profile + if: steps.gate.outputs.verb + # Keep this in sync with account.sh unpack and cmsend-sync.yml. + env: + B64: ${{ secrets.cmsend-account }} + HIT: ${{ steps.cache.outputs.cache-matched-key }} + run: | + set -euo pipefail + mkdir -p ~/.config/cmsend + if [ -n "$HIT" ]; then + printf '%s' "$B64" | sha256sum | cut -d' ' -f1 > ~/.cmsend-key + openssl enc -d -aes-256-cbc -pbkdf2 -pass file:$HOME/.cmsend-key \ + -in ~/cmsend-profile.enc | tar -C ~/.config/cmsend -xzf - + else + echo "::warning::cmsend cache retrieve failed, falling back to the seed secret" + printf '%s' "$B64" | base64 -d | tar -C ~/.config/cmsend -xzf - + for s in ~/.config/cmsend/*/dc.sql; do + d=$(dirname "$s"); sqlite3 "$d/dc.db" < "$s" + mkdir -p "$d/dc.db-blobs"; rm -f "$s" + done + fi + + - name: Notify + if: steps.gate.outputs.verb + env: + VERB: ${{ steps.gate.outputs.verb }} + ALERT_TAG: ${{ inputs.alert-tag }} + REPO: ${{ github.repository }} + WF: ${{ github.workflow }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + printf '%s %s %s\n%s' "$REPO" "$WF" "$VERB" "$RUN_URL" | + uvx --from "$CMSEND_SPEC" cmsend -t "$ALERT_TAG" + + - name: Scrub the profile + if: always() + run: rm -rf ~/.config/cmsend ~/.cmsend-key ~/cmsend-profile.enc diff --git a/.github/workflows/cmsend-sync.yml b/.github/workflows/cmsend-sync.yml new file mode 100644 index 0000000..37ebf7d --- /dev/null +++ b/.github/workflows/cmsend-sync.yml @@ -0,0 +1,88 @@ +name: cmsend-sync +# Reusable nightly workflow going along with cmsend-notify.yml: +# 1. Logs in to keep account alive +# 2. Fetches group updates (memberships and keys) +# 3. Stores the new profile data in an encrypted cache file + +on: + workflow_call: + inputs: + alert-tag: { type: string, default: "LOG" } + secrets: + cmsend-account: { required: true } +permissions: {} +env: + # TEMPORARY: --sync is unreleased, so install from the branch that has it. + # Swap back to a plain "cmsend@" pin once cmsend is tagged. + CMSEND_SPEC: "git+https://github.com/chatmail/cmsend@ci-sync-and-name" +concurrency: + group: cmsend-sync-${{ github.repository }} +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + id: cache + with: + path: ~/cmsend-profile.enc + key: cmsend-profile-${{ github.run_id }} + restore-keys: cmsend-profile- + + - name: Unpack cmsend profile + # Note: Keep the following in sync with cmsend-notify.yml and scripts/pack-cmsend-profile.sh! + env: + B64: ${{ secrets.cmsend-account }} + HIT: ${{ steps.cache.outputs.cache-matched-key }} + run: | + set -euo pipefail + mkdir -p ~/.config/cmsend + printf '%s' "$B64" | sha256sum | cut -d' ' -f1 > ~/.cmsend-key + if [ -n "$HIT" ]; then + openssl enc -d -aes-256-cbc -pbkdf2 -pass file:$HOME/.cmsend-key \ + -in ~/cmsend-profile.enc | tar -C ~/.config/cmsend -xzf - + else + echo "::warning::cmsend cache cold, seeding from the secret" + printf '%s' "$B64" | base64 -d | tar -C ~/.config/cmsend -xzf - + for s in ~/.config/cmsend/*/dc.sql; do + d=$(dirname "$s"); sqlite3 "$d/dc.db" < "$s" + mkdir -p "$d/dc.db-blobs"; rm -f "$s" + done + fi + + - name: Fetch + run: uvx --from "$CMSEND_SPEC" cmsend --fetch-only + + - name: Report membership changes + env: + ALERT_TAG: ${{ inputs.alert-tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + new=~/.config/cmsend/roster.new + uvx --from "$CMSEND_SPEC" cmsend -l | sort > $new + old=~/.config/cmsend/roster.txt + if [ -f "$old" ] && ! diff -q "$old" "$new" > /dev/null; then + { printf '%s cmsend member list changed:\n' "$REPO" + diff "$old" "$new" || true + } | uvx --from "$CMSEND_SPEC" cmsend -t "$ALERT_TAG" + fi + cp "$new" "$old" + + - name: Seal profile + run: | + set -euo pipefail + tar -C ~/.config/cmsend -czf - . | + openssl enc -aes-256-cbc -pbkdf2 -salt -pass file:$HOME/.cmsend-key \ + -out ~/cmsend-profile.enc + + - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/cmsend-profile.enc + key: cmsend-profile-${{ github.run_id }} + + - name: Scrub the profile + if: always() + run: rm -rf ~/.config/cmsend ~/.cmsend-key ~/cmsend-profile.enc diff --git a/doc/cmsend-notify/README.md b/doc/cmsend-notify/README.md new file mode 100644 index 0000000..941db55 --- /dev/null +++ b/doc/cmsend-notify/README.md @@ -0,0 +1,79 @@ +# cmsend-notify + +Reusable Actions workflows that report run results into a Delta Chat +group via the [`cmsend`](https://github.com/chatmail/cmsend) CLI: + + - `cmsend-notify.yml` messages the alert group on state **change**, needs to be paired with + - `cmsend-sync.yml`, which runs on an interval to keep account alive and synced + - `cmsend-test` allows to test the workflow + +## Setup + +Two steps: +- Setup the cmsend profile & save it as action secret +- Add Workflows + +### One-time account setup + +1. In a Delta Chat client of your choice, pick or create (and send an initial + message) the group to alert, and copy the invite link. +2. Create the bot account, name it, and join the group (feature branch right now): + ``` + export CMSEND_SPEC="git+https://github.com/chatmail/cmsend@ci-sync-and-name" + uvx --from "$CMSEND_SPEC" --init --shared --name "CI Bot" + uvx --from "$CMSEND_SPEC" -t LOG --join "" + ``` +3. Test delivery: + ``` + echo "alert test" | uv run cmsend -t LOG + ``` +4. Pack a dump of the cmsend account with `scripts/pack-cmsend-profile.sh` + ``` + scripts/pack-cmsend-profile.sh pack ~/.config/cmsend | tee /tmp/secret.b64 | wc -c # must be < 65536 + ``` + To verify the export works: + ``` + scripts/pack-cmsend-profile.sh unpack /tmp/rt/cmsend < /tmp/secret.b64 + XDG_CONFIG_HOME=/tmp/rt uvx --from \ + "git+https://github.com/chatmail/cmsend@ci-sync-and-name" cmsend -l # must list LOG + ``` +5. Finally, add the output as a repository secret named `CMSEND_ACCOUNT_TGZ_B64` + +On initial run, this will be unpacked to seed a cmsend profile, which will carry across action runs in an cache file encrypted with `CMSEND_ACCOUNT_TGZ_B64` as a secret. + +### Add the workflows: + +### In the workflow you want to be notified about + +add after the job to watch: +```yaml + notify: + needs: + if: always() + permissions: + actions: read # required to read the previous run's conclusion + uses: chatmail/workflows/.github/workflows/cmsend-notify.yml@cmsend-notify + with: + result: ${{ needs..result }} + secrets: + cmsend-account: ${{ secrets.CMSEND_ACCOUNT_TGZ_B64 }} +``` + +Be sure to replace both instances of with your job name. + +### Add the sync workflow, default is nightly + +`cp doc/cmsend-notify/cmsend-nightly.yml /.github/workflows/` + +The nightly job updates and saves an encrypted copy of the profile. + +### Test action + +You can also copy `notify-test.yml` which allows to run PASS/FAIL test notifies +against this repo's notify setup from the actions Web UI. If you dispatch it +twice with `force_fail=true`, then `false` should give you FAILED, then +RECOVERED. Dispatching the same result twice should not alert. + +## Notes + +To point at a different group, `--join` it locally again and redo step 4 + 5. diff --git a/doc/cmsend-notify/cmsend-nightly.yml b/doc/cmsend-notify/cmsend-nightly.yml new file mode 100644 index 0000000..6fae798 --- /dev/null +++ b/doc/cmsend-notify/cmsend-nightly.yml @@ -0,0 +1,12 @@ +name: cmsend-nightly +# cmsend-notify: run the nightly account sync +on: + schedule: + - cron: "43 4 * * *" + workflow_dispatch: +permissions: {} +jobs: + sync: + uses: chatmail/workflows/.github/workflows/cmsend-sync.yml@cmsend-notify + secrets: + cmsend-account: ${{ secrets.CMSEND_ACCOUNT_TGZ_B64 }} diff --git a/doc/cmsend-notify/cmsend-test.yml b/doc/cmsend-notify/cmsend-test.yml new file mode 100644 index 0000000..722f32a --- /dev/null +++ b/doc/cmsend-notify/cmsend-test.yml @@ -0,0 +1,25 @@ +name: notify-test +on: + workflow_dispatch: + inputs: + force_fail: { type: boolean, default: false } +permissions: {} +jobs: + work: + runs-on: ubuntu-latest + permissions: {} + steps: + - if: ${{ inputs.force_fail }} + run: exit 1 + - if: ${{ !inputs.force_fail }} + run: exit 0 + notify: + needs: work + if: always() + permissions: + actions: read # let the reusable workflow read the previous run's result + uses: chatmail/workflows/.github/workflows/cmsend-notify.yml@cmsend-notify + with: + result: ${{ needs.work.result }} + secrets: + cmsend-account: ${{ secrets.CMSEND_ACCOUNT_TGZ_B64 }} diff --git a/scripts/pack-cmsend-profile.sh b/scripts/pack-cmsend-profile.sh new file mode 100755 index 0000000..eb9f019 --- /dev/null +++ b/scripts/pack-cmsend-profile.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Pack/unpack a cmsend account as a small sqlite-dump + accounts.toml so it fits a 64KiB +# GitHub secret, which the naive compressed .config/cmsend directory does not. + +set -euo pipefail +case "${1:-}" in + pack) # pack -> base64 seed on stdout + cfg=${2:?cmsend dir}; w=$(mktemp -d) + cp "$cfg/accounts.toml" "$w/" + for db in "$cfg"/*/dc.db; do + a=$(basename "$(dirname "$db")"); mkdir -p "$w/$a" + sqlite3 "$db" ".dump" > "$w/$a/dc.sql" + done + tar -C "$w" -czf - . | base64 -w0; rm -rf "$w" ;; + + unpack) # unpack (base64 seed on stdin) + cfg=${2:?cmsend dir}; mkdir -p "$cfg" + base64 -d | tar -C "$cfg" -xzf - + for s in "$cfg"/*/dc.sql; do + d=$(dirname "$s"); sqlite3 "$d/dc.db" < "$s"; mkdir -p "$d/dc.db-blobs"; rm -f "$s" + done ;; + + *) echo "usage: $0 pack|unpack " >&2; exit 2 ;; +esac