Shared quality-gate workflow for the craft apps (gate.yml) (#69)

* ci: add a shared CI workflow for the craft apps

A reusable workflow that runs each app's quality gate (by default
`cargo xtask ci`) on one or more runners, with the suite's shared setup:
cached apt packages, the repository's toolchain, prebuilt cargo tools,
the craft-fonts and craft-i18n checkouts, and a dependency cache that only
the default branch writes.

The rust-cache key includes the root Cargo.toml: rust-cache hashes only
the workspace members' manifests, so a [profile] change in a virtual root
manifest would otherwise keep restoring the old cache.

Part of #60.

* tests: cover the shared CI workflow

test_ci.py checks that third-party actions are pinned to commits, that
only non-PR runs on the default branch save the cache, that caller input
reaches run blocks only through env, and runs the caller-environment
script under bash to check it writes valid KEY=VALUE lines and rejects
others. The craft-fonts test also holds ci.yml to the suite's pin, and the
test workflow runs on changes to ci.yml.

* docs: document the shared CI workflow

Describes what ci.yml sets up and runs, with a plain caller and one that
adds a Windows leg on main, manual runs and full-ci pull requests.

* actions: don't persist credentials in the craft-fonts and craft-i18n checkouts

Both repositories are public and nothing later in a job runs git in them,
so the token has no use there.

* ci: name the shared workflow gate.yml

The repository's own CI proposed in #21 takes .github/workflows/ci.yml.
Callers use .github/workflows/gate.yml@main; the job still reports as
gate (<os>).
This commit is contained in:
aiongg
2026-10-23 12:15:49 -04:00
committed by GitHub
parent 5d4336931c
commit 9587bf235d
7 changed files with 378 additions and 3 deletions
+221
View File
@@ -0,0 +1,221 @@
name: "Gate"
# The quality gate the craft apps run on pull requests and pushes to main: one job per runner in
# `os`, each running `command` (by default `cargo xtask ci`) with the suite's shared setup. App-only
# jobs (fuzzing, corpora, web builds) stay in the app's own workflow next to the call.
on:
workflow_call:
inputs:
packages:
description: "Ubuntu packages the build needs, separated by spaces or newlines; installed on Linux legs through the cached install-apt-packages action"
type: string
required: true
command:
description: "The gate, run with bash from the repository root"
type: string
required: false
default: "cargo xtask ci"
windows-command:
description: "The gate on Windows legs; empty runs `command`"
type: string
required: false
default: ""
os:
description: 'JSON array of runner labels; one leg per entry, e.g. ["ubuntu-24.04","windows-latest"]'
type: string
required: false
default: '["ubuntu-24.04"]'
craft-fonts-ref:
# The suite's craft-fonts pin, as in release.yml and freebsd.yml.
description: "The commit of storytold/craft-fonts the tests build with. Defaults to the suite's pin; empty builds without craft-fonts"
type: string
required: false
default: "bfcbf714e45a3ea8e98f88ab2b7971372a576414"
require-craft-fonts:
description: "Set CRAFT_FONTS_REQUIRED=1 when craft-fonts is checked out, so a build that silently drops its fonts fails here as it would in a release"
type: boolean
required: false
default: true
toolchain:
description: "rustup toolchain to install when rust-toolchain.toml names no channel"
type: string
required: false
default: "stable"
targets:
description: "Extra rustup targets, separated by spaces or commas"
type: string
required: false
default: "wasm32-unknown-unknown"
tools:
description: "Prebuilt cargo tools from taiki-e/install-action, comma-separated, each with its version (e.g. cargo-nextest@0.9.149,cargo-deny@0.20.2); empty installs none"
type: string
required: false
default: "cargo-nextest@0.9.149"
env:
description: "KEY=VALUE lines added to the environment of every leg before the cache is restored"
type: string
required: false
default: ""
linux-env:
description: "KEY=VALUE lines for Linux legs only"
type: string
required: false
default: ""
windows-env:
description: "KEY=VALUE lines for Windows legs only"
type: string
required: false
default: ""
dev-debug:
description: "CARGO_PROFILE_DEV_DEBUG for the gate (the test profile inherits it); empty leaves the repository's profile alone"
type: string
required: false
default: "line-tables-only"
free-disk-space:
description: "Delete the Linux image's unused toolchains (Android, .NET, GHC, CodeQL) in the background before the build"
type: boolean
required: false
default: true
cache-version:
description: "Bump to start the dependency cache afresh"
type: string
required: false
default: "v1"
timeout-minutes:
description: "Timeout of each leg"
type: number
required: false
default: 60
permissions:
contents: read
jobs:
gate:
name: gate (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: ${{ inputs.timeout-minutes }}
strategy:
# A failing leg must not cancel the others and hide their failures.
fail-fast: false
matrix:
os: ${{ fromJSON(inputs.os) }}
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUST_BACKTRACE: 1
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check out craft-fonts
if: inputs.craft-fonts-ref != ''
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ inputs.craft-fonts-ref }}
path: craft-fonts
persist-credentials: false
- name: Build with craft-fonts
if: inputs.craft-fonts-ref != ''
shell: bash
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
REQUIRED: ${{ inputs.require-craft-fonts }}
run: |
echo "CRAFT_FONTS_DIR=$CRAFT_FONTS_DIR" >> "$GITHUB_ENV"
if [ "$REQUIRED" = true ]; then echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"; fi
# Does nothing unless the repository has craft-i18n.rev; then builds require the translations.
- name: Check out craft-i18n
uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main
# Before the cache step: rust-cache keys on CARGO_* and RUST* variables.
- name: Caller environment
if: inputs.env != '' || inputs.linux-env != '' || inputs.windows-env != '' || inputs.dev-debug != ''
shell: bash
env:
ALL: ${{ inputs.env }}
LINUX: ${{ inputs.linux-env }}
WINDOWS: ${{ inputs.windows-env }}
DEV_DEBUG: ${{ inputs.dev-debug }}
run: |
set -euo pipefail
case "$RUNNER_OS" in
Linux) extra=$LINUX ;;
Windows) extra=$WINDOWS ;;
*) extra= ;;
esac
if [ -n "$DEV_DEBUG" ]; then echo "CARGO_PROFILE_DEV_DEBUG=$DEV_DEBUG" >> "$GITHUB_ENV"; fi
printf '%s\n%s\n' "$ALL" "$extra" | while IFS= read -r line; do
[ -n "$line" ] || continue
if ! [[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
echo "::error::env: expected KEY=VALUE, got '$line'"
exit 1
fi
echo "$line" >> "$GITHUB_ENV"
done
# Nothing waits on it: the space is free before the build needs it.
- name: Free disk space
if: runner.os == 'Linux' && inputs.free-disk-space
run: |
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
- name: Install Linux build dependencies
if: runner.os == 'Linux'
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: ${{ inputs.packages }}
# The channel in rust-toolchain.toml wins over `toolchain`, so the gate builds with the
# toolchain contributors use and a new Rust release does not invalidate the cache.
- name: Install Rust
shell: bash
env:
TOOLCHAIN: ${{ inputs.toolchain }}
TARGETS: ${{ inputs.targets }}
run: |
set -euo pipefail
if [ -f rust-toolchain.toml ]; then
channel=$(sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' rust-toolchain.toml)
TOOLCHAIN=${channel:-$TOOLCHAIN}
fi
args=(--profile minimal --no-self-update --component clippy --component rustfmt)
for target in ${TARGETS//,/ }; do args+=(--target "$target"); done
rustup toolchain install "$TOOLCHAIN" "${args[@]}"
rustup default "$TOOLCHAIN"
rustc --version
- name: Install cargo tools
if: inputs.tools != ''
uses: taiki-e/install-action@a4d3f3f761dcd95cce1b72cb61cb6d1963438177 # v2.87.30
with:
tool: ${{ inputs.tools }}
# Only the default branch writes, so PR caches never evict its entry; a failing gate still
# saves. rust-cache hashes only the members' manifests, so the root Cargo.toml goes in the key
# or a profile change in a virtual root keeps restoring the old cache.
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
prefix-key: ${{ inputs.cache-version }}-rust
key: ${{ hashFiles('Cargo.toml') }}
save-if: ${{ github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch }}
cache-on-failure: true
- name: Gate
shell: bash
env:
GATE: ${{ runner.os == 'Windows' && inputs.windows-command != '' && inputs.windows-command || inputs.command }}
run: |
printf '%s\n' "$GATE" > "$RUNNER_TEMP/gate.sh"
bash -euo pipefail "$RUNNER_TEMP/gate.sh"
# A gate that changed Cargo.lock ran against a lockfile that is not the one in the repository.
- name: Lockfile unchanged
shell: bash
run: git diff --exit-code -- Cargo.lock
+1 -1
View File
@@ -12,7 +12,7 @@ on:
type: string
required: true
craft-fonts-ref:
# The suite's craft-fonts pin. Bump it here and in freebsd.yml together.
# The suite's craft-fonts pin. Bump it here, in freebsd.yml and in gate.yml together.
description: "Git ref for storytold/craft-fonts embedded font assets. Defaults to the suite's pin; override it only for an app that needs another commit. Desktop builds run with CRAFT_FONTS_DIR and CRAFT_FONTS_REQUIRED=1 either way"
type: string
required: false
+2
View File
@@ -5,6 +5,7 @@ on:
paths:
- .github/workflows/freebsd.yml
- .github/workflows/release.yml
- .github/workflows/gate.yml
- actions/freebsd-cargo-cache/**
- actions/checkout-craft-i18n/**
- .github/workflows/i18n-gate.yml
@@ -17,6 +18,7 @@ on:
paths:
- .github/workflows/freebsd.yml
- .github/workflows/release.yml
- .github/workflows/gate.yml
- actions/freebsd-cargo-cache/**
- actions/checkout-craft-i18n/**
- .github/workflows/i18n-gate.yml
+74
View File
@@ -350,6 +350,79 @@ jobs:
pin-changes-only: true
```
### 7. `gate.yml`
The quality gate for an app's pull requests and pushes to main. It runs `command` (by default
`cargo xtask ci`) on each runner in `os`, after the suite's shared setup:
- the app and craft-fonts (at the suite's pin, with `CRAFT_FONTS_REQUIRED=1`) checked out without
persisted credentials, and craft-i18n when the app has `craft-i18n.rev`;
- `packages` installed through `actions/install-apt-packages` on Linux legs;
- the channel from `rust-toolchain.toml`, or `toolchain`, with clippy, rustfmt and `targets`;
- prebuilt cargo tools from `tools` (by default `cargo-nextest`);
- `CARGO_PROFILE_DEV_DEBUG=line-tables-only` (set `dev-debug: ""` to keep the repository's
profile) and the caller's `env`, `linux-env` and `windows-env` lines, each `KEY=VALUE`;
- a rust-cache dependency cache that only the default branch saves, so pull requests restore
main's entry and never evict it. The key includes the root `Cargo.toml`, since rust-cache hashes
only the members' manifests. Bump `cache-version` to start afresh.
After the gate, the job fails if `Cargo.lock` changed. Legs run with `fail-fast: false`; each has
`timeout-minutes` (default 60). Jobs only some apps need, such as fuzzing or web builds, stay in
the app's own workflow next to the call.
#### Caller Example:
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run. Every push to main gets its own run, so each
# state of main is checked to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
```
Concurrency belongs to the caller: a reusable workflow cannot set it for the run.
An app that also checks Windows on main, on manual runs and on pull requests labelled `full-ci`,
with per-OS environment and a shorter Windows gate (PDFCraft's call):
```yaml
jobs:
gate:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }}
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev qpdf poppler-utils
tools: cargo-nextest@0.9.149,cargo-deny@0.20.2
linux-env: PDFCRAFT_ORACLES_REQUIRED=1
windows-env: PDFCRAFT_REQUIRE_WINRES=1
# Windows checks what differs by platform: the build (with resources) and the tests.
windows-command: |
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
```
## Composite Actions
Individual actions are also available for composing custom pipelines:
@@ -398,6 +471,7 @@ using `uses` cannot set `timeout-minutes`.
| Release FreeBSD | 300 |
| Standalone FreeBSD build, test and optional package | 300 |
| Windows ARM64 package / install | 90 / 30 |
| CI gate, per leg (`timeout-minutes` input) | 60 |
The new limits leave headroom over sampled Craft app job timings; the existing
FreeBSD and ARM64 limits are retained. Treat these as initial limits, review
+1
View File
@@ -35,6 +35,7 @@ runs:
repository: storytold/craft-i18n
ref: ${{ steps.pin.outputs.rev }}
path: ${{ inputs.path }}
persist-credentials: false
- name: Require craft-i18n in builds
if: steps.pin.outputs.rev != ''
shell: bash
+4 -2
View File
@@ -17,10 +17,12 @@ def default_ref(name):
class CraftFontsTests(unittest.TestCase):
def test_release_and_freebsd_share_one_pinned_commit(self):
def test_shared_workflows_share_one_pinned_commit(self):
ref = default_ref('release.yml')
self.assertRegex(ref, r'^[0-9a-f]{40}$')
self.assertEqual(default_ref('freebsd.yml'), ref)
for name in ('freebsd.yml', 'gate.yml'):
with self.subTest(workflow=name):
self.assertEqual(default_ref(name), ref)
def test_release_builds_that_check_out_fonts_require_them(self):
for job, data in workflow('release.yml')['jobs'].items():
+75
View File
@@ -0,0 +1,75 @@
"""The shared gate workflow: pinned actions, a cache only the default branch writes, and caller
input that reaches the shell only through the environment."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = yaml.load((ROOT / '.github/workflows/gate.yml').read_text(), Loader=yaml.BaseLoader)
STEPS = WORKFLOW['jobs']['gate']['steps']
OWN_ACTION = re.compile(r'^storytold/craft-common-actions/actions/[a-z0-9-]+@main$')
def step(name):
return next(s for s in STEPS if s.get('name') == name)
class GateWorkflowTests(unittest.TestCase):
def test_third_party_actions_are_pinned_to_a_commit(self):
for s in STEPS:
if 'uses' in s:
with self.subTest(uses=s['uses']):
if not OWN_ACTION.match(s['uses']):
self.assertRegex(s['uses'], r'@[0-9a-f]{40}$')
def test_only_pushes_to_the_default_branch_save_the_cache(self):
cache = next(s for s in STEPS if 'rust-cache' in s.get('uses', ''))
condition = re.sub(r'\s+', ' ', cache['with']['save-if'])
self.assertIn("github.event_name != 'pull_request'", condition)
self.assertIn('github.ref_name == github.event.repository.default_branch', condition)
self.assertNotIn('||', condition)
def test_inputs_reach_scripts_only_through_env(self):
for s in STEPS:
if 'run' in s:
with self.subTest(step=s.get('name')):
self.assertNotIn('${{', s['run'])
class CallerEnvironmentTests(unittest.TestCase):
STEP = step('Caller environment')
def run_step(self, runner_os='Linux', **inputs):
env = {'ALL': '', 'LINUX': '', 'WINDOWS': '', 'DEV_DEBUG': '', **inputs}
with tempfile.TemporaryDirectory() as directory:
github_env = Path(directory, 'github_env')
github_env.touch()
result = subprocess.run(
['bash', '--noprofile', '--norc', '-eo', 'pipefail', '-c', self.STEP['run']],
cwd=directory, capture_output=True, text=True,
env={**os.environ, **env, 'RUNNER_OS': runner_os, 'GITHUB_ENV': str(github_env)})
return result.returncode, github_env.read_text().splitlines()
def test_the_step_reads_its_inputs_from_env(self):
self.assertEqual(set(self.STEP['env']), {'ALL', 'LINUX', 'WINDOWS', 'DEV_DEBUG'})
def test_writes_shared_and_per_os_lines(self):
inputs = {'ALL': 'A=1\n\nB=two words', 'LINUX': 'ONLY_LINUX=1', 'WINDOWS': 'ONLY_WINDOWS=1',
'DEV_DEBUG': 'line-tables-only'}
self.assertEqual(self.run_step('Linux', **inputs), (0, [
'CARGO_PROFILE_DEV_DEBUG=line-tables-only', 'A=1', 'B=two words', 'ONLY_LINUX=1']))
self.assertEqual(self.run_step('Windows', **inputs)[1][-1], 'ONLY_WINDOWS=1')
def test_rejects_a_line_that_is_not_key_value(self):
for line in ('NOT AN ASSIGNMENT', '1X=1', 'X<<EOF'):
with self.subTest(line=line):
self.assertEqual(self.run_step(ALL=f'GOOD=1\n{line}')[0], 1)
if __name__ == '__main__':
unittest.main()