Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
self-hosted-runner:
labels:
- cratis-arc
- cratis-arc-small
- cratis-arc-release

paths:
'.github/{workflows,templates}/**':
ignore:
# Remove once actionlint supports concurrency.queue.
- 'unexpected key "queue" for "concurrency" section'
27 changes: 27 additions & 0 deletions .github/scripts/install-actionlint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
set -euo pipefail

version=1.7.12
case "$(uname -m)" in
x86_64|amd64)
arch=amd64
sha256=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
;;
aarch64|arm64)
arch=arm64
sha256=325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6
;;
*)
echo "Unsupported actionlint architecture: $(uname -m)" >&2
exit 1
;;
esac

archive="$RUNNER_TEMP/actionlint.tar.gz"
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${version}/actionlint_${version}_linux_${arch}.tar.gz" -o "$archive"
echo "$sha256 $archive" | sha256sum --check --strict
mkdir -p "$RUNNER_TEMP/bin"
tar -xzf "$archive" -C "$RUNNER_TEMP/bin" actionlint
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
name: Broken fixture
on:
workflow_call:
inputs:
declared:
type: string
concurrency:
group: fixture
invalid-key: true
jobs:
check:
needs: missing-job
runs-on: ubuntu-latest
steps:
- run: echo fixture
env:
UNDEFINED: ${{ inputs.nope }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
name: Shared configuration fixture
on: workflow_dispatch
concurrency:
group: fixture
queue: max
jobs:
check:
strategy:
matrix:
runner: [cratis-arc, cratis-arc-small, cratis-arc-release]
runs-on: ${{ matrix.runner }}
steps:
- run: echo fixture
3 changes: 3 additions & 0 deletions .github/scripts/tests/fixtures/workflow-lint/no-ignores.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
{}
118 changes: 118 additions & 0 deletions .github/scripts/tests/workflow-lint.test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
"""Offline actionlint red controls and reusable-workflow smoke coverage."""
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest

import yaml

ROOT = Path(__file__).resolve().parents[3]
WORKFLOWS = ROOT / ".github/workflows"
CONFIG = ROOT / ".github/actionlint.yaml"
FIXTURES = Path(__file__).parent / "fixtures/workflow-lint"
NOT_SMOKEABLE = {
"update-packages.yml": "requires secrets and writes package updates",
"cleanup-pr-artifacts.yml": "requires secrets and deletes repository artifacts",
"trigger-documentation-build.yml": "dispatches to another repository using a secret",
"normalize-dependabot-labels.yml": "writes pull request labels",
"verify-pull-request-policy.yml": "holds Workflows PRs to consumer release policy; decide separately",
"verify-release-notes.yml": "holds Workflows PRs to consumer release policy; decide separately",
"verify-release-intent.yml": "holds Workflows PRs to consumer release policy; decide separately",
}


def document(path):
return yaml.safe_load(path.read_text())


def triggers(workflow):
# PyYAML's YAML 1.1 loader reads the unquoted `on` key as True.
value = workflow.get("on", workflow.get(True, {}))
return {value} if isinstance(value, str) else set(value)


def lint(case, config=True):
args = ["actionlint", "-pyflakes=", "-shellcheck="]
# Always specify a config: discovery would otherwise find the repository's shared config.
args += ["-config-file", str(CONFIG if config else FIXTURES / "no-ignores.yaml")]
files = sorted((FIXTURES / case / ".github/workflows").glob("*.yml"))
return subprocess.run(args + [str(path) for path in files], cwd=FIXTURES / case,
capture_output=True, text=True, timeout=30)


class WorkflowLintTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not shutil.which("actionlint"):
raise RuntimeError("actionlint is required; run .github/scripts/install-actionlint.sh in CI")

def test_broken_fixture_fails_with_expected_rule_ids(self):
result = lint("broken")
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertEqual(set(re.findall(r"\[([a-z-]+)\]", result.stdout)),
{"expression", "job-needs", "syntax-check"}, result.stdout)
for diagnostic in ['property "nope"', '"missing-job"', 'unexpected key "invalid-key"']:
self.assertIn(diagnostic, result.stdout)

def test_queue_and_shared_runner_labels_pass_with_shared_config(self):
result = lint("good")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
# A red control proves the fixture actually needs the narrowly scoped exception.
without_config = lint("good", config=False)
self.assertEqual(without_config.returncode, 1, without_config.stdout + without_config.stderr)
self.assertIn('unexpected key "queue" for "concurrency" section', without_config.stdout)

def test_every_reusable_is_smoked_or_has_an_explicit_reason(self):
reusables = {path.name for path in WORKFLOWS.iterdir()
if path.suffix in {".yml", ".yaml"} and "workflow_call" in triggers(document(path))}
smoke = document(WORKFLOWS / "smoke-workflows.yml")
called = {job["uses"].removeprefix("./.github/workflows/")
for job in smoke["jobs"].values() if "uses" in job}
self.assertTrue(reusables, "No reusable workflows were discovered")
self.assertTrue(called, "No reusable workflows are smoke-called")
self.assertEqual(called & NOT_SMOKEABLE.keys(), set(), "Remove obsolete exemptions")
self.assertEqual(reusables, called | NOT_SMOKEABLE.keys(),
"Smoke-call new reusables or give a concrete NOT_SMOKEABLE reason")
self.assertTrue(all(reason.strip() for reason in NOT_SMOKEABLE.values()))
self.assertEqual(triggers(smoke), {"pull_request"})
self.assertEqual(smoke["permissions"], {"contents": "read"})
for job in smoke["jobs"].values():
if "uses" in job:
self.assertTrue(job["uses"].startswith("./.github/workflows/"))
self.assertNotIn("secrets", job)

def test_lint_step_uses_repo_config_or_shared_default_and_keeps_workflows_with_extras(self):
workflow = document(WORKFLOWS / "workflow-lint.yml")
script = workflow["jobs"]["lint"]["steps"][-1]["run"]
for config_name in ["actionlint.yaml", "actionlint.yml", None]:
with self.subTest(config=config_name), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
shutil.copytree(FIXTURES / "good/.github", root / ".github")
shared = root / ".cratis-workflows/.github"
shared.mkdir(parents=True)
shutil.copy(CONFIG, shared / "actionlint.yaml")
if config_name:
shutil.copy(CONFIG, root / ".github" / config_name)
(shared / "actionlint.yaml").write_text("invalid config: true\n")
extra = root / ".github/templates/extra.yml"
extra.parent.mkdir()
shutil.copy(FIXTURES / "good/.github/workflows/queue.yml", extra)
env = dict(os.environ, SHELLCHECK="false", EXTRA_FILES=str(extra.relative_to(root)))
result = subprocess.run(["bash", "-c", script], cwd=root, env=env,
capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
# A valid extra file must not hide a broken workflow from discovery.
shutil.copy(FIXTURES / "broken/.github/workflows/broken.yml", root / ".github/workflows")
result = subprocess.run(["bash", "-c", script], cwd=root, env=env,
capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("[job-needs]", result.stdout)


if __name__ == "__main__":
unittest.main(verbosity=2)
4 changes: 4 additions & 0 deletions .github/templates/publish.template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ jobs:

steps:
# ... publish dotnet packages ...
- name: Publish .NET packages
run: echo "::error::Replace this step with your .NET publish steps" && exit 1

publish-npm-packages:
if: needs.release.outputs.publish == 'true'
Expand All @@ -98,4 +100,6 @@ jobs:

steps:
# ... publish npm packages ...
- name: Publish npm packages
run: echo "::error::Replace this step with your npm publish steps" && exit 1

60 changes: 60 additions & 0 deletions .github/workflows/smoke-workflows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
name: Smoke Workflows

on:
pull_request:
paths:
- .github/workflows/**
- .github/actions/**
- .github/templates/**
- .github/actionlint.yaml
- .github/scripts/install-actionlint.sh
- .github/scripts/tests/workflow-lint.test.py
- .github/scripts/tests/fixtures/workflow-lint/**
- .github/scripts/tests/fixtures/discoverability/**

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
lint:
uses: ./.github/workflows/workflow-lint.yml
with:
workflows-ref: ${{ github.sha }}
shellcheck: true
extra-files: .github/templates/publish.template.yml

tests:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install actionlint
run: bash .github/scripts/install-actionlint.sh
- name: Exercise lint failures and smoke coverage
run: python3 -B .github/scripts/tests/workflow-lint.test.py

docker-cleanup:
uses: ./.github/workflows/docker-cleanup-gate.yml

public-runners:
uses: ./.github/workflows/check-public-runners.yml
with:
workflows-ref: ${{ github.sha }}

no-work-records:
uses: ./.github/workflows/verify-no-work-records.yml

discoverability:
uses: ./.github/workflows/verify-discoverability.yml
with:
site-path: .github/scripts/tests/fixtures/discoverability/good
base-url: https://example.cratis.io
fail-on: error
12 changes: 1 addition & 11 deletions .github/workflows/verify-release-notes-program.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,6 @@ jobs:
run: node --test .github/scripts/tests/verify-pull-request-policy.spec.mjs
- name: Install actionlint
# The migration test lints every rendered caller; the runner image has no actionlint.
env:
VERSION: 1.7.12
SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
run: |
set -euo pipefail
archive="$RUNNER_TEMP/actionlint.tar.gz"
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${VERSION}/actionlint_${VERSION}_linux_amd64.tar.gz" -o "$archive"
echo "$SHA256 $archive" | sha256sum --check --strict
mkdir -p "$RUNNER_TEMP/bin"
tar -xzf "$archive" -C "$RUNNER_TEMP/bin" actionlint
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
run: bash .github/scripts/install-actionlint.sh
- name: Exercise atomic policy wrapper migration
run: python3 -B .github/scripts/tests/bootstrap-policy.test.py
67 changes: 67 additions & 0 deletions .github/workflows/workflow-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
name: Workflow Lint

on:
workflow_call:
inputs:
runs-on:
description: Runner label for the lint job; keep it hosted in a public repository.
type: string
default: ubuntu-latest
workflows-ref:
description: Cratis/Workflows ref providing the installer and default configuration.
type: string
default: main
shellcheck:
description: Also check inline shell scripts with shellcheck.
type: boolean
default: false
extra-files:
description: Space-separated additional workflow files, such as templates, to lint.
type: string
default: ""

permissions:
contents: read

jobs:
lint:
runs-on: ${{ inputs.runs-on }}
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Cratis/Workflows
ref: ${{ inputs.workflows-ref }}
path: .cratis-workflows
persist-credentials: false

- name: Install actionlint
run: bash .cratis-workflows/.github/scripts/install-actionlint.sh

- name: Lint workflows and additional files
shell: bash
env:
SHELLCHECK: ${{ inputs.shellcheck }}
EXTRA_FILES: ${{ inputs.extra-files }}
run: |
set -euo pipefail
config=.cratis-workflows/.github/actionlint.yaml
for candidate in .github/actionlint.yaml .github/actionlint.yml; do
if [ -f "$candidate" ]; then
config="$candidate"
break
fi
done
args=(-config-file "$config" -pyflakes=)
if [ "$SHELLCHECK" != true ]; then args+=(-shellcheck=); fi
# Explicit file arguments replace discovery, so keep all workflows alongside extras.
shopt -s nullglob
files=(.github/workflows/*.yml .github/workflows/*.yaml)
read -r -a extra_files <<< "$EXTRA_FILES"
actionlint "${args[@]}" "${files[@]}" "${extra_files[@]}"
Loading
Loading