diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..74b5c16
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,293 @@
+name: E2E
+
+# Checks the CloudWatch → Lambda → Better Stack setup from our docs end to end in a throwaway AWS account: creates an
+# AWS CloudWatch source, creates a Lambda from the ZIP with the runtime, architecture and environment variables the docs
+# name, subscribes a log group to it the way the docs describe, writes 20 log lines into the group and compares every
+# stored row with the CloudWatch event that produced it through the team's SQL API connection. The Lambda's own log
+# group must stay free of errors. Pull requests and pushes to main build the ZIP from the checkout; the daily schedule
+# deploys the released ZIP the docs link to. Everything the run creates is named after RUN_ID and deleted in the cleanup
+# step, together with leftovers of earlier runs older than two hours. Pull requests from forks are skipped: they get no
+# OIDC token.
+# See the docs guide in https://betterstack.com/docs/logs/aws-cloudwatch/
+#
+# AWS account: better-stack-e2e-lambda (352333568934), a member of the Better Stack organization that holds nothing but
+# this test. Root sign-in: 1Password, Engineering vault, item "AWS better-stack-e2e-lambda". The standing resources are
+# the CloudFormation stack `e2e-bootstrap`: a GitHub OIDC provider, IAM role `github-actions-e2e` trusting this
+# repository, and IAM role `e2e-lambda-exec` that the test's Lambda runs as (kept standing because a role created
+# seconds before `lambda create-function` is refused until IAM propagates it).
+# Secrets: TEST_TEAM_TELEMETRY_API, TEST_TEAM_CONNECTION_USERNAME, TEST_TEAM_CONNECTION_PASSWORD (the E2E testing team),
+# LINEAR_* (see notify_linear_failure.yml).
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ schedule:
+ - cron: '30 5 * * *'
+ workflow_dispatch:
+ inputs:
+ zip_url:
+ description: 'Lambda ZIP to deploy instead of the checkout build (e.g. a GitHub release asset)'
+ type: string
+
+permissions:
+ id-token: write
+ contents: read
+ actions: read
+
+env:
+ AWS_REGION: us-east-1
+ AWS_ROLE_ARN: arn:aws:iam::352333568934:role/github-actions-e2e
+ LAMBDA_ROLE_ARN: arn:aws:iam::352333568934:role/e2e-lambda-exec
+ API: https://telemetry.betterstack.com
+ DOCS_URL: https://betterstack.com/docs/logs/aws-cloudwatch/
+ RUN_ID: gh${{ github.run_id }}a${{ github.run_attempt }}
+ SOURCE_PREFIX: e2e-aws-cloudwatch-lambda
+ FUNCTION_NAME: e2e-gh${{ github.run_id }}a${{ github.run_attempt }}
+ LOG_GROUP: /e2e/gh${{ github.run_id }}a${{ github.run_attempt }}
+ MARKER: e2e run_id=gh${{ github.run_id }}a${{ github.run_attempt }}
+ ZIP_URL_OVERRIDE: ${{ inputs.zip_url }}
+ TEST_TEAM_TELEMETRY_API: ${{ secrets.TEST_TEAM_TELEMETRY_API }}
+ TEST_TEAM_CONNECTION_USERNAME: ${{ secrets.TEST_TEAM_CONNECTION_USERNAME }}
+ TEST_TEAM_CONNECTION_PASSWORD: ${{ secrets.TEST_TEAM_CONNECTION_PASSWORD }}
+
+jobs:
+ test:
+ name: CloudWatch round trip
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ steps:
+ - name: "[Setup] Checkout code"
+ uses: actions/checkout@v7
+
+ - name: "[Setup] Node.js"
+ uses: actions/setup-node@v7
+ with:
+ node-version: 22.x
+ cache: npm
+
+ - name: "[Check] Docs article names the ZIP, runtime, architecture, environment variables and subscription filter"
+ run: |
+ curl -sSfL "$DOCS_URL" -o docs.html
+ ZIP_URL=$(grep -oE 'https://github.com/logtail/logtail-aws-lambda/releases/latest/download/[A-Za-z0-9._-]+\.zip' docs.html | head -1)
+ RUNTIME_LABEL=$(grep -oE 'Runtime: ]*>[^<]+' docs.html | head -1 | sed -E 's#.*]*>([^<]+).*#\1#')
+ RUNTIME=$(sed -nE 's/^Node\.js ([0-9]+)\.x$/nodejs\1.x/p' <<<"$RUNTIME_LABEL")
+ MISSING=""
+ [ -n "$ZIP_URL" ] || MISSING="$MISSING zip-download-link"
+ [ -n "$RUNTIME" ] || MISSING="$MISSING runtime(got:'$RUNTIME_LABEL')"
+ for text in 'Architecture: ]*>x86_64<' BETTER_STACK_ENTRYPOINT BETTER_STACK_SOURCE_TOKEN 'Create Lambda subscription filter' 'Log format: ]*>JSON<'; do
+ grep -qE "$text" docs.html || MISSING="$MISSING '$text'"
+ done
+ [ -z "$MISSING" ] || { echo "::error::$DOCS_URL changed, this test no longer matches it, missing:$MISSING"; exit 1; }
+ echo "DOCS_ZIP_URL=$ZIP_URL" >> "$GITHUB_ENV"
+ echo "RUNTIME=$RUNTIME" >> "$GITHUB_ENV"
+ echo "docs: $ZIP_URL on $RUNTIME_LABEL ($RUNTIME), x86_64"
+
+ - name: "[Setup] The Lambda ZIP: built from this checkout, or the released one on the schedule"
+ run: |
+ if [ -n "$ZIP_URL_OVERRIDE" ]; then
+ curl -sSfL "$ZIP_URL_OVERRIDE" -o logtail-aws-lambda.zip
+ ZIP_FROM="$ZIP_URL_OVERRIDE"
+ # An explicit asset URL carries its tag; the latest-release URL redirects to releases/download//...
+ RELEASE_TAG=$(sed -nE 's#.*/releases/download/([^/]+)/.*#\1#p' <<<"$ZIP_URL_OVERRIDE")
+ [ -n "$RELEASE_TAG" ] || RELEASE_TAG=$(curl -sSI "$ZIP_URL_OVERRIDE" | tr -d '\r' | sed -nE 's#^[Ll]ocation: .*/releases/download/([^/]+)/.*#\1#p' | head -1)
+ elif [ "${{ github.event_name }}" = schedule ]; then
+ curl -sSfL "$DOCS_ZIP_URL" -o logtail-aws-lambda.zip
+ ZIP_FROM="$DOCS_ZIP_URL"
+ RELEASE_TAG=$(curl -sSI "$DOCS_ZIP_URL" | tr -d '\r' | sed -nE 's#^[Ll]ocation: .*/releases/download/([^/]+)/.*#\1#p' | head -1)
+ [ -n "$RELEASE_TAG" ] || { echo "::error::could not tell the release tag behind $DOCS_ZIP_URL"; exit 1; }
+ else
+ npm ci
+ npm run build
+ cp build/logtail-aws-lambda.zip logtail-aws-lambda.zip
+ ZIP_FROM="this checkout"
+ RELEASE_TAG="$(git rev-parse --short HEAD)"
+ fi
+ unzip -l logtail-aws-lambda.zip | grep -qE ' index\.js$' || { echo "::error::the ZIP contains no index.js"; unzip -l logtail-aws-lambda.zip | head; exit 1; }
+ CLIENT_VERSION=$(unzip -p logtail-aws-lambda.zip node_modules/@logtail/node/package.json | jq -r '.version // empty')
+ [ -n "$CLIENT_VERSION" ] || { echo "::error::the ZIP bundles no node_modules/@logtail/node/package.json"; exit 1; }
+ echo "ZIP_FROM=$ZIP_FROM" >> "$GITHUB_ENV"
+ echo "RELEASE_TAG=${RELEASE_TAG:-unknown}" >> "$GITHUB_ENV"
+ echo "CLIENT_VERSION=$CLIENT_VERSION" >> "$GITHUB_ENV"
+ echo "logtail-aws-lambda ${RELEASE_TAG:-unknown} with @logtail/node $CLIENT_VERSION, $(stat -c %s logtail-aws-lambda.zip) bytes from $ZIP_FROM"
+
+ - name: "[Setup] AWS credentials (OIDC)"
+ uses: aws-actions/configure-aws-credentials@v5
+ with:
+ role-to-assume: ${{ env.AWS_ROLE_ARN }}
+ aws-region: ${{ env.AWS_REGION }}
+
+ - name: "[Setup] Create AWS CloudWatch source"
+ run: |
+ RES=$(curl -sS -X POST "$API/api/v1/sources" -H "Authorization: Bearer $TEST_TEAM_TELEMETRY_API" -H 'Content-Type: application/json' \
+ -d "$(jq -nc --arg name "$SOURCE_PREFIX $RUN_ID" '{name: $name, platform: "aws_cloudwatch"}')")
+ jq -e '.data.id' <<<"$RES" >/dev/null || { echo "::error::creating the source failed: $RES"; exit 1; }
+ SOURCE_TOKEN=$(jq -r '.data.attributes.token' <<<"$RES")
+ echo "::add-mask::$SOURCE_TOKEN"
+ {
+ echo "SOURCE_ID=$(jq -r '.data.id' <<<"$RES")"
+ echo "SOURCE_TOKEN=$SOURCE_TOKEN"
+ echo "INGESTING_HOST=$(jq -r '.data.attributes.ingesting_host' <<<"$RES")"
+ echo "COLLECTION=t$(jq -r '.data.attributes.team_id' <<<"$RES")_$(jq -r '.data.attributes.table_name' <<<"$RES")"
+ echo "CONNECT_HOST=$(jq -r '.data.attributes.data_region' <<<"$RES")-connect.betterstackdata.com"
+ echo "ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)"
+ } >> "$GITHUB_ENV"
+ echo "source $(jq -r '.data.id' <<<"$RES") on $(jq -r '.data.attributes.data_region' <<<"$RES")"
+
+ # A fresh token reaches the ingesters with a delay; until then they answer 401 and the Lambda would drop or retry
+ # its batches. Send warm-up lines until 20 in a row are accepted, then let it settle.
+ - name: "[Setup] Warm up the source until ingest accepts its token"
+ run: |
+ STREAK=0
+ DEADLINE=$((SECONDS + 300))
+ while [ "$STREAK" -lt 20 ]; do
+ CODE=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "https://$INGESTING_HOST/" -H "Authorization: Bearer $SOURCE_TOKEN" -H 'Content-Type: application/json' -d "{\"message\":\"e2e warm-up run_id=$RUN_ID\"}")
+ if [ "$CODE" = 202 ]; then STREAK=$((STREAK + 1)); else echo "ingest answered HTTP $CODE after $STREAK accepted"; STREAK=0; fi
+ [ "$SECONDS" -lt "$DEADLINE" ] || { echo "::error::ingest did not accept the source token 20 times in a row within 5 min"; exit 1; }
+ sleep 1
+ done
+ sleep 15
+ echo "ingest accepts the token"
+
+ - name: "[Test] Create the Lambda from the ZIP with the documented settings"
+ run: |
+ # The response would echo the environment variables, token included; nothing of it is needed.
+ aws lambda create-function --function-name "$FUNCTION_NAME" --runtime "$RUNTIME" --architectures x86_64 --role "$LAMBDA_ROLE_ARN" \
+ --handler index.handler --zip-file fileb://logtail-aws-lambda.zip --timeout 30 \
+ --environment "Variables={BETTER_STACK_ENTRYPOINT=https://$INGESTING_HOST,BETTER_STACK_SOURCE_TOKEN=$SOURCE_TOKEN}" >/dev/null
+ aws lambda wait function-active-v2 --function-name "$FUNCTION_NAME"
+ echo "FUNCTION_ARN=$(aws lambda get-function --function-name "$FUNCTION_NAME" --query Configuration.FunctionArn --output text)" >> "$GITHUB_ENV"
+
+ - name: "[Test] Subscribe a log group to the Lambda"
+ run: |
+ aws logs create-log-group --log-group-name "$LOG_GROUP"
+ aws logs create-log-stream --log-group-name "$LOG_GROUP" --log-stream-name run
+ aws lambda add-permission --function-name "$FUNCTION_NAME" --statement-id e2e-logs --action lambda:InvokeFunction --principal logs.amazonaws.com \
+ --source-arn "arn:aws:logs:$AWS_REGION:$ACCOUNT_ID:log-group:$LOG_GROUP:*" --source-account "$ACCOUNT_ID" >/dev/null
+ aws logs put-subscription-filter --log-group-name "$LOG_GROUP" --filter-name logtail-aws-lambda-filter --filter-pattern "" --destination-arn "$FUNCTION_ARN"
+
+ - name: "[Test] Write 20 log lines, 5 of them with an [ERROR] level prefix"
+ run: |
+ echo "SINCE=dt > toDateTime('$(date -u -d '-10 minutes' '+%F %T')')" >> "$GITHUB_ENV"
+ jq -nc --arg m "$MARKER" '[range(1; 21) as $n | {timestamp: ((now * 1000 | floor) + $n), message: (if $n <= 5 then "[ERROR] \($m) n=\($n)" else "\($m) n=\($n)" end)}]' > events.json
+ aws logs put-log-events --log-group-name "$LOG_GROUP" --log-stream-name run --log-events file://events.json >/dev/null
+
+ - name: "[Test] Read the lines back through the SQL API connection"
+ run: |
+ set -o pipefail
+ sql() {
+ CODE=$(curl -sS -o response.json -w '%{http_code}' -u "$TEST_TEAM_CONNECTION_USERNAME:$TEST_TEAM_CONNECTION_PASSWORD" -H 'Content-Type: plain/text' --data-binary "$1 FORMAT JSONEachRow" "https://$CONNECT_HOST/")
+ # A source's collection exists only from its first stored row on; until then the query is "no rows yet".
+ [ "$CODE" = 500 ] && grep -q CLUSTER_DOESNT_EXIST response.json && return 0
+ [ "$CODE" = 200 ] || { echo "::error::SQL API on $CONNECT_HOST answered HTTP $CODE: $(head -c 300 response.json)" >&2; return 1; }
+ cat response.json
+ }
+ count() { ROWS=$(sql "SELECT count() AS n FROM remote(${COLLECTION}_logs) WHERE $SINCE AND position(raw, '$MARKER') > 0 $1") || return 1; [ -n "$ROWS" ] && jq -r '.n' <<<"$ROWS" || echo 0; }
+ counts() {
+ TOTAL=$(count "")
+ ERRORS=$(count "AND JSONExtractString(raw, 'level') = 'error'")
+ INFOS=$(count "AND JSONExtractString(raw, 'level') = 'info'")
+ NAMED=$(count "AND JSONExtractString(raw, 'context', 'logger_lambda_name') = '$FUNCTION_NAME'")
+ echo "rows $TOTAL/20, level error $ERRORS/5, level info $INFOS/15, lambda name $NAMED/20"
+ }
+ DEADLINE=$((SECONDS + 300))
+ while :; do
+ counts
+ [ "$TOTAL" -ge 20 ] && break
+ [ "$SECONDS" -lt "$DEADLINE" ] || { echo "::error::only $TOTAL of 20 lines arrived within 5 min"; exit 1; }
+ sleep 15
+ done
+ # Everything arrived; a last look 30 s later catches duplicate delivery.
+ sleep 30
+ counts
+ [ "$TOTAL" = 20 ] && [ "$ERRORS" = 5 ] && [ "$INFOS" = 15 ] && [ "$NAMED" = 20 ] || { echo "::error::unexpected rows: total $TOTAL (20), error $ERRORS (5), info $INFOS (15), lambda name $NAMED (20)"; exit 1; }
+ mkdir -p out
+ sql "SELECT * FROM remote(${COLLECTION}_logs) WHERE $SINCE AND position(raw, '$MARKER') > 0 ORDER BY dt LIMIT 2" > out/sample_log.json
+ # Every stored row, field by field, against the event that produced it (events.json from the write step):
+ # the level prefix stripped from the message, the level, the CloudWatch timestamp as dt, and the context
+ # the Lambda's parser builds. The key sets are exact, so a new or renamed field fails here.
+ sql "SELECT toUnixTimestamp64Milli(dt) AS dt_ms, raw FROM remote(${COLLECTION}_logs) WHERE $SINCE AND position(raw, '$MARKER') > 0 ORDER BY dt" > rows.jsonl
+ PROBLEMS=$(jq -rs --slurpfile sent events.json --arg fn "$FUNCTION_NAME" --arg arn "$FUNCTION_ARN" --arg group "$LOG_GROUP" --arg owner "$ACCOUNT_ID" --arg marker "$MARKER" '
+ map(.raw |= fromjson)
+ | map(
+ (.raw.message | capture("n=(?[0-9]+)$") | .n | tonumber) as $n
+ | $sent[0][$n - 1] as $event
+ | [
+ (if (.raw | keys) != ["context", "dt", "level", "message"] then "keys \(.raw | keys | join("/"))" else empty end),
+ (if .raw.message != "\($marker) n=\($n)" then "message \(.raw.message | tojson)" else empty end),
+ (if .raw.level != (if $n <= 5 then "error" else "info" end) then "level \(.raw.level)" else empty end),
+ (if (.dt_ms | tonumber) != $event.timestamp then "dt \(.dt_ms) ms, sent \($event.timestamp)" else empty end),
+ (if (.raw.context | keys) != ["log_group", "log_id", "log_stream", "logger_lambda_arn", "logger_lambda_name", "owner"] then "context keys \(.raw.context | keys | join("/"))" else empty end),
+ (if .raw.context.logger_lambda_name != $fn then "logger_lambda_name \(.raw.context.logger_lambda_name)" else empty end),
+ (if .raw.context.logger_lambda_arn != $arn then "logger_lambda_arn \(.raw.context.logger_lambda_arn)" else empty end),
+ (if .raw.context.owner != $owner then "owner \(.raw.context.owner)" else empty end),
+ (if .raw.context.log_group != $group then "log_group \(.raw.context.log_group)" else empty end),
+ (if .raw.context.log_stream != "run" then "log_stream \(.raw.context.log_stream)" else empty end),
+ (if (.raw.context.log_id | type) != "string" or .raw.context.log_id == "" then "log_id \(.raw.context.log_id)" else empty end)
+ ] as $problems
+ | select($problems | length > 0) | "n=\($n): \($problems | join(", "))")
+ | .[]' rows.jsonl)
+ [ -z "$PROBLEMS" ] || { echo "::error::stored rows differ from what the Lambda should have produced:"; echo "$PROBLEMS"; exit 1; }
+ {
+ echo "### AWS CloudWatch Lambda E2E \`$RUN_ID\`"
+ echo
+ echo "- logtail-aws-lambda \`$RELEASE_TAG\` from $ZIP_FROM, bundling @logtail/node \`$CLIENT_VERSION\`"
+ echo "- source $SOURCE_ID, Lambda \`$FUNCTION_NAME\` on \`$RUNTIME\` x86_64"
+ echo "- read back through \`$CONNECT_HOST\`: $TOTAL rows, $ERRORS error, $INFOS info; every row matches its CloudWatch event in message, level, timestamp and context"
+ echo
+ echo "✅ CloudWatch → Lambda → Better Stack round trip"
+ } > out/summary.md
+
+ - name: "[Test] The Lambda logged no errors"
+ run: |
+ ERRORS=$(aws logs filter-log-events --log-group-name "/aws/lambda/$FUNCTION_NAME" --filter-pattern '?ERROR ?"Task timed out" ?Unhandled' --query 'events[].message' --output text)
+ [ -z "$ERRORS" ] || { echo "::error::the Lambda logged errors:"; echo "$ERRORS"; exit 1; }
+ echo "no errors in /aws/lambda/$FUNCTION_NAME"
+
+ - name: "[Cleanup] Delete this run's resources in Better Stack and AWS (and stale ones)"
+ if: ${{ always() }}
+ run: |
+ set -o pipefail
+ mkdir -p out
+ say() { echo "$1"; echo "$1" >> out/cleanup.md; }
+ could_not_list() { say "- ❌ could not list $1, so none were deleted"; touch cleanup.failed; }
+ say "### Cleanup"
+ say ""
+ STALE_BEFORE=$(date -u -d '-2 hours' +%FT%TZ)
+ SOURCES=$(curl -sS --fail-with-body "$API/api/v1/sources?name=$SOURCE_PREFIX&per_page=250" -H "Authorization: Bearer $TEST_TEAM_TELEMETRY_API" \
+ | jq -r --arg run " $RUN_ID" --arg before "$STALE_BEFORE" '.data[] | select((.attributes.name | endswith($run)) or .attributes.created_at < $before) | "\(.id) \(.attributes.name)"') || { could_not_list sources; SOURCES=""; }
+ while read -r id name; do
+ [ -n "$id" ] || continue
+ CODE=$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE "$API/api/v1/sources/$id" -H "Authorization: Bearer $TEST_TEAM_TELEMETRY_API")
+ [ "$CODE" = 204 ] && say "- deleted source $id \`$name\`" || { say "- ❌ could not delete source $id \`$name\` (HTTP $CODE)"; touch cleanup.failed; }
+ done <<<"$SOURCES"
+ # Runs are named e2e-gha; nothing else in the account starts with that.
+ FUNCTIONS=$(aws lambda list-functions --query "Functions[?starts_with(FunctionName, 'e2e-gh')].[FunctionName,LastModified]" --output json \
+ | jq -r --arg name "$FUNCTION_NAME" --arg before "$STALE_BEFORE" '.[] | select(.[0] == $name or .[1] < $before) | .[0]') || { could_not_list Lambdas; FUNCTIONS=""; }
+ for fn in $FUNCTIONS; do
+ if aws lambda delete-function --function-name "$fn"; then say "- deleted Lambda \`$fn\`"; else say "- ❌ could not delete Lambda \`$fn\`"; touch cleanup.failed; fi
+ done
+ LOG_GROUPS=$(aws logs describe-log-groups --query 'logGroups[].[logGroupName,creationTime]' --output json \
+ | jq -r --arg mine "$LOG_GROUP" --arg fn "/aws/lambda/$FUNCTION_NAME" --argjson before "$(( $(date -u -d '-2 hours' +%s) * 1000 ))" '.[] | select(((.[0] | startswith("/e2e/gh")) or (.[0] | startswith("/aws/lambda/e2e-gh"))) and (.[0] == $mine or .[0] == $fn or .[1] < $before)) | .[0]') || { could_not_list "log groups"; LOG_GROUPS=""; }
+ for group in $LOG_GROUPS; do
+ if aws logs delete-log-group --log-group-name "$group"; then say "- deleted log group \`$group\`"; else say "- ❌ could not delete log group \`$group\`"; touch cleanup.failed; fi
+ done
+ [ "$(wc -l < out/cleanup.md)" -gt 2 ] || say "- nothing to delete"
+ [ ! -f cleanup.failed ] || { say ""; say "❌ cleanup left resources behind"; exit 1; }
+
+ - name: "[Report] Job summary"
+ if: ${{ always() }}
+ run: |
+ if [ -f out/summary.md ]; then cat out/summary.md >> "$GITHUB_STEP_SUMMARY"; else echo "No summary, the run failed before verification." >> "$GITHUB_STEP_SUMMARY"; fi
+ # Rows come back as JSONEachRow with the stored event as a JSON string in raw; render them as a pretty array.
+ [ -f out/sample_log.json ] && { echo; echo 'sample_log.json
'; echo; echo '```json'; jq -s 'map(if has("raw") then .raw |= (fromjson? // .) else . end)' out/sample_log.json; echo '```'; echo ' '; echo; } >> "$GITHUB_STEP_SUMMARY"
+ [ -f out/cleanup.md ] && { echo; cat out/cleanup.md; } >> "$GITHUB_STEP_SUMMARY"
+ true
+
+ notify-linear:
+ if: ${{ failure() && (github.event_name == 'schedule' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) }}
+ needs: [test]
+ uses: ./.github/workflows/notify_linear_failure.yml
+ secrets: inherit
diff --git a/.github/workflows/notify_linear_failure.yml b/.github/workflows/notify_linear_failure.yml
new file mode 100644
index 0000000..529be28
--- /dev/null
+++ b/.github/workflows/notify_linear_failure.yml
@@ -0,0 +1,105 @@
+name: "Notify Linear on failure"
+
+on:
+ workflow_call:
+
+jobs:
+ create-linear-issue:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ actions: read
+ steps:
+ - name: "Gather failure context"
+ id: ctx
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RUN_ID: ${{ github.event.workflow_run.id || github.run_id }}
+ RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt || github.run_attempt }}
+ run: |
+ FAILED=$(gh api \
+ "repos/${{ github.repository }}/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT/jobs" \
+ --paginate \
+ --jq '[.jobs[]
+ | select(.conclusion == "failure")
+ | "- **\(.name)** — failed at _\([.steps[] | select(.conclusion == "failure") | .name] | join(", "))_"]
+ | join("\n")')
+
+ if [ -z "$FAILED" ]; then
+ FAILED="_(no failed jobs reported by the API yet)_"
+ fi
+
+ {
+ echo "failed_jobs<> "$GITHUB_OUTPUT"
+ - name: "Create Linear issue"
+ env:
+ LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
+ LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_TELEMETRY_ID }}
+ LINEAR_STATUS_PLAN_ID: ${{ secrets.LINEAR_STATUS_PLAN_ID }}
+ LINEAR_PROJECT_INGESTION_FOR_EVERYTHING_ID: ${{ secrets.LINEAR_PROJECT_INGESTION_FOR_EVERYTHING_ID }}
+ LINEAR_ASSIGNEE_PETR_ID: ${{ secrets.LINEAR_ASSIGNEE_PETR_ID }}
+ WORKFLOW: ${{ github.event.workflow_run.name || github.workflow }}
+ REF_NAME: ${{ github.event.workflow_run.head_branch || github.ref_name }}
+ RUN_URL: ${{ github.event.workflow_run.html_url || format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }}
+ RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt || github.run_attempt }}
+ EVENT_NAME: ${{ github.event.workflow_run.event || github.event_name }}
+ SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
+ COMMIT_MESSAGE: ${{ github.event.workflow_run.head_commit.message || github.event.head_commit.message }}
+ FAILED_JOBS: ${{ steps.ctx.outputs.failed_jobs }}
+ run: |
+ SHORT_SHA="${SHA:0:7}"
+
+ # head_commit is only populated on push events; skip the line otherwise
+ COMMIT_LINE="**Commit:** \`$SHORT_SHA\`"
+ if [ -n "$COMMIT_MESSAGE" ]; then
+ COMMIT_SUBJECT="$(printf '%s' "$COMMIT_MESSAGE" | head -n1)"
+ COMMIT_LINE="**Commit:** \`$SHORT_SHA\` — $COMMIT_SUBJECT"
+ fi
+
+ ATTEMPT_NOTE=""
+ if [ "$RUN_ATTEMPT" != "1" ]; then
+ ATTEMPT_NOTE=" (attempt #$RUN_ATTEMPT)"
+ fi
+
+ TITLE="E2E failure: $WORKFLOW"
+ DESCRIPTION=$(cat <