diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c7aa2e98..265f51e4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -105,6 +105,9 @@ jobs:
echo "All dependencies are up to date"
fi
+ - name: Check for outdated GitHub Actions
+ run: deno run --allow-read --allow-net=api.github.com --allow-env=GITHUB_STEP_SUMMARY --allow-write scripts/audit_actions.ts
+
skill-review:
name: Skill Review
needs: [changes]
diff --git a/deno.json b/deno.json
index 817f1ebc..4527c969 100644
--- a/deno.json
+++ b/deno.json
@@ -12,6 +12,7 @@
"compile": "deno run -A scripts/compile.ts",
"license-headers": "deno run --allow-read --allow-write scripts/add_license_headers.ts",
"audit": "deno run --allow-read --allow-net=api.osv.dev scripts/audit_deps.ts && deno outdated",
+ "audit-actions": "deno run --allow-read --allow-net=api.github.com --allow-env=GITHUB_STEP_SUMMARY --allow-write scripts/audit_actions.ts",
"review-skills": "deno run --allow-read --allow-run --allow-env=GITHUB_STEP_SUMMARY --allow-write scripts/review_skills.ts",
"eval-skill-triggers": "deno run --allow-read --allow-run --allow-env --allow-write scripts/eval_skill_triggers.ts"
},
diff --git a/scripts/audit_actions.ts b/scripts/audit_actions.ts
new file mode 100644
index 00000000..bc939b5f
--- /dev/null
+++ b/scripts/audit_actions.ts
@@ -0,0 +1,365 @@
+// Swamp, an Automation Framework
+// Copyright (C) 2026 System Initiative, Inc.
+//
+// This file is part of Swamp.
+//
+// Swamp is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License version 3
+// as published by the Free Software Foundation, with the Swamp
+// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
+// file).
+//
+// Swamp is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Swamp. If not, see .
+
+/**
+ * GitHub Actions audit script that checks workflow files for outdated actions
+ * and unpinned references using the GitHub API.
+ *
+ * Reports outdated actions as warnings and unpinned third-party actions
+ * (not using a commit SHA) as errors.
+ *
+ * Usage: deno run audit-actions
+ *
+ * Exit codes:
+ * 0 - Always (this is a warning-only check, never fails the build)
+ */
+
+import { parse as parseYaml } from "@std/yaml";
+
+interface ActionRef {
+ file: string;
+ action: string;
+ owner: string;
+ repo: string;
+ ref: string;
+ isShaPin: boolean;
+ isTrustedPublisher: boolean;
+}
+
+interface TagInfo {
+ latest: string;
+ latestSha: string;
+}
+
+interface AuditFinding {
+ actionRef: ActionRef;
+ kind: "unpinned" | "outdated";
+ detail: string;
+}
+
+/** Publishers whose actions are acceptable with tag-only pins. */
+const TRUSTED_PUBLISHERS = new Set([
+ "actions",
+ "anthropics",
+ "denoland",
+ "github",
+]);
+
+const SHA_PATTERN = /^[0-9a-f]{40}$/;
+
+function extractActionRefs(
+ filePath: string,
+ content: string,
+): ActionRef[] {
+ const refs: ActionRef[] = [];
+
+ // deno-lint-ignore no-explicit-any
+ let workflow: any;
+ try {
+ workflow = parseYaml(content);
+ } catch {
+ console.warn(`Warning: could not parse ${filePath}, skipping`);
+ return refs;
+ }
+
+ if (!workflow || typeof workflow !== "object" || !workflow.jobs) return refs;
+
+ for (const job of Object.values(workflow.jobs)) {
+ // deno-lint-ignore no-explicit-any
+ const steps = (job as any)?.steps;
+ if (!Array.isArray(steps)) continue;
+
+ for (const step of steps) {
+ const uses = step?.uses;
+ if (typeof uses !== "string") continue;
+
+ // Skip Docker and local actions
+ if (uses.startsWith("docker://") || uses.startsWith("./")) continue;
+
+ // Parse owner/repo@ref
+ const atIndex = uses.indexOf("@");
+ if (atIndex === -1) continue;
+
+ const actionPath = uses.substring(0, atIndex);
+ const ref = uses.substring(atIndex + 1);
+ const slashIndex = actionPath.indexOf("/");
+ if (slashIndex === -1) continue;
+
+ const owner = actionPath.substring(0, slashIndex);
+ const repo = actionPath.substring(slashIndex + 1);
+
+ refs.push({
+ file: filePath,
+ action: `${owner}/${repo}`,
+ owner,
+ repo,
+ ref,
+ isShaPin: SHA_PATTERN.test(ref),
+ isTrustedPublisher: TRUSTED_PUBLISHERS.has(owner),
+ });
+ }
+ }
+
+ return refs;
+}
+
+async function getLatestTag(
+ owner: string,
+ repo: string,
+): Promise {
+ // Try releases first, fall back to tags
+ try {
+ const response = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}/releases/latest`,
+ {
+ headers: {
+ "Accept": "application/vnd.github+json",
+ "User-Agent": "swamp-audit-actions",
+ },
+ },
+ );
+
+ if (response.ok) {
+ const release = await response.json();
+ const tagName = release.tag_name;
+
+ // Get the SHA for this tag
+ const tagResponse = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}/git/ref/tags/${tagName}`,
+ {
+ headers: {
+ "Accept": "application/vnd.github+json",
+ "User-Agent": "swamp-audit-actions",
+ },
+ },
+ );
+
+ if (tagResponse.ok) {
+ const tagData = await tagResponse.json();
+ return { latest: tagName, latestSha: tagData.object.sha };
+ }
+
+ return { latest: tagName, latestSha: "" };
+ }
+ } catch {
+ // Fall through to tags API
+ }
+
+ // Fallback: list tags sorted by creation
+ try {
+ const response = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}/tags?per_page=1`,
+ {
+ headers: {
+ "Accept": "application/vnd.github+json",
+ "User-Agent": "swamp-audit-actions",
+ },
+ },
+ );
+
+ if (response.ok) {
+ const tags = await response.json();
+ if (tags.length > 0) {
+ return { latest: tags[0].name, latestSha: tags[0].commit.sha };
+ }
+ }
+ } catch {
+ // Couldn't reach API
+ }
+
+ return null;
+}
+
+/** Check if a tag ref (e.g., "v3") is a major version prefix of the latest tag. */
+function isSameMajor(currentRef: string, latestTag: string): boolean {
+ // Strip 'v' prefix for comparison
+ const current = currentRef.replace(/^v/, "");
+ const latest = latestTag.replace(/^v/, "");
+
+ const currentMajor = current.split(".")[0];
+ const latestMajor = latest.split(".")[0];
+
+ return currentMajor === latestMajor;
+}
+
+async function writeGitHubSummary(findings: AuditFinding[]): Promise {
+ const summaryFile = Deno.env.get("GITHUB_STEP_SUMMARY");
+ if (!summaryFile) return;
+
+ const lines: string[] = ["## GitHub Actions Audit Results\n"];
+
+ const unpinned = findings.filter((f) => f.kind === "unpinned");
+ const outdated = findings.filter((f) => f.kind === "outdated");
+
+ if (unpinned.length > 0) {
+ lines.push("### Unpinned Third-Party Actions\n");
+ lines.push(
+ "These actions are not pinned to a commit SHA and could be modified without your knowledge.\n",
+ );
+ for (const { actionRef, detail } of unpinned) {
+ lines.push(
+ `- \`${actionRef.action}@${actionRef.ref}\` in \`${actionRef.file}\`: ${detail}`,
+ );
+ }
+ lines.push("");
+ }
+
+ if (outdated.length > 0) {
+ lines.push("### Outdated Actions\n");
+ lines.push(
+ "> **Warning**: These actions have newer versions available.\n",
+ );
+ for (const { actionRef, detail } of outdated) {
+ lines.push(
+ `- \`${actionRef.action}@${actionRef.ref}\` in \`${actionRef.file}\`: ${detail}`,
+ );
+ }
+ lines.push("");
+ }
+
+ if (findings.length === 0) {
+ lines.push("All GitHub Actions references are up to date and pinned.");
+ }
+
+ await Deno.writeTextFile(summaryFile, lines.join("\n"));
+}
+
+async function main(): Promise {
+ const workflowDir = ".github/workflows";
+ const refs: ActionRef[] = [];
+
+ // Collect all action references from workflow files
+ try {
+ for await (const entry of Deno.readDir(workflowDir)) {
+ if (
+ !entry.isFile ||
+ (!entry.name.endsWith(".yml") && !entry.name.endsWith(".yaml"))
+ ) {
+ continue;
+ }
+
+ const filePath = `${workflowDir}/${entry.name}`;
+ const content = await Deno.readTextFile(filePath);
+ refs.push(...extractActionRefs(filePath, content));
+ }
+ } catch {
+ console.error(`Error: could not read ${workflowDir}`);
+ Deno.exit(1);
+ }
+
+ if (refs.length === 0) {
+ console.log("No action references found in workflow files.");
+ Deno.exit(0);
+ }
+
+ // Deduplicate by action+ref for API calls
+ const uniqueActions = new Map();
+ for (const ref of refs) {
+ const key = `${ref.action}@${ref.ref}`;
+ if (!uniqueActions.has(key)) {
+ uniqueActions.set(key, ref);
+ }
+ }
+
+ console.log(
+ `Checking ${uniqueActions.size} unique action references across ${refs.length} usages…`,
+ );
+
+ const findings: AuditFinding[] = [];
+
+ // Check each unique action
+ for (const [, actionRef] of uniqueActions) {
+ // Check for unpinned third-party actions
+ if (!actionRef.isShaPin && !actionRef.isTrustedPublisher) {
+ findings.push({
+ actionRef,
+ kind: "unpinned",
+ detail:
+ `Third-party action not pinned to a commit SHA. Pin to a full SHA for supply chain security.`,
+ });
+ }
+
+ // Check for outdated versions
+ const tagInfo = await getLatestTag(actionRef.owner, actionRef.repo);
+ if (tagInfo) {
+ if (actionRef.isShaPin) {
+ // SHA-pinned: check if the SHA matches the latest tag's SHA
+ if (
+ tagInfo.latestSha && actionRef.ref !== tagInfo.latestSha
+ ) {
+ findings.push({
+ actionRef,
+ kind: "outdated",
+ detail: `Pinned SHA does not match latest release ${tagInfo.latest}`,
+ });
+ }
+ } else {
+ // Tag-pinned: check if using an older major version
+ if (!isSameMajor(actionRef.ref, tagInfo.latest)) {
+ findings.push({
+ actionRef,
+ kind: "outdated",
+ detail:
+ `Using ${actionRef.ref}, latest is ${tagInfo.latest}`,
+ });
+ }
+ }
+ }
+ }
+
+ // Write GitHub Actions job summary
+ await writeGitHubSummary(findings);
+
+ const unpinned = findings.filter((f) => f.kind === "unpinned");
+ const outdated = findings.filter((f) => f.kind === "outdated");
+
+ if (findings.length === 0) {
+ console.log("All action references are up to date and properly pinned.");
+ Deno.exit(0);
+ }
+
+ if (unpinned.length > 0) {
+ console.warn(
+ `\nUnpinned third-party actions (${unpinned.length}):\n`,
+ );
+ for (const { actionRef, detail } of unpinned) {
+ console.warn(
+ ` ${actionRef.action}@${actionRef.ref} (${actionRef.file})`,
+ );
+ console.warn(` ${detail}`);
+ }
+ }
+
+ if (outdated.length > 0) {
+ const label = outdated.length === 1 ? "action" : "actions";
+ console.warn(
+ `\nOutdated ${label} (${outdated.length}):\n`,
+ );
+ for (const { actionRef, detail } of outdated) {
+ console.warn(
+ ` ${actionRef.action}@${actionRef.ref} (${actionRef.file})`,
+ );
+ console.warn(` ${detail}`);
+ }
+ }
+
+ Deno.exit(0);
+}
+
+main();