forked from molecule-ai/molecule-core
Supply-chain hardening for the CI pipeline. 23 workflow files
modified, 59 mutable-tag refs replaced with commit SHAs.
The risk
Every `uses:` reference in .github/workflows/*.yml was pinned to a
mutable tag (e.g., `actions/checkout@v4`). A maintainer of an
action — or a compromised maintainer account — can repoint that
tag to malicious code, and our pipelines silently pull it on the
next run. The tj-actions/changed-files compromise of March 2025 is
the canonical example: maintainer credential leak, attacker
repointed several `@v<N>` tags to a payload that exfiltrated
repository secrets. Repos that pinned to SHAs were unaffected.
The fix
Replace each `@v<N>` with `@<commit-sha> # v<N>`. The trailing
comment preserves human readability ("ah, this is v4"); the SHA
makes the reference immutable.
Actions covered (10 distinct):
actions/{checkout,setup-go,setup-python,setup-node,upload-artifact,github-script}
docker/{login-action,setup-buildx-action,build-push-action}
github/codeql-action/{init,autobuild,analyze}
dorny/paths-filter
imjasonh/setup-crane
pnpm/action-setup (already pinned in molecule-app, listed here for completeness)
Excluded:
Molecule-AI/molecule-ci/.github/workflows/disable-auto-merge-on-push.yml@main
— internal org reusable workflow; we control its repo, threat model
is different from third-party actions. Conventional to pin to @main
rather than SHA for internal reusables.
The maintenance cost
SHA pinning means upstream fixes require manual SHA bumps. Without
automation, pinned SHAs go stale. So this PR also enables Dependabot
across four ecosystems:
- github-actions (workflows)
- gomod (workspace-server)
- npm (canvas)
- pip (workspace runtime requirements)
Weekly cadence — the supply-chain attack window is "minutes between
repoint and pull"; weekly auto-bumps don't help with zero-days
regardless. The point is to pull in non-zero-day fixes without
operator effort.
Aligns with user-stated principle: "long-term, robust, fully-
automated, eliminate human error."
Companion PR: Molecule-AI/molecule-controlplane#308 (same pattern,
smaller surface).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
124 lines
5.4 KiB
YAML
124 lines
5.4 KiB
YAML
name: Check merge_group trigger on required workflows
|
|
|
|
# Pre-merge guard against the deadlock pattern where a workflow whose
|
|
# check is in `required_status_checks` lacks a `merge_group:` trigger.
|
|
# Without it, GitHub merge queue stalls forever in AWAITING_CHECKS
|
|
# because the required check can't fire on `gh-readonly-queue/...` refs.
|
|
#
|
|
# This workflow:
|
|
# 1. Lists required status checks on the branch protection rule for `staging`
|
|
# 2. For each required check, finds the workflow that produces it (by job
|
|
# name match)
|
|
# 3. Fails if any such workflow lacks `merge_group:` in its triggers
|
|
#
|
|
# Reasoning for staging-only: main has its own CI gating model (PR review),
|
|
# but staging is what the merge queue runs on, so it's the trigger that
|
|
# matters.
|
|
|
|
on:
|
|
pull_request:
|
|
paths:
|
|
- '.github/workflows/**.yml'
|
|
- '.github/workflows/**.yaml'
|
|
push:
|
|
branches: [staging, main]
|
|
paths:
|
|
- '.github/workflows/**.yml'
|
|
- '.github/workflows/**.yaml'
|
|
# Self-listen on merge_group so the linter passes its own queue run.
|
|
merge_group:
|
|
types: [checks_requested]
|
|
|
|
jobs:
|
|
check:
|
|
name: Required workflows have merge_group trigger
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
- name: Verify merge_group trigger on required-check workflows
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
REPO: ${{ github.repository }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Branch we care about — the one merge queue runs on.
|
|
BRANCH=staging
|
|
|
|
# Pull the list of required status check contexts. If the branch
|
|
# has no protection or no required checks, exit clean — nothing
|
|
# to lint.
|
|
REQUIRED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" \
|
|
--jq '.contexts[]' 2>/dev/null || true)
|
|
if [ -z "$REQUIRED" ]; then
|
|
echo "No required status checks on ${BRANCH} — nothing to verify."
|
|
exit 0
|
|
fi
|
|
|
|
echo "Required checks on ${BRANCH}:"
|
|
echo "${REQUIRED}" | sed 's/^/ - /'
|
|
echo
|
|
|
|
# Build a map: workflow file -> set of job names declared in it.
|
|
# We use yq if available, otherwise grep the `name:` lines under
|
|
# `jobs:`. Stick with grep for portability — runner image always
|
|
# has it; yq isn't in the default image as of 2026-04.
|
|
declare -A workflow_jobs
|
|
shopt -s nullglob
|
|
for wf in .github/workflows/*.yml .github/workflows/*.yaml; do
|
|
[ -f "$wf" ] || continue
|
|
# Extract the workflow name (the `name:` at file root).
|
|
wf_name=$(awk '/^name:[[:space:]]/ {sub(/^name:[[:space:]]+/,""); gsub(/^"|"$/,""); print; exit}' "$wf")
|
|
# Extract job step names from the `jobs:` block. A job step is:
|
|
# - id under `jobs:` (key with 2-space indent followed by colon)
|
|
# - the `name:` field inside that job (4-space indent)
|
|
# We collect both because required_status_checks contexts can
|
|
# match either, depending on how the workflow was authored.
|
|
jobs_block=$(awk '/^jobs:/{flag=1; next} flag' "$wf")
|
|
job_names=$(echo "$jobs_block" | awk '/^[[:space:]]{4}name:[[:space:]]/ {sub(/^[[:space:]]+name:[[:space:]]+/,""); gsub(/^["'"'"']|["'"'"']$/,""); print}')
|
|
workflow_jobs["$wf"]="${wf_name}"$'\n'"${job_names}"
|
|
done
|
|
|
|
# For each required check, find the workflow that produces it.
|
|
# Then verify that workflow lists merge_group as a trigger.
|
|
FAILED=0
|
|
while IFS= read -r check; do
|
|
[ -z "$check" ] && continue
|
|
owning_wf=""
|
|
for wf in "${!workflow_jobs[@]}"; do
|
|
if echo "${workflow_jobs[$wf]}" | grep -Fxq "$check"; then
|
|
owning_wf="$wf"
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [ -z "$owning_wf" ]; then
|
|
echo "::warning::Required check '${check}' has no matching workflow in this repo. Skipping (may be from an external app)."
|
|
continue
|
|
fi
|
|
|
|
# Does the workflow's trigger list include merge_group?
|
|
# Match either bare `merge_group:` line or merge_group with
|
|
# subsequent indented config (types: [checks_requested]).
|
|
if grep -qE '^[[:space:]]*merge_group:' "$owning_wf"; then
|
|
echo "OK: '${check}' (in $owning_wf) — has merge_group trigger"
|
|
else
|
|
echo "::error file=${owning_wf}::Required check '${check}' is produced by ${owning_wf}, but the workflow does not declare a 'merge_group:' trigger. With merge queue enabled on ${BRANCH}, this will deadlock the queue (every PR sits AWAITING_CHECKS forever). Add this to the workflow's 'on:' block:"
|
|
echo "::error file=${owning_wf}:: merge_group:"
|
|
echo "::error file=${owning_wf}:: types: [checks_requested]"
|
|
FAILED=1
|
|
fi
|
|
done <<< "$REQUIRED"
|
|
|
|
if [ "$FAILED" -ne 0 ]; then
|
|
echo
|
|
echo "::error::Block. See errors above. Reference: $(grep -l 'reference_merge_queue' /dev/null 2>/dev/null || echo 'memory: reference_merge_queue_enablement.md')."
|
|
exit 1
|
|
fi
|
|
|
|
echo
|
|
echo "All required workflows on ${BRANCH} declare merge_group triggers."
|