diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..4c8619a4d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,366 @@ +name: Release + +# Prevent duplicate runs for the same workflow + branch combination. +# If a new run starts while one is in progress, the older run is cancelled. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Trigger this workflow when: +# 1. A pull request targeting main is closed (filters to merged release PRs via job `if`). +# 2. A version tag (v*) is pushed directly (e.g. `git push origin v3.6.0`). +on: + push: + tags: + - '*.*.*' + pull_request: + branches: + - main + types: + - closed + +jobs: + release-info: + name: "Extract release info" + # Run when a version tag is pushed OR a merged release/v* PR is closed. + if: >- + github.event_name == 'push' + || (github.event.pull_request.merged == true + && startsWith(github.event.pull_request.head.ref, 'release/v')) + runs-on: warp-ubuntu-2404-x64-2x + # Write permission on contents is required to push a new tag (PR path). + permissions: + contents: write + + # Expose version and commit hash to downstream jobs. + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + short-sha: ${{ steps.version.outputs.short-sha }} + + steps: + # Extract the semver version from the tag ref or branch name. + # Tag push: refs/tags/v3.6.0 -> version="3.6.0", tag="v3.6.0" + # PR merge: release/v3.6.0 -> version="3.6.0", tag="v3.6.0" + # Also compute the 7-char commit hash used as the Docker source tag. + - name: Extract version + id: version + env: + EVENT: ${{ github.event_name }} + TAG_REF: ${{ github.ref }} + TAG_SHA: ${{ github.sha }} + BRANCH: ${{ github.event.pull_request.head.ref }} + PR_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + if [[ "$EVENT" == "push" ]]; then + VERSION="${TAG_REF#refs/tags/v}" + SHA="$TAG_SHA" + else + VERSION="${BRANCH#release/v}" + SHA="$PR_SHA" + fi + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "::error::Invalid semver: $VERSION" + exit 1 + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "short-sha=${SHA::7}" >> "$GITHUB_OUTPUT" + + # Create the tag via the GitHub API so we don't need git credentials. + # Points the tag at the PR's merge commit SHA. + # Skipped on tag push since the tag already exists. + - name: Create tag + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + gh api "repos/$GITHUB_REPOSITORY/git/refs" \ + --method POST \ + -f ref="refs/tags/$TAG" \ + -f sha="$SHA" + + # Trigger the existing Docker retag workflows for each image. + # These are the same workflows you'd normally trigger manually from the Actions UI. + retag-docker-images: + name: "Retag Docker images" + needs: release-info + runs-on: warp-ubuntu-2404-x64-2x + permissions: + actions: write + + # Expose image digests to downstream jobs. + outputs: + mpc_node_manifest_digest: ${{ steps.digests.outputs.mpc_node_manifest_digest }} + mpc_node_image_id: ${{ steps.digests.outputs.mpc_node_image_id }} + mpc_node_gcp_manifest_digest: ${{ steps.digests.outputs.mpc_node_gcp_manifest_digest }} + mpc_node_gcp_image_id: ${{ steps.digests.outputs.mpc_node_gcp_image_id }} + mpc_launcher_manifest_digest: ${{ steps.digests.outputs.mpc_launcher_manifest_digest }} + mpc_launcher_image_id: ${{ steps.digests.outputs.mpc_launcher_image_id }} + + steps: + # Dispatch each retag workflow and immediately capture its run ID. + # A brief sleep after each dispatch lets the run appear in the API + # before we query for it. + - name: Retag mpc-launcher + id: launcher + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHORT_SHA: ${{ needs.release-info.outputs.short-sha }} + VERSION: ${{ needs.release-info.outputs.version }} + run: | + gh workflow run docker_launcher_release.yml \ + --repo "$REPO" \ + -f source-tag="main-${SHORT_SHA}" \ + -f release-tag="${VERSION}" + sleep 5 + RUN_ID=$(gh run list --workflow=docker_launcher_release.yml --repo "$REPO" --limit=1 --json databaseId -q '.[0].databaseId') + echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + + - name: Retag mpc-node-gcp + id: node-gcp + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHORT_SHA: ${{ needs.release-info.outputs.short-sha }} + VERSION: ${{ needs.release-info.outputs.version }} + run: | + gh workflow run docker_node_release.yml \ + --repo "$REPO" \ + -f source-tag="main-${SHORT_SHA}" \ + -f release-tag="${VERSION}" \ + -f repository="mpc-node-gcp" + sleep 5 + RUN_ID=$(gh run list --workflow=docker_node_release.yml --repo "$REPO" --limit=1 --json databaseId -q '.[0].databaseId') + echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + + - name: Retag mpc-node + id: node + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHORT_SHA: ${{ needs.release-info.outputs.short-sha }} + VERSION: ${{ needs.release-info.outputs.version }} + run: | + gh workflow run docker_node_release.yml \ + --repo "$REPO" \ + -f source-tag="main-${SHORT_SHA}" \ + -f release-tag="${VERSION}" \ + -f repository="mpc-node" + sleep 5 + RUN_ID=$(gh run list --workflow=docker_node_release.yml --repo "$REPO" --limit=1 --json databaseId -q '.[0].databaseId') + echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + + # Wait for all three dispatched runs to complete. + # --exit-status makes gh run watch fail if the workflow itself fails. + - name: Wait for retag workflows + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + LAUNCHER_RUN: ${{ steps.launcher.outputs.run_id }} + NODE_GCP_RUN: ${{ steps.node-gcp.outputs.run_id }} + NODE_RUN: ${{ steps.node.outputs.run_id }} + run: | + for RUN_ID in $LAUNCHER_RUN $NODE_GCP_RUN $NODE_RUN; do + gh run watch "$RUN_ID" --repo "$REPO" --exit-status + done + + - name: Install skopeo + run: | + sudo apt-get update + sudo apt-get install -y skopeo + + # Inspect each retagged image and store Manifest digest and Image ID as outputs. + - name: Get image digests + id: digests + env: + RELEASE_TAG: ${{ needs.release-info.outputs.version }} + run: | + for REPO in mpc-launcher mpc-node-gcp mpc-node; do + SAFE_NAME="${REPO//-/_}" + MANIFEST_DIGEST=$(skopeo inspect docker://nearone/$REPO:$RELEASE_TAG | jq -r '.Digest') + # Use --override-os/--override-arch to resolve multi-arch manifest lists + # to the platform-specific image manifest, where .config.digest is the Image ID. + IMAGE_ID=$(skopeo inspect --raw --override-os linux --override-arch amd64 docker://nearone/$REPO:$RELEASE_TAG | jq -r '.config.digest') + echo "${SAFE_NAME}_manifest_digest=$MANIFEST_DIGEST" >> "$GITHUB_OUTPUT" + echo "${SAFE_NAME}_image_id=$IMAGE_ID" >> "$GITHUB_OUTPUT" + done + + # Build the smart contract using reproducible builds, rename the artifacts + # with the release version, and package them into a .tar.gz archive. + # This job runs in parallel with retag-docker-images since both only + # depend on release-info. + build-contract: + name: "Build contract" + needs: release-info + # Uses a 16x runner because the reproducible contract build is resource-intensive. + runs-on: warp-ubuntu-2404-x64-16x + permissions: + contents: read + + # Expose contract hash to downstream jobs. + outputs: + contract-hash: ${{ steps.contract-hash.outputs.sha256 }} + + steps: + # Checkout the release tag so we build the correct code in both trigger paths. + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ needs.release-info.outputs.tag }} + persist-credentials: false + + # Rust cache: lookup-only + save-if:false keeps this step inert so that + # no poisoned cache content can influence release artifacts (zizmor cache-poisoning). + # The actual build happens inside a Docker container (reproducible-wasm) + # so host caching provides limited benefit here anyway. + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + save-if: false + cache-provider: "warpbuild" + lookup-only: true + + - name: Install cargo-binstall + uses: taiki-e/install-action@d4422f254e595ee762a758628fe4f16ce050fa2e # v2.67.28 + with: + tool: cargo-binstall + + - name: Install cargo-near + run: | + sudo apt-get update && sudo apt-get install --assume-yes libudev-dev + cargo binstall --force --no-confirm --locked cargo-near@0.19.1 --pkg-url="{ repo }/releases/download/{ name }-v{ version }/{ name }-{ target }.{ archive-format }" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install wasm-opt + run: | + cargo binstall --force --no-confirm --locked wasm-opt@0.116.1 + echo "${HOME}/.cargo/bin" >> $GITHUB_PATH + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Build the contract with reproducible-wasm to ensure deterministic output. + # This produces mpc_contract.wasm and mpc_contract_abi.json + # under target/near/mpc_contract/. + - name: Build contract + run: cargo near build reproducible-wasm --manifest-path crates/contract/Cargo.toml + + # Compute the SHA-256 hash of the compiled contract .wasm before renaming. + # Used later in the GitHub release notes so users can verify contract integrity. + - name: Compute contract hash + id: contract-hash + run: | + CONTRACT_HASH=$(sha256sum target/near/mpc_contract/mpc_contract.wasm | awk '{print $1}') + echo "sha256=$CONTRACT_HASH" >> "$GITHUB_OUTPUT" + + # Rename the build outputs to include the release version and + # compress them into a single .tar.gz archive for distribution. + # e.g. mpc-contract-v3.6.0.wasm, mpc-contract-v3.6.0-abi.json + - name: Package contract artifacts + env: + VERSION: ${{ needs.release-info.outputs.version }} + run: | + cd target/near/mpc_contract/ + mv mpc_contract.wasm "mpc-contract-v${VERSION}.wasm" + mv mpc_contract_abi.json "mpc-contract-v${VERSION}-abi.json" + tar -czf "mpc-contract-v${VERSION}.tar.gz" "mpc-contract-v${VERSION}.wasm" "mpc-contract-v${VERSION}-abi.json" + + # Upload the .tar.gz as a GitHub Actions artifact so downstream jobs + # (e.g. creating the GitHub release) can download and attach it. + - name: Upload contract artifact + uses: actions/upload-artifact@ea165f8d65b6db9a6b7e75b195db6a7b2bcf4bac # v4.6.2 + with: + name: mpc-contract + path: target/near/mpc_contract/mpc-contract-v${{ needs.release-info.outputs.version }}.tar.gz + + # Create a draft GitHub release with the changelog, Docker image info, + # and the contract artifact attached. + # Runs after both retag-docker-images and build-contract complete. + create-release: + name: "Create draft GitHub release" + needs: [release-info, retag-docker-images, build-contract] + runs-on: warp-ubuntu-2404-x64-2x + permissions: + contents: write + + steps: + # Checkout the release tag to read the correct CHANGELOG.md. + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ needs.release-info.outputs.tag }} + persist-credentials: false + + # Extract the changelog section for this version from CHANGELOG.md. + # Sections are delimited by "## []" headers, so we print + # everything between the current version's header and the next one. + - name: Extract changelog + env: + VERSION: ${{ needs.release-info.outputs.version }} + run: | + awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found" CHANGELOG.md > release-notes-raw.md + if [ ! -s release-notes-raw.md ]; then + echo "::error::No changelog found for version ${VERSION} in CHANGELOG.md" + exit 1 + fi + + # Download the contract .tar.gz uploaded by the build-contract job. + - name: Download contract artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: mpc-contract + + # Compose the full release body: changelog + Docker images + contract hash. + - name: Compose release body + env: + VERSION: ${{ needs.release-info.outputs.version }} + NODE_MANIFEST: ${{ needs.retag-docker-images.outputs.mpc_node_manifest_digest }} + NODE_IMAGE_ID: ${{ needs.retag-docker-images.outputs.mpc_node_image_id }} + NODE_GCP_MANIFEST: ${{ needs.retag-docker-images.outputs.mpc_node_gcp_manifest_digest }} + NODE_GCP_IMAGE_ID: ${{ needs.retag-docker-images.outputs.mpc_node_gcp_image_id }} + LAUNCHER_MANIFEST: ${{ needs.retag-docker-images.outputs.mpc_launcher_manifest_digest }} + LAUNCHER_IMAGE_ID: ${{ needs.retag-docker-images.outputs.mpc_launcher_image_id }} + CONTRACT_HASH: ${{ needs.build-contract.outputs.contract-hash }} + run: | + cp release-notes-raw.md release-notes.md + cat >> release-notes.md <