|
| 1 | +name: Release |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_dispatch: |
| 5 | + inputs: |
| 6 | + version: |
| 7 | + description: Release tag to create, for example v0.1.0. |
| 8 | + required: true |
| 9 | + type: string |
| 10 | + target: |
| 11 | + description: Exact 40-character commit SHA to release. |
| 12 | + required: true |
| 13 | + type: string |
| 14 | + |
| 15 | +permissions: |
| 16 | + contents: read |
| 17 | + |
| 18 | +jobs: |
| 19 | + validate: |
| 20 | + name: Validate release inputs |
| 21 | + runs-on: ubuntu-24.04 |
| 22 | + timeout-minutes: 10 |
| 23 | + outputs: |
| 24 | + version: ${{ steps.validate.outputs.version }} |
| 25 | + target: ${{ steps.validate.outputs.target }} |
| 26 | + steps: |
| 27 | + - name: Check out release target |
| 28 | + uses: actions/checkout@v6 |
| 29 | + with: |
| 30 | + ref: ${{ inputs.target }} |
| 31 | + submodules: recursive |
| 32 | + |
| 33 | + - name: Validate release inputs |
| 34 | + id: validate |
| 35 | + shell: bash |
| 36 | + env: |
| 37 | + GH_TOKEN: ${{ github.token }} |
| 38 | + run: | |
| 39 | + set -euo pipefail |
| 40 | +
|
| 41 | + version="${{ inputs.version }}" |
| 42 | + target="${{ inputs.target }}" |
| 43 | +
|
| 44 | + if [[ ! "${version}" =~ ^v[0-9]+[.][0-9]+[.][0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then |
| 45 | + echo "Release version must look like vX.Y.Z: ${version}" >&2 |
| 46 | + exit 1 |
| 47 | + fi |
| 48 | + if [[ ! "${target}" =~ ^[0-9a-fA-F]{40}$ ]]; then |
| 49 | + echo "Release target must be a 40-character commit SHA: ${target}" >&2 |
| 50 | + exit 1 |
| 51 | + fi |
| 52 | +
|
| 53 | + checked_out="$(git rev-parse HEAD)" |
| 54 | + if [[ "${checked_out}" != "${target}" ]]; then |
| 55 | + echo "Checked out ${checked_out}, expected ${target}" >&2 |
| 56 | + exit 1 |
| 57 | + fi |
| 58 | + if git ls-remote --exit-code --tags origin "refs/tags/${version}" >/dev/null 2>&1; then |
| 59 | + echo "Release tag already exists on origin: ${version}" >&2 |
| 60 | + exit 1 |
| 61 | + fi |
| 62 | + if gh release view "${version}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then |
| 63 | + echo "Release already exists: ${version}" >&2 |
| 64 | + exit 1 |
| 65 | + fi |
| 66 | +
|
| 67 | + awk -v heading="## ${version} " ' |
| 68 | + index($0, heading) == 1 { found = 1; next } |
| 69 | + found && /^## / { exit } |
| 70 | + found { print } |
| 71 | + END { if (!found) exit 1 } |
| 72 | + ' CHANGELOG.md > release-notes.md |
| 73 | + if [[ ! -s release-notes.md ]]; then |
| 74 | + echo "CHANGELOG.md does not contain non-empty notes for ${version}" >&2 |
| 75 | + exit 1 |
| 76 | + fi |
| 77 | +
|
| 78 | + { |
| 79 | + echo "version=${version}" |
| 80 | + echo "target=${target}" |
| 81 | + } >> "${GITHUB_OUTPUT}" |
| 82 | +
|
| 83 | + - name: Upload release notes |
| 84 | + uses: actions/upload-artifact@v7 |
| 85 | + with: |
| 86 | + name: release-notes |
| 87 | + path: release-notes.md |
| 88 | + if-no-files-found: error |
| 89 | + |
| 90 | + build-binaries: |
| 91 | + name: Build Binaries (${{ matrix.name }}) |
| 92 | + needs: validate |
| 93 | + runs-on: ${{ matrix.runner }} |
| 94 | + timeout-minutes: 40 |
| 95 | + strategy: |
| 96 | + fail-fast: false |
| 97 | + matrix: |
| 98 | + include: |
| 99 | + - name: macos-arm64 |
| 100 | + runner: macos-26 |
| 101 | + target: aarch64-apple-darwin |
| 102 | + artifact: slipstream-macos-arm64 |
| 103 | + windows_platform: x64 |
| 104 | + vcpkg_triplet: "" |
| 105 | + - name: macos-x86_64 |
| 106 | + runner: macos-15-intel |
| 107 | + target: x86_64-apple-darwin |
| 108 | + artifact: slipstream-macos-x86_64 |
| 109 | + windows_platform: x64 |
| 110 | + vcpkg_triplet: "" |
| 111 | + - name: linux-x86_64 |
| 112 | + runner: ubuntu-24.04 |
| 113 | + target: x86_64-unknown-linux-gnu |
| 114 | + artifact: slipstream-linux-x86_64 |
| 115 | + windows_platform: x64 |
| 116 | + vcpkg_triplet: "" |
| 117 | + - name: linux-arm64 |
| 118 | + runner: ubuntu-24.04-arm |
| 119 | + target: aarch64-unknown-linux-gnu |
| 120 | + artifact: slipstream-linux-arm64 |
| 121 | + windows_platform: x64 |
| 122 | + vcpkg_triplet: "" |
| 123 | + - name: windows-x86_64 |
| 124 | + runner: windows-2025 |
| 125 | + target: x86_64-pc-windows-msvc |
| 126 | + artifact: slipstream-windows-x86_64 |
| 127 | + windows_platform: x64 |
| 128 | + vcpkg_triplet: x64-windows-static-md |
| 129 | + - name: windows-arm64 |
| 130 | + runner: windows-11-arm |
| 131 | + target: aarch64-pc-windows-msvc |
| 132 | + artifact: slipstream-windows-arm64 |
| 133 | + windows_platform: ARM64 |
| 134 | + vcpkg_triplet: arm64-windows-static-md |
| 135 | + steps: |
| 136 | + - name: Check out release target |
| 137 | + uses: actions/checkout@v6 |
| 138 | + with: |
| 139 | + ref: ${{ needs.validate.outputs.target }} |
| 140 | + submodules: recursive |
| 141 | + |
| 142 | + - name: Build binary artifact |
| 143 | + uses: ./.github/actions/build-binary |
| 144 | + with: |
| 145 | + target: ${{ matrix.target }} |
| 146 | + artifact-name: ${{ matrix.artifact }} |
| 147 | + windows-platform: ${{ matrix.windows_platform }} |
| 148 | + vcpkg-triplet: ${{ matrix.vcpkg_triplet }} |
| 149 | + |
| 150 | + publish: |
| 151 | + name: Publish immutable release |
| 152 | + needs: |
| 153 | + - validate |
| 154 | + - build-binaries |
| 155 | + runs-on: ubuntu-24.04 |
| 156 | + timeout-minutes: 20 |
| 157 | + permissions: |
| 158 | + contents: write |
| 159 | + steps: |
| 160 | + - name: Download release artifacts |
| 161 | + uses: actions/download-artifact@v7 |
| 162 | + with: |
| 163 | + path: release-assets |
| 164 | + |
| 165 | + - name: Create draft, attach assets, and publish |
| 166 | + shell: bash |
| 167 | + env: |
| 168 | + GH_TOKEN: ${{ github.token }} |
| 169 | + run: | |
| 170 | + set -euo pipefail |
| 171 | +
|
| 172 | + version="${{ needs.validate.outputs.version }}" |
| 173 | + target="${{ needs.validate.outputs.target }}" |
| 174 | + artifacts=( |
| 175 | + slipstream-macos-arm64 |
| 176 | + slipstream-macos-x86_64 |
| 177 | + slipstream-linux-x86_64 |
| 178 | + slipstream-linux-arm64 |
| 179 | + slipstream-windows-x86_64 |
| 180 | + slipstream-windows-arm64 |
| 181 | + ) |
| 182 | + asset_paths=() |
| 183 | + expected_assets=() |
| 184 | +
|
| 185 | + for artifact in "${artifacts[@]}"; do |
| 186 | + extension="tar.gz" |
| 187 | + if [[ "${artifact}" == slipstream-windows-* ]]; then |
| 188 | + extension="zip" |
| 189 | + fi |
| 190 | + archive="${artifact}.${extension}" |
| 191 | + checksum="${artifact}.sha256" |
| 192 | + for asset in "${archive}" "${checksum}"; do |
| 193 | + path="release-assets/${artifact}/${asset}" |
| 194 | + if [[ ! -f "${path}" ]]; then |
| 195 | + echo "Missing required release asset: ${path}" >&2 |
| 196 | + exit 1 |
| 197 | + fi |
| 198 | + asset_paths+=("${path}") |
| 199 | + expected_assets+=("${asset}") |
| 200 | + done |
| 201 | + done |
| 202 | +
|
| 203 | + notes_file="release-assets/release-notes/release-notes.md" |
| 204 | + if [[ ! -s "${notes_file}" ]]; then |
| 205 | + echo "Missing release notes artifact: ${notes_file}" >&2 |
| 206 | + exit 1 |
| 207 | + fi |
| 208 | +
|
| 209 | + immutable_enabled="$(gh api "repos/${GITHUB_REPOSITORY}/immutable-releases" -H X-GitHub-Api-Version:2026-03-10 --jq '.enabled')" |
| 210 | + if [[ "${immutable_enabled}" != "true" ]]; then |
| 211 | + echo "Repository immutable releases are not enabled." >&2 |
| 212 | + exit 1 |
| 213 | + fi |
| 214 | +
|
| 215 | + gh release create "${version}" \ |
| 216 | + --repo "${GITHUB_REPOSITORY}" \ |
| 217 | + --target "${target}" \ |
| 218 | + --title "${version}" \ |
| 219 | + --notes-file "${notes_file}" \ |
| 220 | + --draft |
| 221 | + gh release upload "${version}" "${asset_paths[@]}" --repo "${GITHUB_REPOSITORY}" |
| 222 | +
|
| 223 | + remote_assets="$(gh release view "${version}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name')" |
| 224 | + for asset in "${expected_assets[@]}"; do |
| 225 | + if ! grep -qxF "${asset}" <<< "${remote_assets}"; then |
| 226 | + echo "Draft release is missing uploaded asset: ${asset}" >&2 |
| 227 | + exit 1 |
| 228 | + fi |
| 229 | + done |
| 230 | +
|
| 231 | + gh release edit "${version}" --repo "${GITHUB_REPOSITORY}" --draft=false |
| 232 | +
|
| 233 | + immutable="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${version}" -H X-GitHub-Api-Version:2026-03-10 --jq '.immutable')" |
| 234 | + if [[ "${immutable}" != "true" ]]; then |
| 235 | + echo "Published release is not immutable." >&2 |
| 236 | + exit 1 |
| 237 | + fi |
0 commit comments