| 1 | # Releases the self-hosted runner, g1t-runner (crates/runner): builds it for |
| 2 | # Linux, macOS and Windows on x64 and arm64, pushes its container image, |
| 3 | # builds the images `g1t-runner sandbox` runs on people's own computers, |
| 4 | # signs the release (binaries and sandbox images in one manifest), and |
| 5 | # publishes it to g1t.sh/downloads/runner/ (the g1t-downloads R2 bucket). |
| 6 | # Runners already out there update themselves to it. |
| 7 | # |
| 8 | # A release is a tag `runner-v<version>`, where the version is the one in |
| 9 | # crates/runner/Cargo.toml; or run it by hand. scripts/runner-release.mjs |
| 10 | # and scripts/sandbox-image.mjs do the work, and the first's header says how |
| 11 | # to make the release key the first time; CONTRIBUTING.md ("Deploying") |
| 12 | # says how a release is made. |
| 13 | # |
| 14 | # The image jobs run on g1t's own machines (g1t-4core), whose jobs have a |
| 15 | # Docker Engine of their own (crates/runner/src/docker/engine.rs). arm64 |
| 16 | # images need QEMU's emulators registered with the kernel; each image job |
| 17 | # tries that, and when the machine does not allow it, builds amd64 alone |
| 18 | # and says so in a warning rather than failing the release. |
| 19 | name: Runner release |
| 20 | |
| 21 | on: |
| 22 | push: |
| 23 | tags: ["runner-v*"] |
| 24 | workflow_dispatch: |
| 25 | |
| 26 | permissions: |
| 27 | contents: read |
| 28 | |
| 29 | concurrency: |
| 30 | group: runner-release |
| 31 | cancel-in-progress: false |
| 32 | |
| 33 | env: |
| 34 | CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} |
| 35 | WRANGLER_SEND_METRICS: "false" |
| 36 | |
| 37 | jobs: |
| 38 | binaries: |
| 39 | # One job per target, side by side: each cross-compile takes about six |
| 40 | # minutes, and one after another they were most of a release's wait. |
| 41 | name: Build ${{ matrix.target }} |
| 42 | runs-on: ubuntu-latest |
| 43 | timeout-minutes: 60 |
| 44 | strategy: |
| 45 | fail-fast: true |
| 46 | matrix: |
| 47 | target: [linux-x64, linux-arm64, windows-x64] |
| 48 | steps: |
| 49 | - uses: actions/checkout@v5 |
| 50 | - name: The tag names this version |
| 51 | if: startsWith(github.ref, 'refs/tags/runner-v') |
| 52 | run: | |
| 53 | version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/runner/Cargo.toml | head -1)" |
| 54 | [ "runner-v$version" = "${GITHUB_REF_NAME}" ] || { echo "::error::The tag is ${GITHUB_REF_NAME}, but crates/runner is $version"; exit 1; } |
| 55 | # zig and cargo-zigbuild, kept between releases: installing them took |
| 56 | # four minutes of every one. Change the key to take newer versions. |
| 57 | - name: Cache zig and cargo-zigbuild |
| 58 | id: zig |
| 59 | uses: actions/cache@v4 |
| 60 | with: |
| 61 | path: | |
| 62 | ~/.local/lib/python3*/site-packages/ziglang |
| 63 | ~/.local/bin |
| 64 | ~/.cargo/bin/cargo-zigbuild |
| 65 | key: zig-0.13.0-zigbuild-${{ runner.os }}-1 |
| 66 | - name: Install zig and cargo-zigbuild |
| 67 | if: steps.zig.outputs.cache-hit != 'true' |
| 68 | run: | |
| 69 | # The runner's Debian marks its Python as externally managed (PEP 668); zig goes under ~/.local anyway. |
| 70 | pip install --user --break-system-packages ziglang==0.13.0 |
| 71 | cargo install --locked cargo-zigbuild |
| 72 | - name: zig on the PATH |
| 73 | run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" |
| 74 | # Each target's build, kept between releases; a release after a |
| 75 | # Cargo.lock change starts from the last one's rather than from nothing. |
| 76 | - uses: actions/cache@v4 |
| 77 | with: |
| 78 | path: | |
| 79 | ~/.cargo/registry |
| 80 | target |
| 81 | key: runner-release-${{ matrix.target }}-${{ hashFiles('Cargo.lock') }} |
| 82 | restore-keys: runner-release-${{ matrix.target }}- |
| 83 | # Crates whose inputs did not change come back from the Actions cache |
| 84 | # instead of being compiled (scripts/sccache.sh); without it, the job |
| 85 | # builds as before. |
| 86 | - name: sccache |
| 87 | run: bash scripts/sccache.sh install |
| 88 | # The cache above brings back the last release's target/runner-release |
| 89 | # too: left there, it would go out with this one's, and the jobs after |
| 90 | # would find two of each file. |
| 91 | - name: Only this release's files |
| 92 | run: rm -rf target/runner-release |
| 93 | - name: Build Linux and Windows |
| 94 | # The macOS targets need Apple's SDK to link (the Security and |
| 95 | # CoreFoundation frameworks), which zig does not carry, so they are |
| 96 | # left out until this builds in the rust-cross/cargo-zigbuild image |
| 97 | # as the CLI's release does (scripts/cli-release.mjs). A Mac runner |
| 98 | # keeps the version it has, since the manifest names no file for it. |
| 99 | env: |
| 100 | G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }} |
| 101 | run: node scripts/runner-release.mjs build --targets ${{ matrix.target }} |
| 102 | - name: sccache's hits |
| 103 | if: always() |
| 104 | run: bash scripts/sccache.sh stats |
| 105 | - uses: actions/upload-artifact@v4 |
| 106 | with: |
| 107 | name: binaries-${{ matrix.target }} |
| 108 | path: target/runner-release/ |
| 109 | overwrite: true |
| 110 | |
| 111 | image: |
| 112 | name: Container image |
| 113 | needs: binaries |
| 114 | runs-on: g1t-4core |
| 115 | # Production's secrets, without a deployment: a release is not a deploy of |
| 116 | # g1t.sh, and would otherwise show on its card as one in progress. |
| 117 | environment: |
| 118 | name: production |
| 119 | deployment: false |
| 120 | timeout-minutes: 45 |
| 121 | # Its token pushes the image to g1t's registry. |
| 122 | permissions: |
| 123 | contents: read |
| 124 | packages: write |
| 125 | outputs: |
| 126 | # The pushed image by digest, which the sandbox's manifest names. |
| 127 | runner_image: ${{ steps.push.outputs.runner_image }} |
| 128 | steps: |
| 129 | - uses: actions/checkout@v5 |
| 130 | - uses: actions/download-artifact@v4 |
| 131 | with: |
| 132 | pattern: binaries-* |
| 133 | merge-multiple: true |
| 134 | path: release |
| 135 | - name: QEMU for arm64, if this machine allows it |
| 136 | id: qemu |
| 137 | run: | |
| 138 | if docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 \ |
| 139 | && [ "$(docker run --rm --platform linux/arm64 debian:bookworm-slim uname -m 2>/dev/null)" = aarch64 ]; then |
| 140 | echo "arches=amd64 arm64" >> "$GITHUB_OUTPUT" |
| 141 | else |
| 142 | echo "::warning::This machine cannot run arm64 programs (QEMU's emulators could not be registered), so the runner's image is built for amd64 alone this time." |
| 143 | echo "arches=amd64" >> "$GITHUB_OUTPUT" |
| 144 | fi |
| 145 | - name: Build and push |
| 146 | id: push |
| 147 | # To g1t's own registry, where flagon-io keeps it public. |
| 148 | env: |
| 149 | IMAGE: g1t.sh/flagon-io/g1t-runner |
| 150 | ARCHES: ${{ steps.qemu.outputs.arches }} |
| 151 | run: | |
| 152 | version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/runner/Cargo.toml | head -1)" |
| 153 | echo "${{ secrets.G1T_TOKEN }}" | docker login g1t.sh -u g1t --password-stdin |
| 154 | tags=() |
| 155 | for arch in $ARCHES; do |
| 156 | mkdir -p "context-$arch" |
| 157 | cp deploy/runner/Dockerfile "context-$arch/" |
| 158 | name="g1t-runner-linux-$([ "$arch" = amd64 ] && echo x64 || echo arm64)" |
| 159 | cp "release/$version/$name" "context-$arch/g1t-runner" |
| 160 | docker buildx build --platform "linux/$arch" -t "$IMAGE:$version-$arch" --load "context-$arch" |
| 161 | docker push "$IMAGE:$version-$arch" |
| 162 | tags+=("$IMAGE:$version-$arch") |
| 163 | done |
| 164 | docker buildx imagetools create -t "$IMAGE:$version" -t "$IMAGE:latest" "${tags[@]}" |
| 165 | digest="$(docker buildx imagetools inspect "$IMAGE:$version" --format '{{json .Manifest}}' | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).digest))')" |
| 166 | echo "runner_image=$IMAGE@$digest" >> "$GITHUB_OUTPUT" |
| 167 | |
| 168 | sandbox: |
| 169 | name: Sandbox images |
| 170 | needs: [binaries, image] |
| 171 | runs-on: g1t-4core |
| 172 | timeout-minutes: 60 |
| 173 | steps: |
| 174 | - uses: actions/checkout@v5 |
| 175 | - uses: actions/download-artifact@v4 |
| 176 | with: |
| 177 | pattern: binaries-* |
| 178 | merge-multiple: true |
| 179 | path: target/runner-release |
| 180 | - name: QEMU for arm64, if this machine allows it |
| 181 | id: qemu |
| 182 | run: | |
| 183 | if docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 \ |
| 184 | && [ "$(docker run --rm --platform linux/arm64 debian:bookworm-slim uname -m 2>/dev/null)" = aarch64 ]; then |
| 185 | echo "arches=x64 arm64" >> "$GITHUB_OUTPUT" |
| 186 | else |
| 187 | echo "::warning::This machine cannot run arm64 programs, so the sandbox's images are built for x64 alone this time: an arm64 Mac or Linux machine keeps the sandbox image it has." |
| 188 | echo "arches=x64" >> "$GITHUB_OUTPUT" |
| 189 | fi |
| 190 | - name: Build the WSL root filesystem and the VM's disk |
| 191 | env: |
| 192 | ARCHES: ${{ steps.qemu.outputs.arches }} |
| 193 | run: | |
| 194 | version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/runner/Cargo.toml | head -1)" |
| 195 | dir="target/runner-release/$version/" |
| 196 | for arch in $ARCHES; do |
| 197 | node scripts/sandbox-image.mjs build --arch "$arch" --runner "${dir}g1t-runner-linux-$arch" --out "$dir" |
| 198 | done |
| 199 | - name: The manifest's sandbox section |
| 200 | env: |
| 201 | RUNNER_SANDBOX_IMAGE: ${{ needs.image.outputs.runner_image }} |
| 202 | run: | |
| 203 | version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/runner/Cargo.toml | head -1)" |
| 204 | node scripts/sandbox-image.mjs section --dir "target/runner-release/$version/" |
| 205 | - uses: actions/upload-artifact@v4 |
| 206 | with: |
| 207 | name: sandbox |
| 208 | path: | |
| 209 | target/runner-release/*/g1t-sandbox-* |
| 210 | target/runner-release/*/sandbox.json |
| 211 | overwrite: true |
| 212 | |
| 213 | publish: |
| 214 | name: Sign and publish |
| 215 | needs: [binaries, image, sandbox] |
| 216 | # The binaries go out even when an image job failed: the manifest then |
| 217 | # names no sandbox images, and computers keep the sandbox they have. |
| 218 | if: ${{ always() && needs.binaries.result == 'success' }} |
| 219 | runs-on: ubuntu-latest |
| 220 | environment: |
| 221 | name: production |
| 222 | deployment: false |
| 223 | timeout-minutes: 30 |
| 224 | steps: |
| 225 | - uses: actions/checkout@v5 |
| 226 | - uses: actions/download-artifact@v4 |
| 227 | with: |
| 228 | pattern: binaries-* |
| 229 | merge-multiple: true |
| 230 | path: target/runner-release |
| 231 | - uses: actions/download-artifact@v4 |
| 232 | if: ${{ needs.sandbox.result == 'success' }} |
| 233 | with: |
| 234 | name: sandbox |
| 235 | path: target/runner-release |
| 236 | - name: Sign |
| 237 | env: |
| 238 | RUNNER_RELEASE_KEY: ${{ secrets.RUNNER_RELEASE_KEY }} |
| 239 | G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }} |
| 240 | run: | |
| 241 | node scripts/runner-release.mjs manifest |
| 242 | node scripts/runner-release.mjs sign |
| 243 | node scripts/runner-release.mjs verify |
| 244 | # npm's downloads, kept between runs: an install reads them first |
| 245 | # and asks the registry only for what changed, so a dropped |
| 246 | # connection to it rarely stops the job. |
| 247 | - name: Cache npm's downloads |
| 248 | uses: actions/cache@v4 |
| 249 | with: |
| 250 | path: ~/.npm |
| 251 | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} |
| 252 | restore-keys: npm-${{ runner.os }}- |
| 253 | - name: Install Wrangler |
| 254 | run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund |
| 255 | - name: Publish to g1t.sh/downloads/runner |
| 256 | env: |
| 257 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 258 | run: node scripts/runner-release.mjs publish |