Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/scripts/release_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ def build_result(args: argparse.Namespace) -> dict:
"phase": phase,
"failed_requirements": failed,
"jobs": jobs,
"notification": args.notification_result or "skipped",
}


Expand Down Expand Up @@ -114,9 +113,11 @@ def main() -> None:
"publish_result",
"container_alias_result",
"candidate_result",
"notification_result",
):
parser.add_argument(f"--{name.replace('_', '-')}", required=True)
# Compatibility with a rerun of a pre-centralization Release workflow.
# The value is intentionally ignored: notification delivery is app-owned.
parser.add_argument("--notification-result")
parser.add_argument("--run-attempt", type=int, required=True)
parser.add_argument("--image-digest", default="")
parser.add_argument("--output", type=Path, required=True)
Expand Down
5 changes: 2 additions & 3 deletions .github/scripts/tests/test_release_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,15 @@ def args(**overrides):
"publish_result": "success",
"container_alias_result": "skipped",
"candidate_result": "success",
"notification_result": "failure",
}
values.update(overrides)
return argparse.Namespace(**values)


def test_notification_failure_does_not_fail_release():
def test_successful_release_result_contains_only_release_truth():
result = build_result(args())
assert result["status"] == "succeeded"
assert result["notification"] == "failure"
assert "notification" not in result


def test_failure_after_registry_publish_is_partial():
Expand Down
46 changes: 1 addition & 45 deletions .github/workflows/promote-worker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -382,48 +382,6 @@ jobs:
'.tagName == $tag and .isDraft == false and .isPrerelease == false' \
github-release-after.json >/dev/null

- name: Announce promoted release
id: slack
continue-on-error: true
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
CHANNEL: worker-releases
TAG: ${{ steps.candidate.outputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
release_url=$(jq -r .url github-release-after.json)
text="🚀 ${WORKER} v${VERSION} promoted to @latest — <${release_url}|GitHub Release>"
payload=$(jq -n --arg channel "$CHANNEL" --arg text "$text" \
'{channel: $channel, text: $text}')
resp=$(curl -sf -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
-H "Content-Type: application/json; charset=utf-8" \
-d "$payload")
jq -e .ok <<<"$resp" >/dev/null || {
echo "::error::Slack API error: $(jq -r .error <<<"$resp")"
exit 1
}
echo "ts=$(jq -r .ts <<<"$resp")" >>"$GITHUB_OUTPUT"

notes=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body --jq .body 2>/dev/null || true)
if [[ -n "$notes" ]]; then
notes=$(printf '%s' "$notes" | head -c 2900)
thread_payload=$(jq -n \
--arg channel "$CHANNEL" \
--arg text "$notes" \
--arg ts "$(jq -r .ts <<<"$resp")" \
'{channel: $channel, text: $text, thread_ts: $ts}')
thread_resp=$(curl -sf -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
-H "Content-Type: application/json; charset=utf-8" \
-d "$thread_payload")
jq -e .ok <<<"$thread_resp" >/dev/null || {
echo "::error::Slack thread API error: $(jq -r .error <<<"$thread_resp")"
exit 1
}
fi

- name: Write promotion summary
if: always()
run: |
Expand Down Expand Up @@ -452,7 +410,6 @@ jobs:
env:
OPERATION_ID: ${{ inputs.operation_id || format('github:{0}', github.run_id) }}
STEP_ID: ${{ inputs.step_id || 'promote' }}
NOTIFICATION_RESULT: ${{ steps.slack.outcome }}
DEPLOY: ${{ steps.candidate.outputs.deploy }}
run: |
registry=false
Expand All @@ -479,7 +436,6 @@ jobs:
--arg candidate_evidence_run_id "$EVIDENCE_RUN_ID" \
--arg e2e_run_id "$E2E_RUN_ID" \
--arg status "$status" \
--arg notification "${NOTIFICATION_RESULT:-skipped}" \
--argjson registry "$registry" \
--argjson github_release "$github_release" \
--argjson image_alias "$image_alias" \
Expand All @@ -490,7 +446,7 @@ jobs:
release_run_id: $release_run_id,
candidate_evidence_run_id: $candidate_evidence_run_id,
e2e_run_id: ($e2e_run_id | if length > 0 then . else null end),
status: $status, notification: $notification,
status: $status,
results: {registry: $registry, github_release: $github_release, image_alias: $image_alias}}' \
>promotion-result.json

Expand Down
95 changes: 0 additions & 95 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -347,98 +347,6 @@ jobs:
- name: Require all candidate gates
run: jq -e '.candidate_ready == true' release-candidate.json >/dev/null

# ──────────────────────────────────────────────────────────────
# Announce the release in Slack (#worker-releases). Notification failures
# are warnings: they must not turn an already published release red.
# ──────────────────────────────────────────────────────────────
announce:
name: Announce on Slack
needs: [setup, binary-build, container-build, bundle-build, publish, container-alias, candidate-ready]
if: >-
${{
always() &&
needs.setup.result == 'success' &&
needs.setup.outputs.dry_run != 'true' &&
(
(needs.setup.outputs.staged == 'true' && needs.candidate-ready.result == 'success') ||
(
needs.setup.outputs.staged != 'true' &&
needs.binary-build.result != 'failure' &&
needs.container-build.result != 'failure' &&
needs.bundle-build.result != 'failure' &&
(needs.setup.outputs.deploy != 'image' || needs.container-alias.result == 'success') &&
(needs.setup.outputs.interface_smoke == 'false' || needs.publish.result == 'success')
)
)
}}
runs-on: ubuntu-latest
outputs:
notification_result: ${{ steps.post.outcome == 'success' && steps.thread.outcome != 'failure' && 'success' || 'failure' }}
steps:
- name: Post to Slack
id: post
continue-on-error: true
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
CHANNEL: worker-releases
WORKER: ${{ needs.setup.outputs.worker }}
VERSION: ${{ needs.setup.outputs.version }}
IS_PRERELEASE: ${{ needs.setup.outputs.is_prerelease }}
STAGED: ${{ needs.setup.outputs.staged }}
PROMOTABLE: ${{ needs.setup.outputs.promotable }}
RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ needs.setup.outputs.tag }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
if [[ "$STAGED" == true && "$PROMOTABLE" == true ]]; then
text="🧪 ${WORKER} v${VERSION} candidate passed on @next — <${RUN_URL}|promote manually> · <${RELEASE_URL}|GitHub prerelease>"
elif [[ "$STAGED" == true ]]; then
text="🧪 ${WORKER} v${VERSION} prerelease passed on @next — <${RELEASE_URL}|GitHub prerelease>"
else
suffix=""
if [[ "$IS_PRERELEASE" == "true" ]]; then suffix=" (pre-release)"; fi
text="🚀 ${WORKER} v${VERSION}${suffix} released — <${RELEASE_URL}|GitHub Release>"
fi
payload=$(jq -n --arg channel "$CHANNEL" --arg text "$text" '{channel: $channel, text: $text}')
resp=$(curl -sf -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
-H "Content-Type: application/json; charset=utf-8" \
-d "$payload")
if ! echo "$resp" | jq -e .ok > /dev/null; then
echo "::error::Slack API error: $(echo "$resp" | jq -r .error)"
exit 1
fi
echo "ts=$(echo "$resp" | jq -r .ts)" >> "$GITHUB_OUTPUT"

- name: Post release notes in thread
id: thread
if: steps.post.outputs.ts != ''
continue-on-error: true
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
CHANNEL: worker-releases
TAG: ${{ needs.setup.outputs.tag }}
THREAD_TS: ${{ steps.post.outputs.ts }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
notes=$(gh release view "$TAG" -R "${{ github.repository }}" --json body --jq .body 2>/dev/null || true)
if [[ -z "$notes" ]]; then
echo "::notice::no release notes body; skipping thread reply"
exit 0
fi
notes=$(printf '%s' "$notes" | head -c 2900)
payload=$(jq -n --arg channel "$CHANNEL" --arg text "$notes" --arg ts "$THREAD_TS" \
'{channel: $channel, text: $text, thread_ts: $ts}')
resp=$(curl -sf -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
-H "Content-Type: application/json; charset=utf-8" \
-d "$payload")
if ! echo "$resp" | jq -e .ok > /dev/null; then
echo "::error::Slack API error: $(echo "$resp" | jq -r .error)"
exit 1
fi

release-result:
name: Record terminal release result
needs:
Expand All @@ -450,7 +358,6 @@ jobs:
- publish
- container-alias
- candidate-ready
- announce
if: always()
runs-on: ubuntu-latest
permissions:
Expand Down Expand Up @@ -487,7 +394,6 @@ jobs:
PUBLISH_RESULT: ${{ needs.publish.result }}
CONTAINER_ALIAS_RESULT: ${{ needs.container-alias.result }}
CANDIDATE_RESULT: ${{ needs.candidate-ready.result }}
NOTIFICATION_RESULT: ${{ needs.announce.outputs.notification_result || needs.announce.result }}
run: |
python3 .github/scripts/release_result.py \
--repository "$REPOSITORY" \
Expand Down Expand Up @@ -515,7 +421,6 @@ jobs:
--publish-result "$PUBLISH_RESULT" \
--container-alias-result "$CONTAINER_ALIAS_RESULT" \
--candidate-result "$CANDIDATE_RESULT" \
--notification-result "$NOTIFICATION_RESULT" \
--output release-result.json

- uses: actions/upload-artifact@v6
Expand Down
31 changes: 2 additions & 29 deletions .github/workflows/repair-worker-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ on:
description: Repair action
required: true
type: choice
options: [verify, candidate-smoke, container-alias, github-release, notification]
options: [verify, candidate-smoke, container-alias, github-release]
default: verify
channel:
description: 'Surface to repair (original uses the tag annotation)'
Expand Down Expand Up @@ -300,31 +300,6 @@ jobs:
--generate-notes --prerelease="$prerelease" --latest=false
fi

notification:
name: Repeat release notification
needs: [setup, registry]
if: inputs.action == 'notification'
runs-on: ubuntu-latest
steps:
- name: Post explicit repair notification
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
CHANNEL: worker-releases
WORKER: ${{ needs.setup.outputs.worker }}
VERSION: ${{ needs.setup.outputs.version }}
REGISTRY_TAG: ${{ needs.setup.outputs.registry_tag }}
TAG: ${{ needs.setup.outputs.tag }}
run: |
set -euo pipefail
url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
text="🔁 ${WORKER} v${VERSION} @${REGISTRY_TAG} — release notification repaired · <${url}|GitHub Release>"
payload=$(jq -n --arg channel "$CHANNEL" --arg text "$text" '{channel: $channel, text: $text}')
response=$(curl -sf -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
-H "Content-Type: application/json; charset=utf-8" \
-d "$payload")
jq -e .ok <<<"$response" >/dev/null

verify:
name: Verify release surfaces
needs: [setup, registry]
Expand Down Expand Up @@ -369,7 +344,7 @@ jobs:

result:
name: Record repair result
needs: [setup, registry, candidate-smoke, container-alias, candidate-evidence, github-release, notification, verify]
needs: [setup, registry, candidate-smoke, container-alias, candidate-evidence, github-release, verify]
if: always()
runs-on: ubuntu-latest
steps:
Expand All @@ -382,7 +357,6 @@ jobs:
CONTAINER_ALIAS_RESULT: ${{ needs.container-alias.result }}
CANDIDATE_EVIDENCE_RESULT: ${{ needs.candidate-evidence.result }}
GITHUB_RELEASE_RESULT: ${{ needs.github-release.result }}
NOTIFICATION_RESULT: ${{ needs.notification.result }}
VERIFY_RESULT: ${{ needs.verify.result }}
run: |
selected_result=failed
Expand All @@ -391,7 +365,6 @@ jobs:
candidate-smoke) selected_result="$CANDIDATE_EVIDENCE_RESULT" ;;
container-alias) selected_result="$CONTAINER_ALIAS_RESULT" ;;
github-release) selected_result="$GITHUB_RELEASE_RESULT" ;;
notification) selected_result="$NOTIFICATION_RESULT" ;;
esac
status=failed
[[ "$SETUP_RESULT" == success && "$selected_result" == success ]] && status=succeeded
Expand Down
19 changes: 9 additions & 10 deletions docs/sops/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,9 @@ separate successful **Harness E2E deployed** run before promotion.
Every Release run also attempts to upload
`release-result-<run-id>/release-result.json`. It classifies the terminal state
as `succeeded`, `partial`, or `failed`, records the last durable phase and all
job outcomes, and treats a Slack failure as a notification issue rather than a
failed publication.
job outcomes. Workflows do not send operator notifications; Release Control
projects successful terminal evidence to Slack without coupling delivery to
publication state.

## Promote a candidate

Expand All @@ -178,8 +179,6 @@ Promotion performs these guarded, idempotent changes:
5. For images, move GHCR `latest` from the recorded immutable digest.
6. Convert the GitHub prerelease to a normal release without changing the
repository-global GitHub Latest release.
7. Attempt the Slack announcement without making notification delivery part of
publication success.

The terminal `promotion-<worker>-<version>` artifact records `succeeded`,
`partial`, or `failed` plus each external surface. Release Control is the
Expand Down Expand Up @@ -209,7 +208,6 @@ version, original Release run id, and one explicit action:
| `candidate-smoke` | Repeat the exact `next` smoke and emit new candidate evidence |
| `container-alias` | Reconcile only the selected `next` or `latest` image alias |
| `github-release` | Create or reconcile GitHub prerelease/release state |
| `notification` | Explicitly repeat the release notification |

Use `channel=original` for the tag's original channel. Select `next` or
`latest` only when repairing a known partial candidate, direct release, or
Expand Down Expand Up @@ -264,7 +262,7 @@ dry-run versions; validate changes through CI or a branch prerelease.
| Candidate evidence is rejected | Wrong worker/version/run attempt/tag SHA or a failed gate | Use the exact successful Release or repair evidence run |
| Harness promotion lacks E2E | No green deployed evidence for that release | Dispatch Harness E2E deployed with the exact release identity |
| Promotion becomes partial | Registry changed but a later surface failed | Repair the failed `latest` surface; do not cut another version |
| Slack failed | Publication succeeded but notification did not | Use repair action `notification` after checking for duplicates |
| Slack delivery failed | Release Control could not project successful evidence | Check its Slack bot configuration and durable notification outbox; delivery retries automatically |

## Roll forward and verify

Expand All @@ -281,7 +279,8 @@ iii worker info <name>
Confirm the resolved version, published function/trigger interface, GitHub
Release assets, and — for images — the expected channel digest.

Slack announcements remain in `#worker-releases`. Candidate messages use
`next`; promotion messages identify `latest`. Ticket association rides on PR
titles (`(MOT-##) type: description`) or the `no-ticket` label for changes that
do not belong to a Linear issue.
Release Control owns Slack communication in `#worker-releases`. It creates one
root message for the immutable Release run, updates that root when the candidate
reaches `latest`, and keeps lifecycle checkpoints in the same thread. Ticket
association rides on PR titles (`(MOT-##) type: description`) or the `no-ticket`
label for changes that do not belong to a Linear issue.
Loading