forgectl 0.4.0

Keep a GitHub account's repositories in line with one manifest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
name: Rust CI

# The checks shared by every Rust repo forge manages. Each repo's ci.yml calls
# it; triggers can't be shared, so they live there:
#
#   on:
#     pull_request:
#     push:
#       branches: [main]
#     schedule:
#       - cron: "0 6 * * 1"
#     workflow_dispatch:
#   permissions:
#     contents: read
#   jobs:
#     ci:
#       uses: lkurcak/forge/.github/workflows/rust-ci.yml@v1
#
# Pull requests test the branch merged into main as it was then; the push run
# tests what actually landed, which also covers direct pushes and is what the
# release workflow relies on. The weekly run catches new stable Rust breaking
# a repo nobody touched. A newer run of the same ref cancels an older one.
#
# Rust versions come from the repo, not from here: the MSRV job runs when
# Cargo.toml sets rust-version, with that toolchain. Writing the version as an
# input (not `dtolnay/rust-toolchain@1.85`) keeps Dependabot from bumping it.
#
# A repo shipping a container image also runs it on version tags, and lets
# the call publish packages:
#
#   on:
#     ...
#     push:
#       branches: [main]
#       tags: ["v[0-9]+.[0-9]+.[0-9]+"]
#   ...
#   jobs:
#     ci:
#       uses: lkurcak/forge/.github/workflows/rust-ci.yml@v1
#       permissions:
#         contents: read
#         packages: write
#       with:
#         container: true
#
# The container jobs take whatever the caller grants, so callers without
# containers don't have to grant packages: write; every other job keeps
# contents: read for itself.
#
# Where Actions minutes are billed (private repos), `light: true` keeps CI to
# a sanity check and leaves the rest to the machine the work is done on:
#
#   with:
#     light: true
#     container: true   # if it ships one
#
# Pull requests, the weekly run and manual runs then get one Linux job, `ok`,
# running fmt, clippy and the tests (`tests: false` leaves them out), so the
# setup and the build cache are paid for once. Nothing else runs: no docs,
# packaging, MSRV, macOS, Windows or images. A push runs nothing but the
# image, built and published for linux/amd64 (`arm64: true` adds
# linux/arm64); the pull request already checked what landed. Every job is
# billed at least a minute, and the full set runs up to a dozen per push.
# The weekly run, on the default branch, also leaves a build cache there for
# pull requests to start from; it's the only run that saves one.
#
# Image layers are cached between runs, so the Dockerfile compiles the
# dependencies in a layer of their own with cargo-chef (forge's `container`
# tag checks for it). That layer only rebuilds when Cargo.toml, Cargo.lock or
# the toolchain changes; a source change only recompiles the crate:
#
#   FROM rust:1-bookworm AS chef
#   RUN cargo install cargo-chef --locked
#   WORKDIR /build
#   # With a rust-toolchain.toml; without one, leave out these two lines.
#   COPY rust-toolchain.toml ./
#   RUN rustup toolchain install
#
#   FROM chef AS planner
#   COPY . .
#   RUN cargo chef prepare --recipe-path recipe.json
#
#   FROM chef AS builder
#   COPY --from=planner /build/recipe.json ./
#   RUN cargo chef cook --locked --release --recipe-path recipe.json
#   COPY Cargo.toml Cargo.lock ./
#   COPY src ./src
#   RUN cargo build --locked --release
#
#   FROM debian:bookworm-slim
#   COPY --from=builder /build/target/release/{binary} /usr/local/bin/
#   ...
#
# The planner copies the whole context, so .dockerignore should leave out
# target and .git. A pinned toolchain (a dated nightly, say) keeps the cache
# warm; `nightly` alone rebuilds everything daily.

on:
  workflow_call:
    inputs:
      bevy:
        description: >-
          The workspace builds Bevy (with --all-features, at least): install
          the system libraries it needs on Linux (BEVY_LINUX_PACKAGES).
        type: boolean
        default: false
      no-std:
        description: >-
          The crate works without std: build it with --no-default-features
          for NO_STD_TARGET, which has no std at all, on stable and the MSRV.
        type: boolean
        default: false
      container:
        description: >-
          The repo ships as a container image, from the Dockerfile at its
          root: test on Linux only, where it runs, then build the image for
          linux/amd64 and linux/arm64. Pull requests only build it; pushes publish it
          to ghcr.io/{owner}/{repo}, as latest from main and as X.Y.Z and X.Y
          from a vX.Y.Z tag, which must match Cargo.toml's version.
        type: boolean
        default: false
      light:
        description: >-
          Keep CI to a sanity check, for repos whose Actions minutes are
          billed: one Linux job (fmt, clippy, tests) on pull requests,
          scheduled and manual runs, and on pushes only the container
          image, for linux/amd64 alone unless arm64 is set. See the header.
        type: boolean
        default: false
      tests:
        description: >-
          With light, run the tests too; a repo tested only where it's
          worked on sets this to false. Without light, tests always run.
        type: boolean
        default: true
      arm64:
        description: >-
          With light and container, build the image for linux/arm64 as well
          as linux/amd64. Without light, images are always built for both.
        type: boolean
        default: false

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always
  # Bare-metal ARM: anything reaching for std, in the crate or its
  # dependencies, fails to build here.
  NO_STD_TARGET: thumbv7em-none-eabi
  # From Bevy's Linux setup guide: audio, input, windowing and GL.
  BEVY_LINUX_PACKAGES: >-
    libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
    libx11-dev libxrandr-dev libxcursor-dev libxi-dev libxinerama-dev
    libegl1-mesa-dev libgl1-mesa-dev libgles2-mesa-dev
  IMAGE: ghcr.io/${{ github.repository }}

jobs:
  fmt:
    if: ${{ !inputs.light }}
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt
      - run: cargo fmt --all --check

  clippy:
    if: ${{ !inputs.light }}
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - if: inputs.bevy && runner.os == 'Linux'
        name: Install Bevy's Linux libraries
        run: |
          # shellcheck disable=SC2086 # one argument per package
          sudo apt-get update && sudo apt-get install -y --no-install-recommends $BEVY_LINUX_PACKAGES
      - uses: Swatinem/rust-cache@v2
      - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings

  test:
    if: ${{ !inputs.light }}
    permissions:
      contents: read
    strategy:
      fail-fast: false
      matrix:
        os: ${{ fromJSON(inputs.container && '["ubuntu-latest"]' || '["ubuntu-latest", "macos-latest", "windows-latest"]') }}
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - if: inputs.bevy && runner.os == 'Linux'
        name: Install Bevy's Linux libraries
        run: |
          # shellcheck disable=SC2086 # one argument per package
          sudo apt-get update && sudo apt-get install -y --no-install-recommends $BEVY_LINUX_PACKAGES
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --workspace --all-features --locked

  doc:
    if: ${{ !inputs.light }}
    permissions:
      contents: read
    runs-on: ubuntu-latest
    env:
      RUSTDOCFLAGS: -D warnings
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - if: inputs.bevy && runner.os == 'Linux'
        name: Install Bevy's Linux libraries
        run: |
          # shellcheck disable=SC2086 # one argument per package
          sudo apt-get update && sudo apt-get install -y --no-install-recommends $BEVY_LINUX_PACKAGES
      - uses: Swatinem/rust-cache@v2
      - run: cargo doc --workspace --all-features --no-deps --locked

  # What the release would upload to crates.io, for crates that can be
  # published at all.
  package:
    if: ${{ !inputs.light }}
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Dry-run cargo publish
        run: |
          publishable=$(cargo metadata --no-deps --format-version 1 \
            | jq '[.packages[] | select(.publish != [])] | length')
          if [ "$publishable" -eq 0 ]; then
            echo "Nothing here is published to crates.io"
            exit 0
          fi
          cargo publish --workspace --dry-run --locked

  msrv:
    if: ${{ !inputs.light }}
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - name: Read rust-version
        id: msrv
        run: |
          msrv=$(cargo metadata --no-deps --format-version 1 \
            | jq -r '[.packages[].rust_version // empty] | sort_by(split(".") | map(tonumber)) | last // empty')
          echo "version=$msrv" >> "$GITHUB_OUTPUT"
          [ -n "$msrv" ] || echo "No rust-version in Cargo.toml; nothing to check"
      - if: steps.msrv.outputs.version != ''
        uses: dtolnay/rust-toolchain@master
        with:
          toolchain: ${{ steps.msrv.outputs.version }}
          targets: ${{ inputs.no-std && env.NO_STD_TARGET || '' }}
      - if: inputs.bevy && runner.os == 'Linux'
        name: Install Bevy's Linux libraries
        run: |
          # shellcheck disable=SC2086 # one argument per package
          sudo apt-get update && sudo apt-get install -y --no-install-recommends $BEVY_LINUX_PACKAGES
      - if: steps.msrv.outputs.version != ''
        uses: Swatinem/rust-cache@v2
      - if: steps.msrv.outputs.version != ''
        run: cargo test --workspace --all-features --locked
      - if: steps.msrv.outputs.version != '' && inputs.no-std
        run: cargo build --workspace --no-default-features --locked --target "$NO_STD_TARGET"

  no-std:
    permissions:
      contents: read
    if: inputs.no-std && !inputs.light
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ env.NO_STD_TARGET }}
      - uses: Swatinem/rust-cache@v2
      - run: cargo build --workspace --no-default-features --locked --target "$NO_STD_TARGET"

  # A vX.Y.Z tag must match Cargo.toml's version before any image is
  # published under it. Same rule as rust-release.yml: every crate that gets
  # published shares the version (all of them, if none is published).
  version:
    if: inputs.container && github.event_name == 'push' && github.ref_type == 'tag'
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - name: Check tag matches Cargo.toml version
        run: |
          version=${GITHUB_REF_NAME#v}
          mismatched=$(cargo metadata --no-deps --format-version 1 \
            | jq -r --arg v "$version" '.packages as $all
                | [$all[] | select(.publish != [])] | if length > 0 then . else $all end
                | .[] | select(.version != $v) | "\(.name) \(.version)"')
          if [ -n "$mismatched" ]; then
            echo "::error::Tag $GITHUB_REF_NAME does not match Cargo.toml: $mismatched"
            exit 1
          fi

  # One image per architecture, each built natively and pushed by digest
  # alone; container-publish tags them together as one multi-arch image.
  # Built alone (light, without arm64), the image is pushed under its tags
  # right here instead. Light builds images on pushes only.
  container:
    if: >-
      ${{ !cancelled() && !failure() && inputs.container
      && (!inputs.light || github.event_name == 'push') }}
    needs: [fmt, clippy, test, doc, package, msrv, version]
    strategy:
      fail-fast: false
      matrix:
        include: >-
          ${{ fromJSON(inputs.light && !inputs.arm64
          && '[{"platform": "linux/amd64", "runner": "ubuntu-latest"}]'
          || '[{"platform": "linux/amd64", "runner": "ubuntu-latest"},
          {"platform": "linux/arm64", "runner": "ubuntu-24.04-arm"}]') }}
    runs-on: ${{ matrix.runner }}
    env:
      PUSH: ${{ github.event_name == 'push' }}
      ALONE: ${{ inputs.light && !inputs.arm64 }}
    steps:
      - uses: actions/checkout@v7
      - uses: docker/setup-buildx-action@v4
      - if: env.PUSH == 'true'
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: metadata
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=raw,value=latest,enable={{is_default_branch}}
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
      - id: arch
        run: echo "arch=${PLATFORM#linux/}" >> "$GITHUB_OUTPUT"
        env:
          PLATFORM: ${{ matrix.platform }}
      - id: build
        uses: docker/build-push-action@v7
        with:
          context: .
          platforms: ${{ matrix.platform }}
          labels: ${{ steps.metadata.outputs.labels }}
          tags: ${{ env.PUSH == 'true' && env.ALONE == 'true' && steps.metadata.outputs.tags || '' }}
          outputs: >-
            ${{ env.PUSH != 'true' && 'type=cacheonly'
            || env.ALONE == 'true' && 'type=image,push=true'
            || format('type=image,name={0},push-by-digest=true,name-canonical=true,push=true', env.IMAGE) }}
          cache-from: type=gha,scope=container-${{ steps.arch.outputs.arch }}
          cache-to: type=gha,mode=max,scope=container-${{ steps.arch.outputs.arch }}
      - if: env.PUSH == 'true' && env.ALONE != 'true'
        name: Export digest
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          mkdir -p "$RUNNER_TEMP/digests"
          touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}"
      - if: env.PUSH == 'true' && env.ALONE != 'true'
        uses: actions/upload-artifact@v7
        with:
          name: digest-${{ steps.arch.outputs.arch }}
          path: ${{ runner.temp }}/digests/*
          if-no-files-found: error
          retention-days: 1

  container-publish:
    if: >-
      ${{ !cancelled() && needs.container.result == 'success' && inputs.container
      && github.event_name == 'push' && !(inputs.light && !inputs.arm64) }}
    needs: container
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8
        with:
          path: ${{ runner.temp }}/digests
          pattern: digest-*
          merge-multiple: true
      - uses: docker/setup-buildx-action@v4
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: metadata
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=raw,value=latest,enable={{is_default_branch}}
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
      - name: Tag the images as one
        working-directory: ${{ runner.temp }}/digests
        env:
          TAGS: ${{ steps.metadata.outputs.tags }}
        run: |
          args=()
          while read -r tag; do
            [ -n "$tag" ] && args+=(--tag "$tag")
          done <<<"$TAGS"
          for digest in *; do
            args+=("$IMAGE@sha256:$digest")
          done
          docker buildx imagetools create "${args[@]}"

  # One check that passes only if every job above passed or was skipped:
  # what a ruleset requires (require-ci, "ci / ok"), so a PR's auto-merge
  # waits for all of CI, whichever jobs a repo's inputs turn on. With light,
  # it's the one job, and does the checking itself.
  ok:
    if: >-
      ${{ !inputs.light && always()
      || inputs.light && !cancelled() && github.event_name != 'push' }}
    needs: [fmt, clippy, test, doc, package, msrv, no-std, version, container, container-publish]
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - if: ${{ !inputs.light }}
        name: Every job passed or was skipped
        env:
          RESULTS: ${{ join(needs.*.result, ' ') }}
        run: |
          echo "$RESULTS"
          for r in $RESULTS; do
            case "$r" in success|skipped) ;; *) exit 1 ;; esac
          done
      - if: inputs.light
        uses: actions/checkout@v7
      - if: inputs.light
        uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy
          targets: ${{ inputs.no-std && env.NO_STD_TARGET || '' }}
      - if: inputs.light && inputs.bevy
        name: Install Bevy's Linux libraries
        run: |
          # shellcheck disable=SC2086 # one argument per package
          sudo apt-get update && sudo apt-get install -y --no-install-recommends $BEVY_LINUX_PACKAGES
      - if: inputs.light
        run: cargo fmt --all --check
      # Pull requests start from the default branch's cache, and a pull
      # request's own is rarely read again (most get one run), so light
      # saves it from the weekly and manual runs alone: quicker pull
      # requests, and their caches don't crowd the image layers out of the
      # repo's 10 GB.
      - if: inputs.light
        uses: Swatinem/rust-cache@v2
        with:
          save-if: ${{ github.event_name != 'pull_request' }}
      - if: inputs.light
        run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
      - if: inputs.light && inputs.tests
        run: cargo test --workspace --all-features --locked
      - if: inputs.light && inputs.no-std
        run: cargo build --workspace --no-default-features --locked --target "$NO_STD_TARGET"