testnet release vv0.38.0 #17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |