diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000..f4d310d --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,36 @@ +name: actionlint (workflow-templates + l9-ci-pack) +on: + pull_request: + push: + branches: + - main + workflow_dispatch: +permissions: + contents: read +concurrency: + group: actionlint-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + actionlint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Download actionlint (pinned + checksum-verified) + run: | + set -euo pipefail + curl -sSf -o /tmp/download-actionlint.bash \ + "https://raw.githubusercontent.com/rhysd/actionlint/914e7df21a07ef503a81201c76d2b11c789d3fca/scripts/download-actionlint.bash" + echo "72fa3e45ac20f3c3a512d6747b4fcf719e21f890e8c43e78d48a41fdfb900c4e /tmp/download-actionlint.bash" | sha256sum -c - + bash /tmp/download-actionlint.bash 1.7.12 + - name: Lint workflow-templates/ and l9-ci-pack/workflows/ + run: | + set -euo pipefail + shopt -s nullglob + files=(workflow-templates/*.yml l9-ci-pack/workflows/*.yml) + if [ "${#files[@]}" -eq 0 ]; then + echo "No workflow files found to lint." >&2 + exit 1 + fi + ./actionlint -color "${files[@]}" diff --git a/.github/workflows/properties-json-schema.yml b/.github/workflows/properties-json-schema.yml new file mode 100644 index 0000000..4cc86b5 --- /dev/null +++ b/.github/workflows/properties-json-schema.yml @@ -0,0 +1,28 @@ +name: properties.json schema validation +on: + pull_request: + push: + branches: + - main + workflow_dispatch: +permissions: + contents: read +concurrency: + group: properties-json-schema-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + - name: Install jsonschema + run: | + pip install --quiet --only-binary :all: "jsonschema==4.26.0" + - name: Validate workflow-templates/*.properties.json + run: python3 ops/validate-properties-json.py diff --git a/.github/workflows/sha-pin-audit.yml b/.github/workflows/sha-pin-audit.yml new file mode 100644 index 0000000..33962fe --- /dev/null +++ b/.github/workflows/sha-pin-audit.yml @@ -0,0 +1,23 @@ +name: SHA-pin audit (repo-wide) +on: + pull_request: + push: + branches: + - main + workflow_dispatch: +permissions: + contents: read +concurrency: + group: sha-pin-audit-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Run ops/audit-sha-pins.sh + run: | + chmod +x ops/audit-sha-pins.sh + ops/audit-sha-pins.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be1fd66..7267777 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,37 @@ All pull requests must pass: --- +## This Repo's Own CI + +`Quantum-L9/.github` validates itself on every PR/push to `main` — note that +none of the 12 files under `workflow-templates/` ever run as CI *in this +repo*; they only appear as starter-workflow choices in other repos' Actions +tab. What actually executes here: + +- **`validate-starters.sh`** — workflow-templates + `l9-ci-pack/` completeness + and `@main`-ref check (existing). +- **`actionlint`** — lints every file in `workflow-templates/` and + `l9-ci-pack/workflows/` for YAML/expression/shellcheck errors + ([`.github/workflows/actionlint.yml`](.github/workflows/actionlint.yml)). +- **`SHA-pin audit`** — repo-wide: every `uses:` ref in `workflow-templates/`, + `l9-ci-pack/workflows/`, and `.github/workflows/` must be pinned by full + 40-char commit SHA, except the documented frozen `Quantum-L9/l9-ci-core` + tags (`@v1` legacy, `@v2`/`@v2.0.0` current) + ([`ops/audit-sha-pins.sh`](ops/audit-sha-pins.sh)). +- **`properties.json schema validation`** — every + `workflow-templates/*.properties.json` against + [`ops/schemas/workflow-template-properties.schema.json`](ops/schemas/workflow-template-properties.schema.json). + This is a structural schema only — `categories` is deliberately + unconstrained by an enum. GitHub's real category vocabulary is an open + union (11 fixed buckets + any [linguist](https://github.com/github/linguist/blob/main/lib/linguist/languages.yml) + language + tech-stack names) with no single closed list; the community + SchemaStore schema for this file type encodes only the linguist-language + list and would false-flag legitimate buckets like `Automation` or + `continuous-integration`. +- **SonarCloud** — external GitHub App, not a workflow file, always runs. + +--- + ## Kernel Authoring (l9-ci-core contributors only) - Kernels must use `on: workflow_call` only diff --git a/ops/audit-sha-pins.sh b/ops/audit-sha-pins.sh new file mode 100755 index 0000000..019bfc2 --- /dev/null +++ b/ops/audit-sha-pins.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# ops/audit-sha-pins.sh +# +# Repo-wide SHA-pin audit. Every `uses:` reference in every workflow YAML file +# in this repo (workflow-templates/, l9-ci-pack/workflows/, .github/workflows/) +# must be pinned by a full 40-character commit SHA, EXCEPT the documented, +# intentionally-frozen Quantum-L9/l9-ci-core major-version tags (@v1 = frozen +# legacy kernel set; @v2 / @v2.0.0 = current major once published). `@main`, +# `@master`, `@latest`, or any other floating tag is a failure. +# +# This generalizes the l9-ci-pack-only pin check already in +# ops/validate-starters.sh to every workflow file in the repo, including the +# workflow-templates/ gallery (which validate-starters.sh does not scan for +# pins beyond the @main check). +set -euo pipefail + +FAILED=0 +CHECKED=0 + +# Directories that can contain `uses:` lines. +SEARCH_DIRS=("workflow-templates" "l9-ci-pack/workflows" ".github/workflows") + +is_sha() { + local ref="$1" + [[ "$ref" =~ ^[0-9a-f]{40}$ ]] +} + +is_frozen_core_tag() { + local repo="$1" ref="$2" + [[ "$repo" == "Quantum-L9/l9-ci-core"* ]] && [[ "$ref" =~ ^v(1|2|2\.0\.0)$ ]] +} + +for dir in "${SEARCH_DIRS[@]}"; do + [[ -d "$dir" ]] || continue + while IFS= read -r -d '' f; do + line_no=0 + while IFS= read -r line; do + line_no=$((line_no + 1)) + # Strip leading whitespace and an optional YAML list-item dash + # (" - uses: ..." is the common steps: syntax; "uses: ..." is the + # job-level reusable-workflow-call syntax). + trimmed="$(echo "$line" | sed -E 's/^[[:space:]]*(-[[:space:]]+)?//')" + # Skip full-line comments; only inspect live `uses:` keys. + [[ "$trimmed" == \#* ]] && continue + [[ "$trimmed" =~ ^uses:[[:space:]]*(.+)$ ]] || continue + spec="${BASH_REMATCH[1]}" + # Strip a trailing inline comment (e.g. "... # v4.2.2"). + spec="$(echo "$spec" | sed -E 's/[[:space:]]+#.*$//')" + # Local/relative actions (./.github/actions/...) have no ref to pin. + [[ "$spec" == .* ]] && continue + [[ "$spec" == *"@"* ]] || continue + ref="${spec##*@}" + repo="${spec%@*}" + CHECKED=$((CHECKED + 1)) + if is_sha "$ref"; then + continue + fi + if is_frozen_core_tag "$repo" "$ref"; then + continue + fi + echo "FAIL: ${f}:${line_no}: floating ref '${ref}' on '${repo}' — pin by full 40-char commit SHA" + FAILED=$((FAILED + 1)) + done < "$f" + done < <(find "$dir" -type f \( -name "*.yml" -o -name "*.yaml" \) -print0) +done + +echo "---" +echo "Checked ${CHECKED} uses: references." +if [[ "$FAILED" -gt 0 ]]; then + echo "${FAILED} floating (non-SHA, non-frozen-tag) reference(s) found." + exit 1 +fi +echo "All references pinned by SHA or a documented frozen Core tag (@v1/@v2/@v2.0.0)." diff --git a/ops/schemas/workflow-template-properties.schema.json b/ops/schemas/workflow-template-properties.schema.json new file mode 100644 index 0000000..ed8fe0c --- /dev/null +++ b/ops/schemas/workflow-template-properties.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$comment": "Structural schema for GitHub Actions workflow-template *.properties.json files (https://docs.github.com/en/actions/using-workflows/creating-starter-workflows-for-your-organization). categories.items is intentionally type:string with NO enum: GitHub accepts an open union of 11 fixed category buckets (continuous-integration, deployment, testing, code-quality, code-review, dependency-management, monitoring, Automation, utilities, Pages, Hugo) PLUS any github/linguist language name PLUS any known tech-stack name -- there is no single published closed list. A strict enum here would false-flag legitimate values (the SchemaStore community schema for this file type only encodes the linguist-language list and omits the 11 fixed buckets entirely, which is exactly this trap). This schema enforces structure/type-correctness only.", + "type": "object", + "additionalProperties": false, + "required": ["name", "description"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "iconName": { "type": "string", "minLength": 1 }, + "creator": { "type": "string", "minLength": 1 }, + "categories": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "filePatterns": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "labels": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/ops/validate-properties-json.py b/ops/validate-properties-json.py new file mode 100644 index 0000000..07997e5 --- /dev/null +++ b/ops/validate-properties-json.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Validate every workflow-templates/*.properties.json against +ops/schemas/workflow-template-properties.schema.json. + +See that schema file's $comment for why `categories` is intentionally +type:string with no enum (GitHub's real category vocabulary is an open +union of 11 fixed buckets + linguist languages + tech stacks; there is no +single closed list to validate against, and the community SchemaStore +schema for this exact file type gets this wrong by encoding only the +linguist-language list). +""" +import json +import sys +from pathlib import Path + +try: + import jsonschema +except ImportError: + print("jsonschema package not available; install with: pip install jsonschema", file=sys.stderr) + sys.exit(1) + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCHEMA_PATH = REPO_ROOT / "ops" / "schemas" / "workflow-template-properties.schema.json" + + +def main() -> int: + schema = json.loads(SCHEMA_PATH.read_text()) + files = sorted((REPO_ROOT / "workflow-templates").glob("*.properties.json")) + if not files: + print("No workflow-templates/*.properties.json files found.", file=sys.stderr) + return 1 + + failed = 0 + for path in files: + rel = path.relative_to(REPO_ROOT) + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + print(f"FAIL {rel}: invalid JSON: {exc}") + failed += 1 + continue + try: + jsonschema.validate(data, schema) + except jsonschema.ValidationError as exc: + print(f"FAIL {rel}: {exc.message} (at {'/'.join(str(p) for p in exc.absolute_path) or ''})") + failed += 1 + continue + print(f"OK {rel}") + + print("---") + print(f"Checked {len(files)} properties.json file(s).") + if failed: + print(f"{failed} file(s) failed schema validation.") + return 1 + print("All files valid.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())