mirror of
https://github.com/storytold/craft-common-actions.git
synced 2026-11-09 12:11:40 +00:00
* 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>).
76 lines
3.1 KiB
Python
76 lines
3.1 KiB
Python
"""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()
|