Skip to content

testnet release vv0.38.0 #17

testnet release vv0.38.0

testnet release vv0.38.0 #17

name: release.testnet
run-name: "testnet release v${{ inputs.version }}${{ inputs.dry_run && ' (dry run)' || '' }}"
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (format: X.Y.Z, no leading v)'
required: true
type: string
dry_run:
description: 'Validate plumbing: draft PRs, no tags, check-mode deploys'
required: false
type: boolean
default: false
skip_devnet_check:
description: 'Skip the "latest devnet daily succeeded" preflight check'
required: false
type: boolean
default: false
permissions:
contents: read
env:
SLACK_CHANNEL_ID: C0853CEAUR4 # bots
# Serialize releases of the same version: overlapping dispatches would
# force-push the same release branches and race on the same tags.
concurrency:
group: release-testnet-${{ inputs.version }}
cancel-in-progress: false
jobs:
preflight:
runs-on: ubuntu-latest
outputs:
prev_version: ${{ steps.prev.outputs.prev_version }}
# Empty if the Slack post failed; downstream posts then degrade to
# top-level messages instead of threaded replies.
thread_ts: ${{ steps.slack.outputs.ts }}
steps:
- name: Validate version format
run: |
[[ "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "::error::version must be X.Y.Z (got '${{ inputs.version }}')"; exit 1; }
- uses: actions/checkout@v4
- name: Read current workspace version
id: prev
run: |
PREV=$(grep -m1 '^version = ' Cargo.toml | sed 's/version = "\(.*\)"/\1/')
echo "prev_version=$PREV" >> "$GITHUB_OUTPUT"
if [ "$PREV" = "${{ inputs.version }}" ] && [ "${{ inputs.dry_run }}" != "true" ]; then
echo "::error::main is already at ${{ inputs.version }}"; exit 1
fi
- name: Check latest devnet daily release succeeded
if: ${{ !inputs.skip_devnet_check }}
env:
GH_TOKEN: ${{ github.token }}
run: |
CONCLUSION=$(gh run list -R malbeclabs/doublezero \
--workflow release.devnet.all.daily.yml --limit 1 \
--json conclusion --jq '.[0].conclusion')
echo "latest devnet daily run: $CONCLUSION"
[ "$CONCLUSION" = "success" ] || {
echo "::error::latest devnet daily was '$CONCLUSION'; fix devnet or re-run with skip_devnet_check"; exit 1; }
# Parent message for this run's Slack thread; every later post replies to it.
# Runs after validation so a garbage version never opens a thread.
- name: Open Slack thread
id: slack
uses: ./.github/actions/slack-thread-post
with:
token: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ env.SLACK_CHANNEL_ID }}
text: "Testnet Deploy v${{ inputs.version }}${{ inputs.dry_run && ' (dry run)' || '' }} :thread:\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
open-prs:
runs-on: ubuntu-latest
needs: preflight
outputs:
dz_pr: ${{ steps.dzpr.outputs.pr }}
infra_pr: ${{ steps.infrapr.outputs.pr }}
steps:
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: malbeclabs
repositories: doublezero,infra
- uses: actions/checkout@v4
with:
token: ${{ steps.app.outputs.token }}
- uses: dtolnay/rust-toolchain@1.97.1
- name: Open doublezero version-bump PR
id: dzpr
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
VERSION: ${{ inputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
BRANCH="release/v${VERSION}"
./scripts/release/bump-version.sh "$VERSION"
BASE=$(git rev-parse HEAD)
# Create the release branch at HEAD, or force-reset one left by a previous run.
gh api repos/malbeclabs/doublezero/git/refs -f ref="refs/heads/${BRANCH}" -f sha="$BASE" ||
gh api -X PATCH "repos/malbeclabs/doublezero/git/refs/heads/${BRANCH}" -f sha="$BASE" -F force=true
# Commit via the GraphQL API rather than git: API commits are signed by
# GitHub, and main requires verified signatures (the App has no signing key).
base64 Cargo.toml | tr -d '\n' > "$RUNNER_TEMP/toml.b64"
base64 Cargo.lock | tr -d '\n' > "$RUNNER_TEMP/lock.b64"
base64 CHANGELOG.md | tr -d '\n' > "$RUNNER_TEMP/changelog.b64"
SHA=$(jq -n --arg branch "$BRANCH" --arg oid "$BASE" \
--arg msg "release: bump version to ${VERSION}" \
--rawfile toml "$RUNNER_TEMP/toml.b64" \
--rawfile lock "$RUNNER_TEMP/lock.b64" \
--rawfile chlog "$RUNNER_TEMP/changelog.b64" \
'{query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }",
variables: {input: {
branch: {repositoryNameWithOwner: "malbeclabs/doublezero", branchName: $branch},
expectedHeadOid: $oid,
message: {headline: $msg},
fileChanges: {additions: [
{path: "Cargo.toml", contents: $toml},
{path: "Cargo.lock", contents: $lock},
{path: "CHANGELOG.md", contents: $chlog}]}}}}' \
| gh api graphql --input - --jq '.data.createCommitOnBranch.commit.oid')
echo "signed version-bump commit: ${SHA}"
PR=$(gh pr list -R malbeclabs/doublezero --head "$BRANCH" --state open \
--json number --jq '.[0].number // empty')
if [ -z "$PR" ]; then
TITLE="release: bump version to ${VERSION}"
DRAFT=""
if [ "$DRY_RUN" = "true" ]; then TITLE="[DRY RUN] $TITLE"; DRAFT="--draft"; fi
URL=$(gh pr create -R malbeclabs/doublezero --head "$BRANCH" $DRAFT \
--title "$TITLE" \
--body "Automated version bump for the v${VERSION} testnet release. Review the promoted CHANGELOG section before merging.")
PR=$(gh pr view "$URL" --json number --jq .number)
fi
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "doublezero version PR: https://github.com/malbeclabs/doublezero/pull/$PR" >> "$GITHUB_STEP_SUMMARY"
- name: Checkout infra
uses: actions/checkout@v4
with:
repository: malbeclabs/infra
token: ${{ steps.app.outputs.token }}
path: infra
- name: Open infra pinned-versions PR
id: infrapr
working-directory: infra
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
VERSION: ${{ inputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
git config user.name "dz-release-bot"
git config user.email "dz-release-bot@malbeclabs.com"
BRANCH="release/testnet-v${VERSION}"
./scripts/bump-testnet-versions.sh "$VERSION"
git checkout -b "$BRANCH"
git add ansible/inventory/testnet/group_vars/all.yml
git commit -m "release: tn ${VERSION}"
git push -f origin "$BRANCH"
PR=$(gh pr list -R malbeclabs/infra --head "$BRANCH" --state open \
--json number --jq '.[0].number // empty')
if [ -z "$PR" ]; then
TITLE="release: tn ${VERSION}"
DRAFT=""
if [ "$DRY_RUN" = "true" ]; then TITLE="[DRY RUN] $TITLE"; DRAFT="--draft"; fi
URL=$(gh pr create -R malbeclabs/infra --head "$BRANCH" $DRAFT \
--title "$TITLE" \
--body "Automated testnet version pin bump to ${VERSION}-1.")
PR=$(gh pr view "$URL" --json number --jq .number)
fi
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "infra version PR: https://github.com/malbeclabs/infra/pull/$PR" >> "$GITHUB_STEP_SUMMARY"
- name: Post PR links to Slack thread
uses: ./.github/actions/slack-thread-post
with:
token: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ env.SLACK_CHANNEL_ID }}
thread-ts: ${{ needs.preflight.outputs.thread_ts }}
text: |-
Version PRs are open.
*Operator Steps Required*
1. Approve and merge <https://github.com/malbeclabs/doublezero/pull/${{ steps.dzpr.outputs.pr }}|doublezero#${{ steps.dzpr.outputs.pr }}>
2. Approve and merge <https://github.com/malbeclabs/infra/pull/${{ steps.infrapr.outputs.pr }}|infra#${{ steps.infrapr.outputs.pr }}>
3. Approve gate 1 (`testnet` environment prompt) — *only after both PRs merge*: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
Approving gate 1 confirms the merges and pushes the 9 component tags with no further prompt.
# Gate 1 — the single approval for the tag phase: approving asserts both
# version PRs are merged AND authorizes pushing the 9 component tags (the tag
# workflow carries no environment of its own, so no second prompt follows).
# Belt and suspenders for eager approvals: if this is approved before both
# PRs are merged, the job posts a Slack notice and waits up to 30 minutes for
# the merges instead of failing outright; a closed PR or the timeout still
# fails the gate.
gate-tags:
name: "gate 1: approve only after BOTH version PRs are merged — this pushes the tags"
runs-on: ubuntu-latest
# preflight is already in the transitive chain; listed directly so its
# thread_ts output is readable here (needs only exposes direct dependencies).
needs: [open-prs, preflight]
environment: testnet
outputs:
tag_sha: ${{ steps.tag-target.outputs.sha }}
steps:
# checkout only to resolve the local composite action
- uses: actions/checkout@v4
with:
sparse-checkout: .github
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: malbeclabs
repositories: doublezero,infra
- name: Check version PRs
id: check
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
run: |
set -euo pipefail
DZ=$(gh pr view ${{ needs.open-prs.outputs.dz_pr }} -R malbeclabs/doublezero --json state --jq .state)
INFRA=$(gh pr view ${{ needs.open-prs.outputs.infra_pr }} -R malbeclabs/infra --json state --jq .state)
echo "doublezero PR: $DZ, infra PR: $INFRA"
if [ "$DZ" = "CLOSED" ] || [ "$INFRA" = "CLOSED" ]; then
echo "::error::a version PR was closed — aborting the release"; exit 1
fi
if [ "${{ inputs.dry_run }}" = "true" ]; then
# dry-run PRs stay open on purpose; open is all that's required
echo "merged=true" >> "$GITHUB_OUTPUT"
elif [ "$DZ" = "MERGED" ] && [ "$INFRA" = "MERGED" ]; then
echo "merged=true" >> "$GITHUB_OUTPUT"
else
echo "merged=false" >> "$GITHUB_OUTPUT"
fi
- name: Post early-approval notice to Slack thread
if: steps.check.outputs.merged == 'false'
uses: ./.github/actions/slack-thread-post
with:
token: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ env.SLACK_CHANNEL_ID }}
thread-ts: ${{ needs.preflight.outputs.thread_ts }}
color: warning
text: |-
*Gate 1 was approved before both version PRs were merged* — waiting up to 30 minutes.
*Operator Steps Required*
1. Approve and merge <https://github.com/malbeclabs/doublezero/pull/${{ needs.open-prs.outputs.dz_pr }}|doublezero#${{ needs.open-prs.outputs.dz_pr }}>
2. Approve and merge <https://github.com/malbeclabs/infra/pull/${{ needs.open-prs.outputs.infra_pr }}|infra#${{ needs.open-prs.outputs.infra_pr }}>
The tags push automatically once both PRs are merged; close either PR to abort instead.
- name: Wait for version PRs to merge
if: steps.check.outputs.merged == 'false'
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
run: |
set -euo pipefail
for i in $(seq 1 60); do
sleep 30
# A transient API error counts as a failed poll attempt, not a job failure.
DZ=$(gh pr view ${{ needs.open-prs.outputs.dz_pr }} -R malbeclabs/doublezero --json state --jq .state) || DZ=UNKNOWN
INFRA=$(gh pr view ${{ needs.open-prs.outputs.infra_pr }} -R malbeclabs/infra --json state --jq .state) || INFRA=UNKNOWN
echo "attempt $i/60: doublezero PR: $DZ, infra PR: $INFRA"
if [ "$DZ" = "CLOSED" ] || [ "$INFRA" = "CLOSED" ]; then
echo "::error::a version PR was closed — aborting the release"; exit 1
fi
if [ "$DZ" = "MERGED" ] && [ "$INFRA" = "MERGED" ]; then
echo "both version PRs merged"; exit 0
fi
done
echo "::error::gave up waiting for the version PRs after 30 minutes — re-run failed jobs and approve after both PRs are merged"
exit 1
# The run is pinned to the sha main had at dispatch, which by construction
# predates the version-bump merge. Tag the bump commit itself so the tags
# contain the version bump (v0.30.0's tags landed one commit shy of it).
- name: Resolve tag target (version-bump merge commit)
id: tag-target
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
run: |
set -euo pipefail
if [ "${{ inputs.dry_run }}" = "true" ]; then
# dry-run PRs never merge; the tag jobs are no-ops on the default sha
echo "sha=" >> "$GITHUB_OUTPUT"
exit 0
fi
SHA=$(gh pr view ${{ needs.open-prs.outputs.dz_pr }} -R malbeclabs/doublezero --json mergeCommit --jq .mergeCommit.oid)
if [ -z "$SHA" ] || [ "$SHA" = "null" ]; then
echo "::error::could not resolve the version-bump merge commit"; exit 1
fi
echo "tags will point at $SHA"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
- name: Post tag-push notice to Slack thread
uses: ./.github/actions/slack-thread-post
with:
token: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ env.SLACK_CHANNEL_ID }}
thread-ts: ${{ needs.preflight.outputs.thread_ts }}
text: |-
Gate 1 approved and version PRs confirmed merged — pushing the 9 component tags${{ inputs.dry_run && ' (dry run: tag jobs are no-ops)' || '' }}. No further approval needed for this phase.
# Runs in dry runs too (as a validated no-op via dry_run): a job-level skip
# here would transitively skip every downstream job with a default status
# condition — a skipped ancestor poisons `success()` for the whole graph
# below it, even past jobs that override their own condition.
push-tags:
needs: gate-tags
uses: ./.github/workflows/release.testnet.push.tags.yml
permissions:
contents: write
strategy:
matrix:
component:
- controller
- internet-latency-collector
- agent
- device-telemetry-agent
- geoprobe-agent
- geoprobe-target
- funder
- monitor
- client
with:
version: v${{ inputs.version }}
component: ${{ matrix.component }}
# the version-bump merge commit, not the pre-bump sha this run is pinned to
ref: ${{ needs.gate-tags.outputs.tag_sha }}
skip_existing: true
dry_run: ${{ inputs.dry_run }}
secrets:
DOUBLEZERO_PAT: ${{ secrets.DOUBLEZERO_PAT }}
verify-cloudsmith:
runs-on: ubuntu-latest
needs: [preflight, gate-tags, push-tags]
timeout-minutes: 75
steps:
- name: Install Cloudsmith CLI
run: pip install cloudsmith-cli==1.13.0
- name: Wait for all packages to publish
env:
CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_TOKEN }}
VERSION: ${{ inputs.dry_run && needs.preflight.outputs.prev_version || inputs.version }}
run: |
set -euo pipefail
REPO="malbeclabs/doublezero-testnet"
PACKAGES="doublezero-agent doublezero-controller doublezero-device-telemetry-agent doublezero-funder doublezero-internet-latency-collector doublezero-monitor doublezero-geoprobe-agent doublezero-geoprobe-target doublezero"
MISSING=""
for i in $(seq 1 60); do
MISSING=""
for pkg in $PACKAGES; do
# A transient API error counts as a failed poll attempt, not a job failure.
RPM=$(cloudsmith ls pkg "$REPO" -q "name:^${pkg}$ AND version:^${VERSION}$" -F json | jq '.data | length') || RPM=0
DEB=$(cloudsmith ls pkg "$REPO" -q "name:^${pkg}$ AND version:^${VERSION}-1$" -F json | jq '.data | length') || DEB=0
[ "$((RPM + DEB))" -gt 0 ] || MISSING="$MISSING $pkg"
done
if [ -z "$MISSING" ]; then echo "All packages present at $VERSION"; exit 0; fi
echo "attempt $i/60: still waiting for:$MISSING"
sleep 60
done
echo "::error::timed out waiting for packages:$MISSING"
exit 1
build-programs:
runs-on: ubuntu-24.04-16c-64gb
needs: gate-tags
steps:
- uses: actions/checkout@v4
with:
ref: main # the merged version-bump commit
- uses: dtolnay/rust-toolchain@1.97.1
- uses: Swatinem/rust-cache@v2
# Keep the agave version and --tools-version in sync with
# release.devnet.smartcontract.daily.yml (platform-tools >= v1.54 is
# required since the solana 3.0 tree pulls edition2024 crates).
- name: Install agave solana tools
run: |
sh -c "$(curl -sSfL https://release.anza.xyz/v3.0.4/install)"
echo "$HOME/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH
- name: Build programs for testnet
run: |
set -euo pipefail
(cd smartcontract/programs/doublezero-serviceability && cargo build-sbf --tools-version v1.54)
(cd smartcontract/programs/doublezero-telemetry && cargo build-sbf --tools-version v1.54 --features testnet)
(cd smartcontract/programs/doublezero-geolocation && cargo build-sbf --tools-version v1.54 --features testnet)
- name: Assemble artifact with deploy manifest
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
mkdir -p staged
cp target/deploy/doublezero_serviceability.so staged/
cp target/deploy/doublezero_telemetry.so staged/
cp target/deploy/doublezero_geolocation.so staged/
# shellcheck disable=SC1003 # trailing backslashes inside single quotes are literal line-continuations for the generated markdown
{
echo "# Testnet program deploy v${VERSION}"
echo
echo "Built from commit $(git rev-parse HEAD)"
echo "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "Features: serviceability (default), telemetry (testnet), geolocation (testnet)"
echo
echo "## Checksums"
echo '```'
(cd staged && sha256sum ./*.so)
echo '```'
echo
echo "## Deploy"
echo
echo "Runbook: https://github.com/malbeclabs/infra/blob/main/docs/runbooks/deploys/solana-programs-testnet.md"
echo "(its Build/scp section is superseded by these prebuilt, staged artifacts)"
echo
echo '```bash'
echo "cd /opt/doublezero/program-releases/v${VERSION}"
echo
echo 'solana program deploy \'
echo ' --program-id ~/testnet-ops/serviceability-program-keypair.json \'
echo ' -k ~/testnet-ops/serviceability-program-authority.json \'
echo ' doublezero_serviceability.so'
echo
echo 'solana program deploy \'
echo ' --program-id ~/testnet-ops/telemetry-program-keypair.json \'
echo ' -k ~/.config/doublezero/id.json \'
echo ' doublezero_telemetry.so'
echo
echo 'solana program deploy \'
echo ' --program-id ~/testnet-ops/geolocation-program-testnet-keypair.json \'
echo ' -k ~/.config/doublezero/id.json \'
echo ' doublezero_geolocation.so'
echo
echo '# refresh the onchain program version'
echo 'doublezero init'
echo '```'
echo
echo "Verify with: doublezero --env testnet version"
echo
echo "Then approve the waiting 'gate-programs' job (testnet environment) on the orchestrator run above."
} > staged/DEPLOY.md
- uses: actions/upload-artifact@v4
with:
name: solana-programs-v${{ inputs.version }}
path: staged/
stage-programs:
runs-on: ubuntu-latest
# preflight is already in the transitive chain; listed directly so its
# thread_ts output is readable here (needs only exposes direct dependencies).
needs: [build-programs, preflight]
# The infra run waits on a required-reviewer approval of infra's `testnet`
# environment; leave room for a slow approval.
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: malbeclabs
repositories: infra
- uses: ./.github/actions/dispatch-and-wait
with:
repo: malbeclabs/infra
workflow: stage-programs.testnet.yml
token: ${{ steps.app.outputs.token }}
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
slack-channel: ${{ env.SLACK_CHANNEL_ID }}
slack-thread-ts: ${{ needs.preflight.outputs.thread_ts }}
fields: |
version=${{ inputs.version }}
source_run_id=${{ github.run_id }}
- name: Post deploy instructions
run: |
{
echo "## Program deploy required"
echo "Artifacts staged on nyc-tn-bm2 at /opt/doublezero/program-releases/v${{ inputs.version }}/."
echo ""
echo "**Operator Steps Required**"
echo "1. SSH into nyc-tn-bm2"
echo "2. cd /opt/doublezero/program-releases/v${{ inputs.version }}"
echo "3. Deploy the three programs with the local keypair and set the onchain version, following DEPLOY.md there"
echo "4. Approve gate 2, the waiting 'gate-programs' job (testnet environment) — only after the deploy"
} >> "$GITHUB_STEP_SUMMARY"
- name: Ping dev team
uses: ./.github/actions/slack-thread-post
with:
token: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ env.SLACK_CHANNEL_ID }}
thread-ts: ${{ needs.preflight.outputs.thread_ts }}
color: warning
text: |-
*Testnet release v${{ inputs.version }}: program deploy needed*
Artifacts are staged on nyc-tn-bm2 at /opt/doublezero/program-releases/v${{ inputs.version }}/.
*Operator Steps Required*
1. SSH into nyc-tn-bm2
2. cd /opt/doublezero/program-releases/v${{ inputs.version }}
3. Deploy the three programs and set the onchain version, following DEPLOY.md there
4. Approve gate 2, the waiting gate-programs job (`testnet` environment prompt) — *only after the deploy*: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Gate 2: approve only after the programs are deployed on testnet.
gate-programs:
name: "gate 2: approve only after the programs are deployed on nyc-tn-bm2"
runs-on: ubuntu-latest
needs: [stage-programs, verify-cloudsmith]
environment: testnet
steps:
- name: Gate passed
run: echo "Program deploy confirmed by approver; verifying onchain next."
verify-onchain:
runs-on: ubuntu-latest
needs: gate-programs
steps:
- name: Short-circuit on dry run
if: ${{ inputs.dry_run }}
run: echo "dry run — skipping onchain version check"
- name: Install doublezero client from Cloudsmith
if: ${{ !inputs.dry_run }}
run: |
curl -1sLf 'https://dl.cloudsmith.io/public/malbeclabs/doublezero-testnet/setup.deb.sh' | sudo -E bash
sudo apt-get install -y doublezero=${{ inputs.version }}-1
- name: Verify onchain program version
if: ${{ !inputs.dry_run }}
run: |
set -euo pipefail
OUT=$(doublezero --env testnet version)
echo "$OUT"
GOT=$(echo "$OUT" | sed -n 's/^program version: *//p')
[ "$GOT" = "${{ inputs.version }}" ] || {
echo "::error::onchain program version is '${GOT:-<missing>}', expected ${{ inputs.version }} — was the deploy completed?"; exit 1; }
deploy-core:
runs-on: ubuntu-latest
needs: [verify-onchain, preflight]
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: malbeclabs
repositories: infra
- uses: ./.github/actions/dispatch-and-wait
with:
repo: malbeclabs/infra
workflow: deploy-core.testnet.yml
token: ${{ steps.app.outputs.token }}
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
slack-channel: ${{ env.SLACK_CHANNEL_ID }}
slack-thread-ts: ${{ needs.preflight.outputs.thread_ts }}
fields: |
mode=${{ inputs.dry_run && 'dry-run' || 'send-it' }}
deploy-clients:
runs-on: ubuntu-latest
needs: [deploy-core, preflight]
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: malbeclabs
repositories: infra
- uses: ./.github/actions/dispatch-and-wait
with:
repo: malbeclabs/infra
workflow: deploy-clients.testnet.yml
token: ${{ steps.app.outputs.token }}
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
slack-channel: ${{ env.SLACK_CHANNEL_ID }}
slack-thread-ts: ${{ needs.preflight.outputs.thread_ts }}
fields: |
mode=${{ inputs.dry_run && 'dry-run' || 'send-it' }}
qa:
runs-on: ubuntu-latest
# preflight is already in the transitive chain; listed directly so its
# thread_ts output is readable here (needs only exposes direct dependencies).
needs: [deploy-clients, preflight]
timeout-minutes: 90 # qa.testnet has an hourly cron + concurrency group; we may queue behind one run
steps:
- uses: actions/checkout@v4
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: malbeclabs
repositories: infra
- uses: ./.github/actions/dispatch-and-wait
with:
repo: malbeclabs/infra
workflow: qa.testnet.yml
token: ${{ steps.app.outputs.token }}
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
slack-channel: ${{ env.SLACK_CHANNEL_ID }}
slack-thread-ts: ${{ needs.preflight.outputs.thread_ts }}
announce:
runs-on: ubuntu-latest
needs: [qa, preflight]
steps:
# checkout only to resolve the local composite action
- uses: actions/checkout@v4
with:
sparse-checkout: .github
- name: Announce success
uses: ./.github/actions/slack-thread-post
with:
token: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ env.SLACK_CHANNEL_ID }}
thread-ts: ${{ needs.preflight.outputs.thread_ts }}
color: good
text: |-
*Testnet release v${{ inputs.version }} deployed${{ inputs.dry_run && ' (dry run)' || '' }}*
QA passed. Watch the dashboard for ~30 min: https://data.doublezero.xyz/ — and remember the community announcement (foundation).
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Guard against hollow successes: a skipped job transitively skips everything
# below it with a default status condition, and the run would still conclude
# "success" (see #4040 — dry runs silently lost gate-programs through
# announce this way). Fails unless every stage actually succeeded, so an
# incomplete pipeline is loud, not green. !cancelled() (not always()) so
# deliberately cancelled runs don't end with a red guard or a spurious
# failure alert. announce is excluded: it can only be skipped when qa or
# preflight didn't succeed (both checked here), and including it would route
# an announce-only failure into notify-failure's "release failed" alert for
# a release that actually succeeded.
pipeline-complete:
runs-on: ubuntu-latest
needs: [preflight, open-prs, gate-tags, push-tags, verify-cloudsmith, build-programs, stage-programs, gate-programs, verify-onchain, deploy-core, deploy-clients, qa]
if: ${{ !cancelled() }}
steps:
- name: Assert every stage succeeded
env:
RESULTS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
BAD=$(echo "$RESULTS" | jq -r 'to_entries[] | select(.value.result != "success") | "\(.key)=\(.value.result)"' | tr '\n' ' ')
if [ -n "${BAD// /}" ]; then
echo "::error::pipeline incomplete — stages that did not succeed: $BAD"
exit 1
fi
echo "all stages succeeded"
notify-failure:
runs-on: ubuntu-latest
# announce is deliberately excluded: by that point the release itself has
# succeeded, and a failed announce means Slack delivery is broken — this
# notifier posts via the same Slack API and would fail the same way.
# pipeline-complete IS included so a hollow success also alerts.
needs: [preflight, open-prs, gate-tags, push-tags, verify-cloudsmith, build-programs, stage-programs, gate-programs, verify-onchain, deploy-core, deploy-clients, qa, pipeline-complete]
if: failure()
steps:
# checkout only to resolve the local composite action
- uses: actions/checkout@v4
with:
sparse-checkout: .github
- name: Notify failure
uses: ./.github/actions/slack-thread-post
with:
token: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ env.SLACK_CHANNEL_ID }}
thread-ts: ${{ needs.preflight.outputs.thread_ts }}
color: danger
text: |-
*Testnet release v${{ inputs.version }} failed*
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}