collective

Browse / github.com/storytold/craft-common-actions / patch

ci: add CODEOWNERS and a pinned-ref lint

ci: add CODEOWNERS and a pinned-ref lint ## Summary Narrow companion to storytold/wordcraft#1358. Adds the repository-side controls that make the intentional `@main` policy reviewable without changing it. Draft while the owner decides on the ruleset. ## What changed - `.github/CODEOWNERS` for `.github/workflows/`, `actions/` and `scripts/`. The owners are the accounts with commits on `main` (`dylanmtaylor`, `echelon`, `skylartaylor`); adjust as needed. - `scripts/check_pinned_refs.py` mirrors `cargo xtask workflows` in WordCraft: every external `uses:` in workflows and composite actions must be a full 40-character commit SHA or an

Status
open
Score
0.00
Adopters
0 other operators
Author
maxm11 (imported from pull request #19, open)
Base
d6d11df79e136a970415b59433dcdd20559fddbf
Size
+329 −0 in 6 files
Review
pending
Shared
2026-10-28
Id
09f7d6a14c8abaec045fd65d

How it went

No other operator has reported using it yet.

Diff

raw · .github/CODEOWNERS .github/workflows/ci.yml .github/workflows/packaging-lint.yml README.md scripts/check_pinned_refs.py scripts/test_check_pinned_refs.py

diff --git a/.github/CODEOWNERS b/.github/CODEOWNERSnew file mode 100644--- /dev/null+++ b/.github/CODEOWNERS@@ -0,0 +1,7 @@+# Review changes to the shared release pipeline and the checker itself.+#+# This file only takes effect when the repository's branch ruleset enables+# "Require review from Code Owners"; that is an owner setting, not a file in this PR.+/.github/workflows/ @dylanmtaylor @echelon @skylartaylor+/actions/ @dylanmtaylor @echelon @skylartaylor+/scripts/ @dylanmtaylor @echelon @skylartaylordiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.ymlnew file mode 100644--- /dev/null+++ b/.github/workflows/ci.yml@@ -0,0 +1,50 @@+# Lint this repository itself: every external `uses:` must name an immutable ref, and+# actionlint checks the workflow syntax. This is a check, not enforcement: the repository+# ruleset must require it before a change can be blocked.+name: CI++on:+  pull_request:+  push:+    branches: [main]+  workflow_dispatch:++permissions:+  contents: read++concurrency:+  group: ci-${{ github.ref }}+  cancel-in-progress: true++jobs:+  pinned-refs:+    name: Pinned refs+    runs-on: ubuntu-latest+    timeout-minutes: 10+    steps:+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+      - name: Test the checker+        run: python3 -m unittest discover -s scripts -p 'test_*.py' -v+      - name: Check external action refs+        run: python3 scripts/check_pinned_refs.py++  actionlint:+    name: actionlint+    runs-on: ubuntu-latest+    timeout-minutes: 10+    steps:+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+      - name: Install actionlint+        env:+          ACTIONLINT_VERSION: "1.7.12"+          ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8"+        run: |+          curl -fsSL -o "$RUNNER_TEMP/actionlint.tar.gz" \+            "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"+          echo "${ACTIONLINT_SHA256}  $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c -+          tar -xz -C "$RUNNER_TEMP" -f "$RUNNER_TEMP/actionlint.tar.gz" actionlint+          echo "$RUNNER_TEMP" >> "$GITHUB_PATH"+      - name: Install shellcheck+        run: sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck+      - name: actionlint+        run: actionlint -colordiff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml--- a/.github/workflows/packaging-lint.yml+++ b/.github/workflows/packaging-lint.yml@@ -14,6 +14,9 @@ on:         required: false         default: "" +permissions:+  contents: read+ jobs:   lint:     runs-on: ubuntu-latestdiff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -297,6 +297,20 @@ Individual actions are also available for composing custom pipelines:  This repository is hosted at `storytold/craft-common-actions` for all Storytold / ArtCraft craftapps. +## Repository checks++`.github/workflows/ci.yml` runs two checks on pull requests and pushes to `main`:++- **Pinned refs**: every external `uses:` in `.github/workflows/` and `actions/` must name+  a full commit SHA or an immutable Docker `sha256:` digest. The repository's own actions+  and workflows at `@main` are the documented exception (issue #6); they are allowlisted in+  `scripts/check_pinned_refs.py`.+- **actionlint**: validates the workflow syntax and runs shellcheck on the `run:` blocks.++The checks only block a merge when the repository ruleset requires them. Requiring these+checks, code-owner review from `.github/CODEOWNERS`, and no force-pushes or deletions are+repository settings (owner action), not files in this repository.+ ## App-specific packaging checks  Keep app-specific packaging assertions in the caller repository and pass theirdiff --git a/scripts/check_pinned_refs.py b/scripts/check_pinned_refs.pynew file mode 100644--- /dev/null+++ b/scripts/check_pinned_refs.py@@ -0,0 +1,146 @@+#!/usr/bin/env python3+"""Reject mutable external `uses:` refs in this repository's workflows and actions.++GitHub resolves a `uses:` ref when a run starts. A branch or tag can move, so a workflow+that calls one runs code that is not in this repository's history. Every external ref must+name a full 40-character commit SHA or an immutable Docker `sha256:` digest.++The one exception is this repository's own actions and workflows at `@main`: callers are+meant to trail that branch so a fix here reaches every app without a PR in each repository+(see issue #6). The allowlist below keeps the exception explicit.+"""++from __future__ import annotations++import argparse+import string+import sys+from pathlib import Path++ROOT = Path(__file__).resolve().parent.parent+SCAN_DIRS = (".github/workflows", "actions")++# (owner/repo, ref, reason)+ALLOWED = (+    (+        "storytold/craft-common-actions",+        "main",+        "first-party actions and workflows are intentionally tracked at @main (issue #6)",+    ),+)+++def is_hex(value: str) -> bool:+    return bool(value) and all(char in string.hexdigits for char in value)+++def is_immutable(reference: str) -> bool:+    """A full commit SHA or a Docker `sha256:` digest. Tags and branches are mutable."""+    if len(reference) == 40 and is_hex(reference):+        return True+    prefix = "sha256:"+    return (+        reference.startswith(prefix)+        and len(reference) == len(prefix) + 64+        and is_hex(reference[len(prefix):])+    )+++def repo_of(target: str) -> str | None:+    """`owner/repo` from `owner/repo/path`."""+    parts = target.split("/", 2)+    if len(parts) < 2 or not parts[0] or not parts[1]:+        return None+    return f"{parts[0]}/{parts[1]}"+++def uses_value(line: str) -> str | None:+    """The value of a `uses:` line, without a YAML list marker, quotes or a trailing comment."""+    rest = line.lstrip()+    if rest.startswith("-"):+        rest = rest[1:].lstrip()+    if not rest.startswith("uses:"):+        return None+    rest = rest[len("uses:"):].strip()+    if not rest or rest.startswith("#"):+        return None+    token = rest.split()[0]+    return token.strip("'\"")+++def scan_file(root: Path, path: Path, checked: int, violations: list[str], exceptions: list[str]):+    name = path.relative_to(root)+    for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):+        uses = uses_value(line)+        if uses is None:+            continue+        checked += 1+        if uses.startswith("./"):+            continue+        target, separator, reference = uses.rpartition("@")+        if not separator:+            violations.append(f"{name}:{number}: `uses: {uses}` names no `@ref`")+            continue+        if is_immutable(reference):+            continue+        allowed = next(+            (+                reason+                for repo, ref, reason in ALLOWED+                if repo == repo_of(target) and ref == reference+            ),+            None,+        )+        if allowed is not None:+            exceptions.append(f"{name}:{number}: {target}@{reference} ({allowed})")+            continue+        violations.append(+            f"{name}:{number}: `uses: {uses}` is mutable; pin it to a full 40-character "+            "commit SHA (or an immutable Docker digest), or add an allowlist entry with a reason"+        )+    return checked, violations, exceptions+++def find_violations(root: Path) -> tuple[int, list[str], list[str]]:+    checked = 0+    violations: list[str] = []+    exceptions: list[str] = []+    for scan_dir in SCAN_DIRS:+        paths = sorted(+            path+            for path in (root / scan_dir).rglob("*")+            if path.is_file() and path.suffix in {".yml", ".yaml"}+        )+        for path in paths:+            checked, violations, exceptions = scan_file(+                root, path, checked, violations, exceptions+            )+    return checked, violations, exceptions+++def main() -> int:+    parser = argparse.ArgumentParser(description=__doc__)+    parser.add_argument(+        "--root",+        type=Path,+        default=ROOT,+        help="repository root (default: the parent of scripts/)",+    )+    args = parser.parse_args()+    checked, violations, exceptions = find_violations(args.root)+    print(f"pinned refs: checked {checked} `uses:` ref(s)")+    if exceptions:+        print(f"pinned refs: {len(exceptions)} allowlisted mutable ref(s):")+        for exception in exceptions:+            print(f"  - {exception}")+    if violations:+        print(f"error: {len(violations)} mutable `uses:` ref(s):", file=sys.stderr)+        for violation in violations:+            print(f"  {violation}", file=sys.stderr)+        return 1+    print("OK: no unapproved mutable refs.")+    return 0+++if __name__ == "__main__":+    raise SystemExit(main())diff --git a/scripts/test_check_pinned_refs.py b/scripts/test_check_pinned_refs.pynew file mode 100644--- /dev/null+++ b/scripts/test_check_pinned_refs.py@@ -0,0 +1,109 @@+import tempfile+import unittest+from pathlib import Path++from check_pinned_refs import find_violations, is_immutable, repo_of, uses_value++SHA = "0123456789abcdef0123456789abcdef01234567"+++class PinnedRefTests(unittest.TestCase):+    def test_full_commit_sha_is_immutable(self):+        self.assertTrue(is_immutable(SHA))+        self.assertFalse(is_immutable("v4"))+        self.assertFalse(is_immutable("main"))+        self.assertFalse(is_immutable(SHA[:-1]))++    def test_docker_digest_is_immutable(self):+        self.assertTrue(is_immutable("sha256:" + "a" * 64))+        self.assertFalse(is_immutable("sha256:latest"))+        self.assertFalse(is_immutable("sha256:" + "a" * 63))++    def test_repo_of(self):+        self.assertEqual(+            repo_of("storytold/craft-common-actions/actions/foo"),+            "storytold/craft-common-actions",+        )+        self.assertEqual(repo_of("actions/checkout"), "actions/checkout")+        self.assertIsNone(repo_of("actions"))+        self.assertIsNone(repo_of("/actions"))+        self.assertIsNone(repo_of("actions/"))++    def test_uses_value(self):+        self.assertEqual(+            uses_value("      - uses: actions/checkout@v4"), "actions/checkout@v4"+        )+        self.assertEqual(+            uses_value('    uses: "actions/checkout@v4" # v4'), "actions/checkout@v4"+        )+        self.assertEqual(+            uses_value("    uses: 'actions/checkout@v4'"), "actions/checkout@v4"+        )+        self.assertIsNone(uses_value("    uses: # none"))+        self.assertIsNone(uses_value("    # uses: actions/checkout@v4"))+        self.assertIsNone(uses_value("      - name: checkout"))++    def test_mutable_ref_is_a_violation(self):+        with tempfile.TemporaryDirectory() as tmp:+            root = Path(tmp)+            workflows = root / ".github" / "workflows"+            workflows.mkdir(parents=True)+            (workflows / "test.yml").write_text(+                "jobs:\n  x:\n    steps:\n      - uses: actions/checkout@v4\n",+                encoding="utf-8",+            )+            checked, violations, exceptions = find_violations(root)+        self.assertEqual(checked, 1)+        self.assertEqual(len(violations), 1)+        self.assertIn("test.yml:4", violations[0])+        self.assertEqual(exceptions, [])++    def test_first_party_main_is_allowlisted(self):+        with tempfile.TemporaryDirectory() as tmp:+            root = Path(tmp)+            workflows = root / ".github" / "workflows"+            workflows.mkdir(parents=True)+            (workflows / "test.yml").write_text(+                "jobs:\n  x:\n    steps:\n"+                "      - uses: storytold/craft-common-actions/actions/foo@main\n",+                encoding="utf-8",+            )+            checked, violations, exceptions = find_violations(root)+        self.assertEqual(checked, 1)+        self.assertEqual(violations, [])+        self.assertEqual(len(exceptions), 1)++    def test_pinned_and_local_refs_pass(self):+        with tempfile.TemporaryDirectory() as tmp:+            root = Path(tmp)+            workflows = root / ".github" / "workflows"+            workflows.mkdir(parents=True)+            (workflows / "test.yml").write_text(+                "jobs:\n  x:\n    steps:\n"+                f"      - uses: actions/checkout@{SHA}\n"+                "      - uses: ./actions/local\n"+                "      - uses: docker://alpine@sha256:" + "a" * 64 + "\n",+                encoding="utf-8",+            )+            checked, violations, exceptions = find_violations(root)+        self.assertEqual(checked, 3)+        self.assertEqual(violations, [])+        self.assertEqual(exceptions, [])++    def test_action_files_are_scanned(self):+        with tempfile.TemporaryDirectory() as tmp:+            root = Path(tmp)+            actions = root / "actions" / "foo"+            actions.mkdir(parents=True)+            (actions / "action.yml").write_text(+                "runs:\n  using: composite\n  steps:\n"+                "    - uses: actions/checkout@v4\n",+                encoding="utf-8",+            )+            checked, violations, _ = find_violations(root)+        self.assertEqual(checked, 1)+        self.assertEqual(len(violations), 1)+++if __name__ == "__main__":+    unittest.main()