This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-1-test by this push:
new 8743c572e00 [v3-1-test] Deduplicate Slack CI notifications with
artifact-based state tracking (#63676) (#63686)
8743c572e00 is described below
commit 8743c572e00ccb12b69155a760146d91cb38d2a3
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Mar 16 00:46:34 2026 +0100
[v3-1-test] Deduplicate Slack CI notifications with artifact-based state
tracking (#63676) (#63686)
Slack notifications for CI failures and missing doc inventories were
posted on every failing run regardless of whether the failure was
already reported. This adds per-branch state tracking via GitHub
Actions artifacts so notifications are only sent when the set of
failures changes or 24 hours pass (as a "still not fixed" reminder).
Recovery notifications are posted when a previously-failing run passes.
(cherry picked from commit 60e4393bfc80711a57f33ecbc28c3ae6810d5092)
---
.github/workflows/ci-amd-arm.yml | 87 ++++++++-
.github/workflows/ci-image-checks.yml | 85 ++++++++-
.github/workflows/ci-notification.yml | 69 ++++++-
.../src/airflow_breeze/utils/workflow_status.py | 39 +++-
scripts/ci/slack_notification_state.py | 210 +++++++++++++++++++++
5 files changed, 470 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/ci-amd-arm.yml b/.github/workflows/ci-amd-arm.yml
index 0faa6f16b10..02672f043eb 100644
--- a/.github/workflows/ci-amd-arm.yml
+++ b/.github/workflows/ci-amd-arm.yml
@@ -923,16 +923,89 @@ jobs:
use-uv: ${{ needs.build-info.outputs.use-uv }}
debug-resources: ${{ needs.build-info.outputs.debug-resources }}
- notify-slack-failure:
- name: "Notify Slack on Failure"
+ notify-slack:
+ name: "Notify Slack"
needs:
- build-info
- finalize-tests
- if: github.event_name == 'schedule' && failure() && github.run_attempt == 1
+ if: >-
+ always() &&
+ !cancelled() &&
+ github.event_name == 'schedule' &&
+ github.run_attempt == 1
runs-on: ["ubuntu-22.04"]
steps:
- - name: Notify Slack
- id: slack
+ - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #
v6.0.2
+ with:
+ persist-credentials: false
+ - name: "Get failing jobs"
+ id: get-failures
+ shell: bash
+ run: |
+ FAILED_JOBS=$(gh run view "${{ github.run_id }}" \
+ --repo "${{ github.repository }}" \
+ --json jobs \
+ --jq '[.jobs[] | select(.conclusion == "failure") | .name] | sort
| .[]')
+ echo "failed-jobs<<EOF" >> "${GITHUB_OUTPUT}"
+ echo "${FAILED_JOBS}" >> "${GITHUB_OUTPUT}"
+ echo "EOF" >> "${GITHUB_OUTPUT}"
+ if [[ -n "${FAILED_JOBS}" ]]; then
+ echo "has-failures=true" >> "${GITHUB_OUTPUT}"
+ else
+ echo "has-failures=false" >> "${GITHUB_OUTPUT}"
+ fi
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: "Determine notification action"
+ id: notification
+ shell: bash
+ run: python3 scripts/ci/slack_notification_state.py
+ env:
+ ARTIFACT_NAME: "slack-state-tests-${{ github.ref_name }}"
+ CURRENT_FAILURES: "${{ steps.get-failures.outputs.failed-jobs }}"
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: "Upload notification state"
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
# v7.0.0
+ with:
+ name: "slack-state-tests-${{ github.ref_name }}"
+ path: ./slack-state/
+ retention-days: 7
+ overwrite: true
+ - name: "Notify Slack (new/changed failures)"
+ if: steps.notification.outputs.action == 'notify_new'
+ uses:
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
+ with:
+ method: chat.postMessage
+ token: ${{ env.SLACK_BOT_TOKEN }}
+ # yamllint disable rule:line-length
+ payload: |
+ channel: "internal-airflow-ci-cd"
+ text: "🚨 Failure Alert: Scheduled CI (${{
needs.build-info.outputs.platform }}) on branch *${{ github.ref_name
}}*\n\nFailing jobs:\n${{ steps.get-failures.outputs.failed-jobs
}}\n\n*Details:* <https://github.com/${{ github.repository }}/actions/runs/${{
github.run_id }}|View the failure log>"
+ blocks:
+ - type: "section"
+ text:
+ type: "mrkdwn"
+ text: "🚨 Failure Alert: Scheduled CI (${{
needs.build-info.outputs.platform }}) on *${{ github.ref_name }}*\n\nFailing
jobs:\n${{ steps.get-failures.outputs.failed-jobs }}\n\n*Details:*
<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id
}}|View the failure log>"
+ # yamllint enable rule:line-length
+ - name: "Notify Slack (still not fixed)"
+ if: steps.notification.outputs.action == 'notify_reminder'
+ uses:
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
+ with:
+ method: chat.postMessage
+ token: ${{ env.SLACK_BOT_TOKEN }}
+ # yamllint disable rule:line-length
+ payload: |
+ channel: "internal-airflow-ci-cd"
+ text: "🚨🔁 Still not fixed: Scheduled CI (${{
needs.build-info.outputs.platform }}) on branch *${{ github.ref_name
}}*\n\nFailing jobs:\n${{ steps.get-failures.outputs.failed-jobs
}}\n\n*Details:* <https://github.com/${{ github.repository }}/actions/runs/${{
github.run_id }}|View the failure log>"
+ blocks:
+ - type: "section"
+ text:
+ type: "mrkdwn"
+ text: "🚨🔁 Still not fixed: Scheduled CI (${{
needs.build-info.outputs.platform }}) on *${{ github.ref_name }}*\n\nFailing
jobs:\n${{ steps.get-failures.outputs.failed-jobs }}\n\n*Details:*
<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id
}}|View the failure log>"
+ # yamllint enable rule:line-length
+ - name: "Notify Slack (all tests passing)"
+ if: steps.notification.outputs.action == 'notify_recovery'
uses:
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
@@ -940,12 +1013,12 @@ jobs:
# yamllint disable rule:line-length
payload: |
channel: "internal-airflow-ci-cd"
- text: "🚨🕒 Failure Alert: Scheduled CI (${{
needs.build-info.outputs.platform }}) on branch *${{ github.ref_name }}*
🕒🚨\n\n*Details:* <https://github.com/${{ github.repository }}/actions/runs/${{
github.run_id }}|View the failure log>"
+ text: "✅ All tests passing: Scheduled CI (${{
needs.build-info.outputs.platform }}) on branch *${{ github.ref_name
}}*\n\n*Details:* <https://github.com/${{ github.repository }}/actions/runs/${{
github.run_id }}|View the run log>"
blocks:
- type: "section"
text:
type: "mrkdwn"
- text: "🚨🕒 Failure Alert: Scheduled CI (${{
needs.build-info.outputs.platform }}) 🕒🚨\n\n*Details:* <https://github.com/${{
github.repository }}/actions/runs/${{ github.run_id }}|View the failure log>"
+ text: "✅ All tests passing: Scheduled CI (${{
needs.build-info.outputs.platform }}) on *${{ github.ref_name }}*\n\n*Details:*
<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id
}}|View the run log>"
# yamllint enable rule:line-length
summarize-warnings:
diff --git a/.github/workflows/ci-image-checks.yml
b/.github/workflows/ci-image-checks.yml
index 41b6e0cba6c..e6764de87bd 100644
--- a/.github/workflows/ci-image-checks.yml
+++ b/.github/workflows/ci-image-checks.yml
@@ -208,7 +208,7 @@ jobs:
platform: ${{ inputs.platform }}
save-cache: false
- name: "MyPy checks for ${{ matrix.mypy-check }}"
- run: prek --color always --verbose --hook-stage manual "$MYPY_CHECK"
--all-files
+ run: prek --color always --verbose --stage manual "$MYPY_CHECK"
--all-files
env:
VERBOSE: "false"
COLUMNS: "202"
@@ -254,7 +254,7 @@ jobs:
uses:
apache/infrastructure-actions/stash/restore@1c35b5ccf8fba5d4c3fdf25a045ca91aa0cbc468
with:
path: ./generated/_inventory_cache/
- key: cache-docs-inventory-v1
+ key: cache-docs-inventory
id: restore-docs-inventory-cache
- name: "Building docs with ${{ matrix.flag }} flag"
env:
@@ -277,11 +277,82 @@ jobs:
else
echo "missing=false" >> "${GITHUB_OUTPUT}"
fi
- - name: "Notify Slack about missing inventories (canary only)"
+ - name: "Get docs build job URL"
+ id: get-job-url
if: >-
+ always() &&
inputs.canary-run == 'true' &&
- steps.check-missing-inventories.outputs.missing == 'true' &&
matrix.flag == '--docs-only'
+ shell: bash
+ run: |
+ JOB_URL=$(gh api "repos/${{ github.repository }}/actions/runs/${{
github.run_id }}/jobs" \
+ --jq '[.jobs[] | select(.name | test("Build
documentation.*docs-only"))][0].html_url // empty')
+ if [[ -z "${JOB_URL}" ]]; then
+ JOB_URL="https://github.com/${{ github.repository
}}/actions/runs/${{ github.run_id }}"
+ fi
+ echo "url=${JOB_URL}" >> "${GITHUB_OUTPUT}"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: "Determine inventory notification action"
+ id: inventory-notification
+ if: >-
+ always() &&
+ inputs.canary-run == 'true' &&
+ matrix.flag == '--docs-only'
+ shell: bash
+ run: python3 scripts/ci/slack_notification_state.py
+ env:
+ ARTIFACT_NAME: "slack-state-inventory-${{ inputs.branch }}"
+ CURRENT_FAILURES: "${{
steps.check-missing-inventories.outputs.packages }}"
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: "Upload inventory notification state"
+ if: >-
+ always() &&
+ inputs.canary-run == 'true' &&
+ matrix.flag == '--docs-only'
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
# v7.0.0
+ with:
+ name: "slack-state-inventory-${{ inputs.branch }}"
+ path: ./slack-state/
+ retention-days: 7
+ overwrite: true
+ - name: "Notify Slack about missing inventories (new/changed)"
+ if: >-
+ inputs.canary-run == 'true' &&
+ matrix.flag == '--docs-only' &&
+ steps.inventory-notification.outputs.action == 'notify_new'
+ uses:
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
+ with:
+ method: chat.postMessage
+ token: ${{ env.SLACK_BOT_TOKEN }}
+ # yamllint disable rule:line-length
+ payload: |
+ channel: "internal-airflow-ci-cd"
+ text: "⚠️ Missing 3rd-party doc inventories in canary build on
*${{ github.ref_name }}*: ${{ steps.check-missing-inventories.outputs.packages
}}\n\n<${{ steps.get-job-url.outputs.url }}|View job log>"
+ # yamllint enable rule:line-length
+ env:
+ SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
+ - name: "Notify Slack about missing inventories (still not fixed)"
+ if: >-
+ inputs.canary-run == 'true' &&
+ matrix.flag == '--docs-only' &&
+ steps.inventory-notification.outputs.action == 'notify_reminder'
+ uses:
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
+ with:
+ method: chat.postMessage
+ token: ${{ env.SLACK_BOT_TOKEN }}
+ # yamllint disable rule:line-length
+ payload: |
+ channel: "internal-airflow-ci-cd"
+ text: "⚠️🔁 Still not fixed: Missing 3rd-party doc inventories in
canary build on *${{ github.ref_name }}*: ${{
steps.check-missing-inventories.outputs.packages }}\n\n<${{
steps.get-job-url.outputs.url }}|View job log>"
+ # yamllint enable rule:line-length
+ env:
+ SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
+ - name: "Notify Slack about inventory recovery"
+ if: >-
+ inputs.canary-run == 'true' &&
+ matrix.flag == '--docs-only' &&
+ steps.inventory-notification.outputs.action == 'notify_recovery'
uses:
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
@@ -289,7 +360,7 @@ jobs:
# yamllint disable rule:line-length
payload: |
channel: "internal-airflow-ci-cd"
- text: "⚠️ Missing 3rd-party doc inventories in canary build on
*${{ github.ref_name }}*\n\nPackages:\n${{
steps.check-missing-inventories.outputs.packages }}\n\n<https://github.com/${{
github.repository }}/actions/runs/${{ github.run_id }}/job/${{ job.check_run_id
}}|View job log>"
+ text: "✅ All 3rd-party doc inventories are now available in canary
build on *${{ github.ref_name }}*\n\n<${{ steps.get-job-url.outputs.url }}|View
job log>"
# yamllint enable rule:line-length
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
@@ -297,12 +368,12 @@ jobs:
uses:
apache/infrastructure-actions/stash/save@1c35b5ccf8fba5d4c3fdf25a045ca91aa0cbc468
with:
path: ./generated/_inventory_cache/
- key: cache-docs-inventory-v1
+ key: cache-docs-inventory-v1-${{ hashFiles('**/pyproject.toml') }}
if-no-files-found: 'error'
retention-days: '2'
# If we upload from multiple matrix jobs we could end up with a race
condition. so just pick one job
# to be responsible for updating it.
https://github.com/actions/upload-artifact/issues/506
- if: steps.restore-docs-inventory-cache.outputs.stash-hit != 'true' &&
matrix.flag == '--docs-only'
+ if: steps.restore-docs-inventory-cache != 'true' && matrix.flag ==
'--docs-only'
- name: "Upload build docs"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
# v7.0.0
with:
diff --git a/.github/workflows/ci-notification.yml
b/.github/workflows/ci-notification.yml
index e009e380e92..2cad9aec1e9 100644
--- a/.github/workflows/ci-notification.yml
+++ b/.github/workflows/ci-notification.yml
@@ -55,9 +55,67 @@ jobs:
workflow_branch: ${{ matrix.branch }}
workflow_id: ${{ matrix.workflow-id }}
- - name: "Send Slack notification"
- if: steps.find-workflow-run-status.outputs.conclusion == 'failure'
- id: slack
+ - name: "Determine notification action"
+ id: notification
+ shell: bash
+ run: python3 scripts/ci/slack_notification_state.py
+ env:
+ ARTIFACT_NAME: "slack-state-ci-${{ matrix.branch }}-${{
matrix.workflow-id }}"
+ CURRENT_FAILURES: "${{
steps.find-workflow-run-status.outputs.failed-jobs }}"
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: "Upload notification state"
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
# v7.0.0
+ with:
+ name: "slack-state-ci-${{ matrix.branch }}-${{ matrix.workflow-id }}"
+ path: ./slack-state/
+ retention-days: 7
+ overwrite: true
+
+ - name: "Send Slack notification (new/changed failures)"
+ if: steps.notification.outputs.action == 'notify_new'
+ uses:
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
+ with:
+ method: chat.postMessage
+ token: ${{ env.SLACK_BOT_TOKEN }}
+ # yamllint disable rule:line-length
+ payload: |
+ channel: "internal-airflow-ci-cd"
+ text: "🚨 Failure Alert: ${{ env.workflow_id }} on branch *${{
env.branch }}*\n\nFailing jobs:\n${{
steps.find-workflow-run-status.outputs.failed-jobs }}\n\n*Details:* <${{
env.run_url }}|View the failure log>"
+ blocks:
+ - type: "section"
+ text:
+ type: "mrkdwn"
+ text: "🚨 Failure Alert: ${{ env.workflow_id }} on *${{
env.branch }}*\n\nFailing jobs:\n${{
steps.find-workflow-run-status.outputs.failed-jobs }}\n\n*Details:* <${{
env.run_url }}|View the failure log>"
+ # yamllint enable rule:line-length
+ env:
+ run_url: ${{ steps.find-workflow-run-status.outputs.run-url }}
+ branch: ${{ matrix.branch }}
+ workflow_id: ${{ matrix.workflow-id }}
+
+ - name: "Send Slack notification (still not fixed)"
+ if: steps.notification.outputs.action == 'notify_reminder'
+ uses:
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
+ with:
+ method: chat.postMessage
+ token: ${{ env.SLACK_BOT_TOKEN }}
+ # yamllint disable rule:line-length
+ payload: |
+ channel: "internal-airflow-ci-cd"
+ text: "🚨🔁 Still not fixed: ${{ env.workflow_id }} on branch *${{
env.branch }}*\n\nFailing jobs:\n${{
steps.find-workflow-run-status.outputs.failed-jobs }}\n\n*Details:* <${{
env.run_url }}|View the failure log>"
+ blocks:
+ - type: "section"
+ text:
+ type: "mrkdwn"
+ text: "🚨🔁 Still not fixed: ${{ env.workflow_id }} on *${{
env.branch }}*\n\nFailing jobs:\n${{
steps.find-workflow-run-status.outputs.failed-jobs }}\n\n*Details:* <${{
env.run_url }}|View the failure log>"
+ # yamllint enable rule:line-length
+ env:
+ run_url: ${{ steps.find-workflow-run-status.outputs.run-url }}
+ branch: ${{ matrix.branch }}
+ workflow_id: ${{ matrix.workflow-id }}
+
+ - name: "Send Slack notification (all passing)"
+ if: steps.notification.outputs.action == 'notify_recovery'
uses:
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
@@ -65,12 +123,13 @@ jobs:
# yamllint disable rule:line-length
payload: |
channel: "internal-airflow-ci-cd"
- text: "🚨🕒 Failure Alert: ${{ env.workflow_id }} on branch *${{
env.branch }}* 🕒🚨\n\n*Details:* <${{ env.run_url }}|View the failure log>"
+ text: "✅ All passing: ${{ env.workflow_id }} on branch *${{
env.branch }}*\n\n*Details:* <${{ env.run_url }}|View the run log>"
blocks:
- type: "section"
text:
type: "mrkdwn"
- text: "🚨🕒 Failure Alert: ${{ env.workflow_id }} ${{
env.branch }} 🕒🚨\n\n*Details:* <${{ env.run_url }}|View the failure log>"
+ text: "✅ All passing: ${{ env.workflow_id }} on *${{
env.branch }}*\n\n*Details:* <${{ env.run_url }}|View the run log>"
+ # yamllint enable rule:line-length
env:
run_url: ${{ steps.find-workflow-run-status.outputs.run-url }}
branch: ${{ matrix.branch }}
diff --git a/dev/breeze/src/airflow_breeze/utils/workflow_status.py
b/dev/breeze/src/airflow_breeze/utils/workflow_status.py
index 482d702b5e0..6ed3bd55390 100644
--- a/dev/breeze/src/airflow_breeze/utils/workflow_status.py
+++ b/dev/breeze/src/airflow_breeze/utils/workflow_status.py
@@ -51,7 +51,7 @@ def workflow_status(
"--repo",
"apache/airflow",
"--json",
- "conclusion,url",
+ "conclusion,url,databaseId",
]
result = subprocess.run(
cmd,
@@ -71,6 +71,33 @@ def workflow_status(
return run_info
+def get_failed_jobs(run_id: int) -> list[str]:
+ """Get list of failed job names from a workflow run."""
+ cmd = [
+ "gh",
+ "run",
+ "view",
+ str(run_id),
+ "--repo",
+ "apache/airflow",
+ "--json",
+ "jobs",
+ "--jq",
+ '[.jobs[] | select(.conclusion == "failure") | .name] | sort | .[]',
+ ]
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ console.print(f"[red]Error fetching failed jobs:
{result.stderr}[/red]")
+ return []
+
+ return [line.strip() for line in result.stdout.strip().splitlines() if
line.strip()]
+
+
if __name__ == "__main__":
branch = os.environ.get("workflow_branch")
workflow_id = os.environ.get("workflow_id")
@@ -84,6 +111,13 @@ if __name__ == "__main__":
data: list[dict] = workflow_status(branch, workflow_id)
conclusion = data[0].get("conclusion")
url = data[0].get("url")
+ run_id = data[0].get("databaseId")
+
+ failed_jobs: list[str] = []
+ if conclusion == "failure" and run_id:
+ console.print(f"[blue]Fetching failed jobs for run {run_id}[/blue]")
+ failed_jobs = get_failed_jobs(run_id)
+ console.print(f"[blue]Failed jobs: {failed_jobs}[/blue]")
if os.environ.get("GITHUB_OUTPUT") is None:
console.print("[red]GITHUB_OUTPUT environment variable is not set.
Cannot write output.[/red]")
@@ -92,3 +126,6 @@ if __name__ == "__main__":
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"conclusion={conclusion}\n")
f.write(f"run-url={url}\n")
+ f.write(f"run-id={run_id}\n")
+ failed_jobs_str = "\n".join(failed_jobs)
+ f.write(f"failed-jobs<<EOF\n{failed_jobs_str}\nEOF\n")
diff --git a/scripts/ci/slack_notification_state.py
b/scripts/ci/slack_notification_state.py
new file mode 100644
index 00000000000..f6dc0a035c2
--- /dev/null
+++ b/scripts/ci/slack_notification_state.py
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# /// script
+# requires-python = ">=3.10"
+# ///
+"""
+Determine whether to send a Slack notification based on previous state.
+
+Downloads previous state from GitHub Actions artifacts, compares with current
+failures, and outputs the appropriate action to take.
+
+Outputs (written to GITHUB_OUTPUT):
+ action - One of: notify_new, notify_reminder, notify_recovery,
skip
+ current-failures - JSON list of current failure names
+ previous-failures - JSON list of previous failure names
+
+Environment variables (required):
+ ARTIFACT_NAME - Name of the artifact storing notification state
+ GITHUB_REPOSITORY - Owner/repo (e.g. apache/airflow)
+
+Environment variables (optional):
+ CURRENT_FAILURES - Newline-separated list of current failures (empty if
none)
+ GITHUB_OUTPUT - Path to GitHub Actions output file
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+REMINDER_INTERVAL_HOURS = 24
+STATE_DIR = Path("./slack-state")
+PREV_STATE_DIR = Path("./prev-slack-state")
+
+
+def download_previous_state(artifact_name: str, repo: str) -> dict | None:
+ """Download previous notification state artifact from GitHub Actions."""
+ result = subprocess.run(
+ [
+ "gh",
+ "api",
+ f"repos/{repo}/actions/artifacts",
+ "-f",
+ f"name={artifact_name}",
+ "-f",
+ "per_page=1",
+ "--jq",
+ ".artifacts[0]",
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ print(f"Could not query artifacts API: {result.stderr}",
file=sys.stderr)
+ return None
+
+ output = result.stdout.strip()
+ if not output or output == "null":
+ print("No previous state artifact found.")
+ return None
+
+ try:
+ artifact = json.loads(output)
+ except json.JSONDecodeError:
+ print(f"Invalid JSON from artifacts API: {output}", file=sys.stderr)
+ return None
+
+ if artifact.get("expired", False):
+ print("Previous state artifact has expired.")
+ return None
+
+ run_id = artifact.get("workflow_run", {}).get("id")
+ if not run_id:
+ print("No workflow run ID in artifact metadata.")
+ return None
+
+ PREV_STATE_DIR.mkdir(parents=True, exist_ok=True)
+ dl_result = subprocess.run(
+ [
+ "gh",
+ "run",
+ "download",
+ str(run_id),
+ "--name",
+ artifact_name,
+ "--dir",
+ str(PREV_STATE_DIR),
+ "--repo",
+ repo,
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ if dl_result.returncode != 0:
+ print(f"Could not download previous state: {dl_result.stderr}",
file=sys.stderr)
+ return None
+
+ state_file = PREV_STATE_DIR / "state.json"
+ if not state_file.exists():
+ print("Downloaded artifact does not contain state.json.")
+ return None
+
+ try:
+ return json.loads(state_file.read_text())
+ except json.JSONDecodeError:
+ print("Invalid JSON in state.json.", file=sys.stderr)
+ return None
+
+
+def determine_action(current_failures: list[str], prev_state: dict | None) ->
str:
+ """Determine what notification action to take.
+
+ Returns one of: notify_new, notify_reminder, notify_recovery, skip.
+ """
+ prev_failures = sorted(prev_state.get("failures", [])) if prev_state else
[]
+ prev_notified = prev_state.get("last_notified") if prev_state else None
+ now = datetime.now(timezone.utc)
+
+ if current_failures:
+ if not prev_failures:
+ return "notify_new"
+ if current_failures != prev_failures:
+ return "notify_new"
+ # Same failures as before — check if reminder is due
+ if prev_notified:
+ prev_time = datetime.fromisoformat(prev_notified)
+ hours_since = (now - prev_time).total_seconds() / 3600
+ if hours_since >= REMINDER_INTERVAL_HOURS:
+ return "notify_reminder"
+ else:
+ return "notify_new"
+ elif prev_failures:
+ # Was failing, now all clear
+ return "notify_recovery"
+
+ return "skip"
+
+
+def save_state(current_failures: list[str], action: str, prev_notified: str |
None) -> None:
+ """Save current state to file for upload as artifact."""
+ now = datetime.now(timezone.utc)
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+
+ new_state = {
+ "failures": current_failures,
+ "last_notified": (now.isoformat() if action != "skip" else
(prev_notified or now.isoformat())),
+ }
+ (STATE_DIR / "state.json").write_text(json.dumps(new_state, indent=2))
+ print(f"Saved state to {STATE_DIR / 'state.json'}: {new_state}")
+
+
+def main() -> None:
+ artifact_name = os.environ.get("ARTIFACT_NAME")
+ if not artifact_name:
+ print("ERROR: ARTIFACT_NAME environment variable is required.",
file=sys.stderr)
+ sys.exit(1)
+
+ repo = os.environ.get("GITHUB_REPOSITORY", "apache/airflow")
+ current_failures_str = os.environ.get("CURRENT_FAILURES", "")
+ current_failures = sorted([f.strip() for f in
current_failures_str.strip().splitlines() if f.strip()])
+
+ # Download previous state
+ print(f"Looking up previous state for artifact: {artifact_name}")
+ prev_state = download_previous_state(artifact_name, repo)
+ print(f"Previous state: {prev_state}")
+
+ # Determine action
+ action = determine_action(current_failures, prev_state)
+ prev_failures = sorted(prev_state.get("failures", [])) if prev_state else
[]
+
+ print(f"Action: {action}")
+ print(f"Current failures: {current_failures}")
+ print(f"Previous failures: {prev_failures}")
+
+ # Save new state
+ prev_notified = prev_state.get("last_notified") if prev_state else None
+ save_state(current_failures, action, prev_notified)
+
+ # Output for GitHub Actions
+ github_output = os.environ.get("GITHUB_OUTPUT")
+ if github_output:
+ with open(github_output, "a") as f:
+ f.write(f"action={action}\n")
+ f.write(f"current-failures={json.dumps(current_failures)}\n")
+ f.write(f"previous-failures={json.dumps(prev_failures)}\n")
+
+
+if __name__ == "__main__":
+ main()