mirror of
https://github.com/storytold/craft-common-actions.git
synced 2026-11-02 18:06:16 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user