# Automations Anonymous: full corpus 9 published automations. Each record: summary, tools, steps, payload verbatim, failure modes. No authors. Site: https://automationsanonymous.com # Fail the build when published content stops being valid A CI step parses every code snippet a site publishes using the real tool for its declared format, so a snippet that stops being valid breaks the build instead of sitting published and wrong. URL: https://automationsanonymous.com/automations/fail-the-build-when-published-content-stops-being-valid Difficulty: beginner Tools: github-actions, node ## Problem Documentation and directories publish code that is never executed again after it is written. It rots on someone else's release schedule, and the first person to find out is a reader who pastes it and it fails. ## Trigger Every push, through the CI workflow, and weekly against production. ## Prerequisites - Snippets stored as data with a declared format, not embedded in prose. - The checkers available on the runner. bash, node and python3 ship on GitHub-hosted Ubuntu. ## Steps 1. Store the declared format alongside every snippet Without it there is nothing to check against, and a checker has to guess. 2. Parse each snippet with the real tool for its format (node) bash -n for shell, node --check for JavaScript, a YAML and XML parse, a field count for cron. Write to a temp file whose extension the tool expects. 3. Check the content rules you claim to hold, in the same pass Slug shape, field lengths, ordering, references that must resolve. A rule nothing enforces is a preference. 4. Exit non-zero and name every failure (github-actions) One report listing every problem beats failing on the first one. ## Payload (yaml) ```yaml name: CI on: push: branches: ["**"] pull_request: # The operator's machine cannot build this project: Turbopack fails to bind a # worker port there, so local builds run --webpack while Vercel builds with # Turbopack. That makes CI the only place a trustworthy build signal exists. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: verify: runs-on: ubuntu-latest env: # Public by construction: this is the NEXT_PUBLIC_ Convex URL the browser # already receives. Static generation reads published records at build. NEXT_PUBLIC_CONVEX_URL: https://exciting-deer-586.convex.cloud steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - name: Types run: npx tsc --noEmit - name: Lint run: npm run lint - name: Corpus conformance run: node scripts/check-payloads.mjs - name: Build run: npx next build - name: Audit production dependencies run: npm audit --omit=dev --audit-level=high ``` ## Failure modes - Node picks a module format from the file extension, so a snippet written to a .javascript file fails to parse for a reason that has nothing to do with the snippet. - A parse check proves syntax and nothing else. A script that parses cleanly and deletes the wrong directory passes. - Checking only the source file misses drift in what is actually published, if the two can differ. --- # Check that your API and MCP server still say what your website says A read-only script asserts that every machine-readable view of a site answers, that its MCP server completes a handshake and lists its tools, and that no private field appears in any public response. URL: https://automationsanonymous.com/automations/check-your-api-still-matches-your-website Difficulty: intermediate Tools: bash, curl, node ## Problem When a site serves the same data as HTML, as JSON, as plain text and over MCP, the four drift apart quietly. A field excluded from one surface reappears in another, and nobody notices until it is indexed. ## Trigger Run by hand before a deploy, or from CI on a schedule against production. ## Prerequisites - curl and node. No browser and no credentials, so it is safe to point at production. - A list of the field names that are supposed to be private. ## Steps 1. Assert every read view answers and returns the shape you expect (curl) Check a field that would only exist if the data really loaded, not just the status code. 2. Assert unknown identifiers return 404 rather than an empty success (curl) 3. Complete a real MCP handshake and list the tools (node) POST initialize, then tools/list, then call one tool and check the content it returns. Streamable HTTP may answer as SSE, so take the last data: line. 4. Grep every public response for the fields that must never leave the server (bash) This is the check that matters. Everything else is a smoke test. ## Payload (sh) ```sh #!/usr/bin/env bash # Check that an agent has parity with a person: every read view answers, the # MCP server handshakes and lists its tools, a tool call returns a real # payload, and no private field escapes. # # scripts/agent-surface-check.sh # dev server # scripts/agent-surface-check.sh https://automationsanonymous.com # production # # Read-only. It never calls submit_automation or POST /api/submit, so it is # safe against production. set -uo pipefail BASE="${1:-http://localhost:3000}" BASE="${BASE%/}" FAIL=0 ok() { printf ' ok %s\n' "$1"; } bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } check_json() { # path, jq-ish node expression, description local body body="$(curl -s --max-time 30 "$BASE$1")" if node -e " const d = JSON.parse(process.argv[1]); if (!($2)) { process.exit(1); } " "$body" 2>/dev/null; then ok "$1 $3"; else bad "$1 $3"; fi } echo "== capability manifest ==" check_json "/api" "d.mcp && d.endpoints.length >= 8" "lists mcp and endpoints" echo "== read views ==" check_json "/api/automations" "d.count >= 1 && d.automations[0].slug" "lists automations" check_json "/api/automations?difficulty=beginner" "d.automations.every(a => true)" "accepts difficulty" check_json "/api/automations?q=backup" "Array.isArray(d.automations)" "accepts q" check_json "/api/tools" "d.count >= 1 && d.tools[0].automationCount !== undefined" "lists tools with counts" SLUG="$(curl -s --max-time 30 "$BASE/api/automations" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(j.automations[0]?.slug??"")})')" if [ -z "$SLUG" ]; then bad "no automations published, cannot check a record"; else check_json "/api/automations/$SLUG" "d.slug && d.steps.length >= 1 && d.jsonLd['@type'] === 'HowTo'" "record with json-ld" MD="$(curl -s --max-time 30 "$BASE/api/automations/$SLUG.md")" case "$MD" in "# "*) ok "/api/automations/$SLUG.md markdown view";; *) bad "/api/automations/$SLUG.md markdown view";; esac check_json "/api/tools/$(curl -s "$BASE/api/tools" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).tools[0].slug))')" "d.slug && Array.isArray(d.automations)" "one tool" fi check_json "/api/blog" "d.count >= 1 && d.posts[0].slug" "lists posts" echo "== not found ==" for p in /api/automations/nope /api/tools/nope /api/stacks/nope-to-nada /api/blog/nope; do code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$BASE$p")" [ "$code" = "404" ] && ok "$p returns 404" || bad "$p returned $code, want 404" done echo "== private fields never leak ==" # Asserted on structure, not on substrings. The corpus itself publishes this # script as a record, so a grep for the field names matches its own source and # reports a leak that is not there. Checking JSON keys is both immune to that # and stricter: it also catches a value rendered under a renamed key. leak_check() { # url, description curl -s --max-time 30 "$BASE$1" | node -e ' let s = ""; process.stdin.on("data", d => s += d).on("end", () => { const PRIVATE = ["submitterEmail", "rejectionNote", "internalNotes"]; const found = new Set(); const walk = (v) => { if (Array.isArray(v)) return v.forEach(walk); if (v && typeof v === "object") { for (const k of Object.keys(v)) { if (PRIVATE.includes(k)) found.add(k); walk(v[k]); } } }; try { walk(JSON.parse(s)); } catch { process.exit(2); } if (found.size) { console.error([...found].join(",")); process.exit(1); } });' } for path in "/api/automations" "/api/automations/$SLUG"; do if leak_check "$path" 2>/dev/null; then ok "$path exposes no private key"; else bad "$path exposes a private key"; fi done # The public record must carry only known-public keys, so a newly added private # field fails here rather than waiting for someone to add it to a deny list. ALLOWED="slug,title,summary,problem,trigger,steps,prerequisites,failureModes,payload,toolSlugs,timeSavedMinutes,difficulty,sourceUrl,origin,publishedAt,url,markdown,jsonLd" EXTRA="$(curl -s --max-time 30 "$BASE/api/automations/$SLUG" | node -e ' let s = ""; process.stdin.on("data", d => s += d).on("end", () => { const allowed = new Set(process.argv[1].split(",")); console.log(Object.keys(JSON.parse(s)).filter(k => !allowed.has(k)).join(",")); });' "$ALLOWED")" [ -z "$EXTRA" ] && ok "record carries only known-public keys" || bad "record carries unexpected key(s): $EXTRA" echo "== mcp ==" mcp() { # method, params json curl -s --max-time 30 -X POST "$BASE/mcp" \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$1\",\"params\":$2}" } # Streamable HTTP may answer as SSE; take the last data: line when it does. unwrap() { node -e ' let s = ""; process.stdin.on("data", d => s += d).on("end", () => { const lines = s.split(/\r?\n/).filter(l => l.startsWith("data: ")); process.stdout.write(lines.length ? lines[lines.length - 1].slice(6) : s); });'; } INIT="$(mcp initialize '{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"agent-surface-check","version":"1.0.0"}}' | unwrap)" if printf '%s' "$INIT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const r=JSON.parse(s);process.exit(r.result&&r.result.serverInfo?0:1)})' 2>/dev/null; then ok "initialize handshake" else bad "initialize handshake: $(printf '%s' "$INIT" | head -c 200)" fi TOOLS="$(mcp tools/list '{}' | unwrap)" NAMES="$(printf '%s' "$TOOLS" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).result.tools.map(t=>t.name).sort().join(","))}catch{console.log("")}})')" EXPECTED="get_automation,get_post,get_stack,list_posts,list_tools,search_automations,submit_automation" [ "$NAMES" = "$EXPECTED" ] && ok "tools/list: $NAMES" || bad "tools/list got [$NAMES] want [$EXPECTED]" CALL="$(mcp tools/call "{\"name\":\"get_automation\",\"arguments\":{\"slug\":\"$SLUG\"}}" | unwrap)" if printf '%s' "$CALL" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const t=JSON.parse(s).result.content[0].text;process.exit(t.includes("## Steps")&&t.includes("# ")?0:1)}catch{process.exit(1)}})' 2>/dev/null; then ok "tools/call get_automation returns the record" else bad "tools/call get_automation: $(printf '%s' "$CALL" | head -c 200)" fi SEARCH="$(mcp tools/call '{"name":"search_automations","arguments":{"q":"backup"}}' | unwrap)" printf '%s' "$SEARCH" | grep -q '"text"' && ok "tools/call search_automations" || bad "tools/call search_automations" echo if [ "$FAIL" -eq 0 ]; then echo "agent surface: all checks passed against $BASE"; else echo "agent surface: $FAIL check(s) failed against $BASE" >&2; fi exit "$FAIL" ``` ## Failure modes - The script passes while the site is broken, because every assertion only checked a status code. Assert on a field that proves the data loaded. - MCP over streamable HTTP may answer as JSON or as an SSE stream depending on the client headers, so a parser that assumes one shape fails against a working server. - A private field that is absent because the record has no value for it reads identically to one that is correctly excluded. Test with a record that has the field set. - The corpus publishes this script, so a substring grep for the private field names matches its own source and reports a leak that is not there. Assert on JSON keys instead. --- # Screenshot every route at phone and desktop width after every change One script walks every route in a site, checks each against an expected status code, captures the canonical, robots and description tags, screenshots at two widths, and exits non-zero when any check fails. URL: https://automationsanonymous.com/automations/screenshot-every-route-after-every-change Difficulty: intermediate Tools: bash, curl, agent-browser ## Problem A CSS change that only breaks the phone layout, or a route that starts returning 200 where it should return 404, is invisible until someone happens to look. Checking by hand after every change lasts about two days. ## Trigger Run by hand after a visual change, or from CI against a deployed URL. ## Prerequisites - agent-browser on PATH, and a Chrome or Chromium it can drive. - A site that is actually reachable at the base URL, since the script preflights it and stops if not. - Somewhere to put screenshots. Each run writes a dated directory. ## Steps 1. Install the browser driver (agent-browser) npm i -g agent-browser && agent-browser install 2. List every route with the status it should return (bash) The ROUTES array pairs a path with an expected code, so a 404 that starts returning 200 is a failure rather than a passing row. 3. Run it against a local server or a deployed URL (curl) scripts/ux-loop.sh https://example.com. With no argument it preflights localhost and exits early if nothing is listening. 4. Read the dated report and the bold cells Each run writes its own directory with a markdown table and the screenshots. Failures are bold in the table and set exit code 2. ## Payload (sh) ```sh #!/usr/bin/env bash # UX loop: screenshot every route at phone and desktop widths, check HTTP # status and console errors, write a dated report. Rerun after each fix. # # scripts/ux-loop.sh # against the dev server, http://localhost:3000 # scripts/ux-loop.sh https://automationsanonymous.com # # Needs agent-browser on PATH (npm i -g agent-browser && agent-browser install). # Output lands in ux-out// which is gitignored. set -euo pipefail BASE="${1:-http://localhost:${PORT:-3000}}" BASE="${BASE%/}" curl -sf --max-time 10 -o /dev/null "$BASE/" || { echo "No server responding at $BASE" >&2; exit 1; } STAMP="$(date +%Y-%m-%d-%H%M%S)" OUT="ux-out/$STAMP" SESSION="ux-loop-$$" mkdir -p "$OUT" # route expected-status ROUTES=( "/ 200" "/automations 200" "/automations?tool=n8n&difficulty=beginner 200" "/automations/back-up-one-folder-every-night 200" "/automations/does-not-exist 404" "/tools 200" "/tools/cron 200" "/tools/does-not-exist 404" "/stacks/cron-to-slack 200" "/stacks/cron-to-cron 404" "/stacks/a-to-b 404" "/blog 200" "/blog/how-to-pick-your-first-automation 200" "/blog/example-post 404" "/submit 200" "/admin/queue 200" "/admin/automations/example-id 200" "/admin/import 200" "/does-not-exist 404" "/sitemap.xml 200" "/robots.txt 200" "/llms.txt 200" "/llms-full.txt 200" "/api 200" "/api/automations 200" "/api/automations/back-up-one-folder-every-night 200" "/api/automations/back-up-one-folder-every-night.md 200" "/api/automations/does-not-exist 404" "/api/tools 200" "/api/tools/cron 200" "/api/stacks/cron-to-slack 200" "/api/blog 200" "/api/blog/how-to-pick-your-first-automation.md 200" ) # name width height VIEWPORTS=( "phone 390 844" "desktop 1280 800" ) REPORT="$OUT/report.md" { echo "# UX loop $STAMP" echo echo "Base: $BASE" echo echo "| Route | HTTP | Canonical | Robots | Description | Title | Console errors | Phone | Desktop |" echo "|---|---|---|---|---|---|---|---|---|" } > "$REPORT" ab() { agent-browser --session "$SESSION" "$@"; } # Always close the browser; mark the report if the run died partway. trap 'rc=$?; ab close >/dev/null 2>&1 || true; if [ $rc -ne 0 ]; then echo "| ABORTED (exit $rc) | | | | | | | | |" >> "$REPORT"; echo "ux-loop aborted (exit $rc); partial report: $REPORT" >&2; fi' EXIT slug() { echo "$1" | sed -E 's#^/$#home#; s#^/##; s#[^A-Za-z0-9]+#-#g; s#-+$##'; } fail=0 body="$(mktemp)" for entry in "${ROUTES[@]}"; do read -r route expect <<< "$entry" name="$(slug "$route")" url="$BASE$route" code="$(curl -s -o "$body" -w '%{http_code}' --max-time 30 "$url" || echo 000)" # The answer-engine surface: what a crawler sees without a browser. canonical="$(grep -oE '/dev/null ab open "$url" >/dev/null ab wait --load networkidle >/dev/null || true [ -z "$title" ] && title="$(ab get title 2>/dev/null | tr -d '\n' || true)" e="$(ab errors 2>/dev/null | grep -v '^$' | grep -vi 'no errors' || true)" [ -n "$e" ] && errors="$errors $vname:$(echo "$e" | wc -l | tr -d ' ')" shot="$OUT/$name.$vname.png" ab screenshot --full "$shot" >/dev/null shots+=("$shot") done echo "| \`$route\` | $mark | ${canonical:-none} | ${robots:-none} | $( [ -n "$desc" ] && echo "${desc:0:60}" || echo none ) | $title | ${errors:-none} | ${shots[0]} | ${shots[1]} |" >> "$REPORT" echo "$code $route" done rm -f "$body" echo echo "Report: $REPORT" if [ "$fail" -gt 0 ]; then echo "$fail check(s) failed; see bold cells in the report" >&2 exit 2 fi ``` ## Failure modes - The headless browser emulates prefers-reduced-motion by default, so animations and any once-per-session overlay never play and screenshots look wrong for reasons that are not bugs. Clear it with agent-browser set media light. - A click on a control below the fold silently misses, because find does not scroll. Resolve the element reference first and scroll it into view. - get text returns rendered text, so anything styled uppercase comes back uppercase and a lowercase grep finds nothing. - A route that is slow rather than broken trips the curl timeout and reports 000, which reads identically to a dead server. --- # Post every form response to a chat channel An Apps Script trigger on the responses spreadsheet posts each new form submission to a chat channel as a readable message, so the people who act on it never open the sheet. URL: https://automationsanonymous.com/automations/post-every-form-response-to-chat Difficulty: beginner Tools: google-forms, google-sheets, google-apps-script, slack ## Problem A form writes a row and nobody sees it. The team either checks the sheet out of habit or finds out days later that someone was waiting on them. ## Trigger A form submission, through the spreadsheet's on form submit trigger. ## Prerequisites - A Google Form whose responses go to a spreadsheet. - An incoming webhook URL from the chat tool. - Permission to authorize a script on the account that owns the form. ## Steps 1. Open the responses spreadsheet, then Extensions > Apps Script (google-apps-script) 2. Paste the script and save (google-apps-script) 3. Add SLACK_WEBHOOK_URL under Project Settings > Script properties (slack) Script properties keep the webhook out of the code and out of version control. 4. Add the trigger (google-apps-script) Triggers > Add trigger > function onFormSubmit > event source From spreadsheet > event type On form submit. Authorize when prompted. 5. Submit the form once as a test (google-forms) Executions in the Apps Script sidebar shows the run and any error. ## Payload (javascript) ```javascript /** * Google Apps Script, bound to the form's responses spreadsheet. * Extensions > Apps Script, paste this, then add a trigger: * Triggers > Add trigger > onFormSubmit > From spreadsheet > On form submit. * Put the webhook in Project Settings > Script properties as SLACK_WEBHOOK_URL. */ function onFormSubmit(e) { const webhook = PropertiesService.getScriptProperties().getProperty('SLACK_WEBHOOK_URL'); if (!webhook) throw new Error('Script property SLACK_WEBHOOK_URL is not set'); const answers = e.namedValues; const lines = Object.keys(answers) .filter(function (question) { return String(answers[question]).trim() !== ''; }) .map(function (question) { return '*' + question + '*: ' + answers[question]; }); const body = { text: 'New form response', blocks: [{ type: 'section', text: { type: 'mrkdwn', text: lines.join('\n') } }] }; UrlFetchApp.fetch(webhook, { method: 'post', contentType: 'application/json', payload: JSON.stringify(body), muteHttpExceptions: true }); } ``` ## Failure modes - The trigger is attached to the form instead of the spreadsheet and e.namedValues is empty. It must be From spreadsheet. - A long free text answer makes the message unreadable in chat. Post the questions that matter rather than all of them. - The webhook is revoked and every run fails quietly. muteHttpExceptions keeps the row from being lost, so check Executions occasionally. - Apps Script has daily quotas on UrlFetchApp. A form taking thousands of responses a day needs a different approach. --- # Reclaim disk space from Docker every Sunday A weekly cron line prunes Docker images, containers, networks and build cache untouched for seven days, and deliberately leaves volumes alone so data survives. URL: https://automationsanonymous.com/automations/reclaim-disk-space-from-docker-weekly Difficulty: intermediate Tools: cron, docker ## Problem Docker keeps every layer it has ever pulled. On a build machine that is tens of gigabytes within a month, and the first symptom is a build failing on a full disk at an inconvenient hour. ## Trigger Cron, Sundays at 04:00. ## Prerequisites - Docker installed, and permission to talk to the daemon. - A writable /var/log, or a different path in the line. - Nothing on the machine that depends on an image it cannot pull again. ## Steps 1. See what would go first (docker) docker system df shows reclaimable space per category. 2. Add the line to root's crontab (cron) sudo crontab -e. Talking to the docker socket needs root or membership of the docker group. 3. Check the log after the first Sunday tail /var/log/docker-prune.log shows what was deleted and how much space came back. ## Payload (cron) ```cron # Sunday 04:00. Removes images, containers, networks and build cache # untouched for a week. Volumes are left alone on purpose. 0 4 * * 0 /usr/bin/docker system prune --all --force --filter "until=168h" >> /var/log/docker-prune.log 2>&1 ``` ## Failure modes - --all removes images with no running container, including one a stopped container needs at its next start. Drop --all to keep tagged images. - An air gapped machine cannot re-pull what was pruned. Do not run this there. - The until filter is relative to image creation, not last use. An old base image in daily use is still removed and pulled again. - Volumes are untouched by design. Adding --volumes to this line deletes database data. --- # Build, test and deploy on every push to main A GitHub Actions workflow installs, tests and builds on every push to main and then runs one publish step, cancelling an older run when a newer push arrives. URL: https://automationsanonymous.com/automations/build-test-and-deploy-on-every-push Difficulty: intermediate Tools: github-actions, npm ## Problem Deploying by hand from a laptop means the deployed thing was built on that laptop, with whatever happened to be installed that day, and two people deploying at once race each other. ## Trigger A push to the main branch. ## Prerequisites - A repository on GitHub with Actions enabled. - A package.json with a build script. Tests are optional: --if-present skips cleanly when there are none. - A deploy credential that can be revoked on its own without rotating anything else. ## Steps 1. Save the workflow at .github/workflows/deploy.yml (github-actions) 2. Put the deploy credential in repository secrets as DEPLOY_TOKEN (github-actions) Settings > Secrets and variables > Actions. Never in the workflow file. 3. Write scripts/publish.sh so it is the only step that touches production Keeping the publish in one script means it can still be run by hand in an emergency. 4. Push a trivial change and watch the run (github-actions) The Actions tab shows every step. A green run that skipped the tests is not a green run: check that npm test actually ran. ## Payload (yaml) ```yaml name: Deploy on: push: branches: [main] # One deploy at a time. A newer push cancels an older run. concurrency: group: deploy cancel-in-progress: true jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test --if-present - run: npm run build - name: Publish run: ./scripts/publish.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} ``` ## Failure modes - cancel-in-progress cancels a run that was already mid publish. Make publish.sh safe to run twice, or drop the concurrency block. - npm ci fails when package-lock.json is out of date with package.json. Commit the lockfile. - The build passes on the runner and fails on a developer machine, or the reverse. The runner is the source of truth from now on. - A secret is only masked in logs when it matches exactly. Do not echo credentials, even partially. --- # File every receipt out of the inbox automatically One Gmail filter labels anything that looks like a receipt or an invoice and archives it, so purchases stay searchable in one place and out of the inbox. URL: https://automationsanonymous.com/automations/file-every-receipt-out-of-the-inbox Difficulty: beginner Tools: gmail ## Problem Receipts arrive constantly, are read once, and are needed twice a year. Left in the inbox they are noise. Deleted, they are gone at tax time. ## Trigger Every incoming message, evaluated by Gmail on arrival. ## Prerequisites - A Gmail or Google Workspace account. - Five minutes to read what the search matches before turning it on. ## Steps 1. Create a label called Receipts (gmail) 2. Import the filter (gmail) Settings > Filters and blocked addresses > Import filters, upload the file below, then select the filter and create it. 3. Apply it to existing mail once (gmail) Search the same terms, select all, apply the label and archive. A filter only sees new mail. 4. Watch it for a week and tighten the terms Anything wrongly filed tells you which word is too broad. no-reply is the usual culprit. ## Payload (xml) ```xml Mail Filters Receipts ``` ## Failure modes - The word invoice appears in a thread that matters and it is archived unread. Search label:Receipts to find it, then narrow the terms. - Receipts that use none of these words are missed. Add senders as you notice them. - The filter does not touch mail that arrived before it existed. Step 3 exists for that reason. - shouldNeverSpam keeps these out of spam, which also means a fake receipt stays in the inbox. Read before clicking, always. --- # Tell a chat channel when a site goes down, once A five minute cron check posts to a chat webhook only when a site changes state, so an outage announces itself once and the recovery announces itself once. URL: https://automationsanonymous.com/automations/tell-a-chat-channel-when-a-site-goes-down Difficulty: beginner Tools: cron, bash, curl, slack ## Problem Uptime checks that post on every failed poll turn a two hour outage into twenty four identical messages, and people mute the channel. The useful signal is the transition, not the poll. ## Trigger Cron, every five minutes. ## Prerequisites - A webhook URL from the chat tool. - curl, present by default on macOS and most Linux images. - A writable /tmp for the state file. ## Steps 1. Create an incoming webhook in the chat tool and copy the URL (slack) Any service that accepts a JSON body with a text field works the same way. 2. Put the webhook URL in the environment, not in the script The script reads SLACK_WEBHOOK_URL and exits with a message if it is missing. 3. Set URL at the top and run it twice by hand (curl) The first run records the state and stays quiet. Point it at a URL that fails to watch the alert fire. 4. Add the cron line (cron) */5 * * * * SLACK_WEBHOOK_URL=https://... $HOME/bin/uptime.sh ## Payload (sh) ```sh #!/usr/bin/env bash # Check one URL. Post to chat only when the state changes, so a long # outage does not repeat the same message every five minutes. set -euo pipefail URL="https://example.com" WEBHOOK="${SLACK_WEBHOOK_URL:?set SLACK_WEBHOOK_URL in the environment}" STATE_FILE="/tmp/uptime-$(printf '%s' "$URL" | cksum | cut -d' ' -f1).state" code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$URL" || echo 000)" if [ "$code" = "200" ]; then now="up"; else now="down"; fi was="$(cat "$STATE_FILE" 2>/dev/null || echo up)" printf '%s' "$now" > "$STATE_FILE" if [ "$now" = "$was" ]; then exit 0 fi if [ "$now" = "down" ]; then text="$URL is down. HTTP $code." else text="$URL is back. HTTP $code." fi curl -s -X POST -H 'Content-Type: application/json' \ --data "$(printf '{"text":"%s"}' "$text")" "$WEBHOOK" > /dev/null ``` ## Failure modes - The checking machine loses its own network. Everything looks down and the alert cannot be sent anyway. Run this somewhere other than the machine you are watching. - /tmp is cleared on reboot, the state resets to up, and a still down site alerts a second time. Move STATE_FILE somewhere persistent if that matters. - The site returns 200 with an error page. This checks the status code only. - A redirect returns 301 and reads as down. Add -L to the curl call if redirects are expected. --- # Back up one folder every night and keep two weeks A nightly cron job archives one folder to a dated tar.gz and deletes archives older than fourteen days, so a restore is always a single file away. URL: https://automationsanonymous.com/automations/back-up-one-folder-every-night Difficulty: beginner Tools: cron, bash, tar ## Problem Backups that depend on remembering to run them are not backups. The folder that matters is usually one folder, and copying it by hand lasts about a week before it stops happening. ## Trigger Cron, every night at 02:00 local time. ## Prerequisites - A machine that is awake at the scheduled time. A laptop asleep at 02:00 skips the run. - Enough free space for fourteen compressed copies of the folder. - tar and cron, both present by default on macOS and Linux. ## Steps 1. Save the script as backup-folder.sh and make it executable (bash) chmod +x backup-folder.sh. Edit SOURCE and DEST at the top; nothing else needs changing. 2. Run it once by hand and confirm the archive opens (tar) tar -tzf ~/Backups/Documents-YYYY-MM-DD.tar.gz lists the contents. If that fails, stop here and fix it before scheduling anything. 3. Add the cron line (cron) crontab -e, then: 0 2 * * * $HOME/bin/backup-folder.sh >> $HOME/.backup.log 2>&1 4. Check the log the next morning tail ~/.backup.log. One line per run, with the path it wrote. ## Payload (sh) ```sh #!/usr/bin/env bash # Nightly archive of one folder. Keeps the last 14 days, deletes the rest. set -euo pipefail SOURCE="$HOME/Documents" DEST="$HOME/Backups" KEEP_DAYS=14 mkdir -p "$DEST" NAME="$(basename "$SOURCE")" ARCHIVE="$DEST/$NAME-$(date +%Y-%m-%d).tar.gz" tar --create --gzip --file "$ARCHIVE" --directory "$(dirname "$SOURCE")" "$NAME" find "$DEST" -name "$NAME-*.tar.gz" -type f -mtime +"$KEEP_DAYS" -delete echo "$(date +%Y-%m-%dT%H:%M:%S) wrote $ARCHIVE" ``` ## Failure modes - The disk fills and tar writes a truncated archive. The script does not check free space, so watch the log and the archive sizes. - The source folder is renamed or moved, tar exits non-zero, and set -e stops the run. Nothing is deleted, but nothing is backed up either. - The archive sits on the same disk as the source, so one dead drive loses both. Point DEST at an external volume or a synced folder. - cron runs with a minimal environment. Use absolute paths, and test the way cron will see it: env -i /bin/bash $HOME/bin/backup-folder.sh