Skip to content

Pin GitHub Actions to full-length commit SHAs - #396

Open
Dan Fiedler (danfiedler-msft) wants to merge 1 commit into
microsoft:mainfrom
danfiedler-msft:danfiedler/pin-actions
Open

Dan Fiedler (danfiedler-msft) wants to merge 1 commit into
microsoft:mainfrom
danfiedler-msft:danfiedler/pin-actions

Conversation

@danfiedler-msft

Copy link
Copy Markdown

Summary

This PR pins GitHub Actions to full-length commit SHAs for improved security and reproducibility and adds a 7 day cooldown to Dependabot configuration for GitHub Actions. This work is described in more detail at https://aka.ms/action-pinning.

Why?

Pinning actions to commit SHAs prevents supply-chain attacks where a tag could be moved to point to malicious code. This is a recommended security best practice per the GitHub Actions security hardening guide.

This change mitigates the risk of tag retargeting to malicious code as seen in incidents like the tj-actions/changed-files action compromise or codfish/semantic-release-action compromise and improves the integrity and reproducibility of the CI/CD pipeline.

What changed?

Action pinning: Third-party action references in .github/workflows/ that used mutable tag-based references (e.g., actions/checkout@v4) have been updated to full-length commit SHAs with a version comment (e.g., actions/checkout@<sha> # v4) using the pinact tool. References that were already pinned to a SHA, or that used immutable release tags, were left unchanged.

Dependabot configuration: .github/dependabot.yml has been updated to ensure a github-actions package-ecosystem section is present with a cooldown configuration (default-days: 7). If the file did not exist, it was created. If a github-actions section already existed, only the cooldown block was added or its default-days value was increased to 7 if it was lower. The 7-day cooldown provides a window for the community to detect and report compromised releases before they are automatically proposed as updates, reducing exposure to supply-chain attacks via newly published malicious versions.

Is this safe to merge?

Yes. The pinned SHAs correspond to the same commits that the existing tags pointed to. No behavioral changes in action execution are introduced. You can verify the pinned SHA value using the GitHub REST API (e.g., the commit hash for actions/checkout@v7 can be found in the sha property in the JSON response for GET https://api.github.com/repos/actions/checkout/commits/v7).

Additional Information

For more information, please see https://aka.ms/action-pinning


- name: Run Ruff Linter
uses: astral-sh/ruff-action@v3
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ruff action v4+ is using immutable tags and would be safe. (granted may as well be consistent in the dependency definition, I just thought I'd mention it)

@karianna

Copy link
Copy Markdown
Member

Not a repo I'm a SME in, so I won't have Copilot apply the fix but heere's the analysis of failing tests:

The job fails in stubtest for two separate reasons:

  1. skimage.feature.Cascade is now marked as a disjoint_base at runtime.
  2. The unpinned SymPy dependency has drifted beyond the version covered by the checked-in stubs.

1. Fix skimage.feature.Cascade

The runtime error is:

skimage.feature.Cascade is a disjoint base at runtime, but isn't marked with @disjoint_base in the stub

Update stubs/skimage/feature/_cascade.pyi:

-from typing import Any, ClassVar
+from typing import Any, ClassVar
+from typing_extensions import disjoint_base

 __reduce_cython__: Any
 __setstate_cython__: Any
 __test__: dict

+@disjoint_base
 class Cascade:

This single class is re-exported by stubs/skimage/feature/__init__.pyi, so no separate decorator should be added to the alias there.

2. Stabilize the SymPy test dependency

The SymPy failures are not isolated typing mistakes. The runtime and stub APIs are substantially different:

  • runtime properties such as is_real, is_zero, and is_commutative are read-only properties;
  • the stubs expose them as variables;
  • Basic.subs has explicit arg1 and arg2 parameters at runtime, while the stub still declares *args;
  • several runtime values are concrete bools while the stubs use ...;
  • stubtest also crashes with IndexError while processing an outdated overload declaration.

The dependency is currently unpinned in pyproject.toml:

"sympy",

Pin SymPy to the version targeted by the current stubs/sympy-stubs implementation, for example:

"sympy == <supported-version>",

The supported version should be the version used when these stubs last passed stubtest; determine it from the project’s last known-good dependency set rather than accepting the latest release implicitly. The same version must be used locally and in CI.

As a temporary containment measure, the SymPy module can be added to the relevant SymPy stubtest allowlist:

sympy.sets.handlers.comparison

However, that only hides the incompatibility and should not replace either updating the SymPy stubs or pinning the runtime dependency.

Validation

After the changes, run:

python -m pip install --group tests
python tests/run_stubtest.py

The Node 20 deprecation message is unrelated to the failure. The actionable CI failures are the missing @disjoint_base annotation and the unbounded SymPy dependency.

@karianna

Copy link
Copy Markdown
Member

Avasam (@Avasam) are you able to review the failures?

@Avasam

Avasam (Avasam) commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

For hygiene (Ruff), just remove the # noqa: F811 at stubs/matplotlib/pyplot.pyi:782


For the pyright failure on Python 3.13, either:

  • Remove from numpy.core.numeric import ComplexWarning as ComplexWarning in stubs/sklearn/utils/validation.pyi:16. It looks like an accidental re-export anyway. The symbol is also already explicitly re-exposed from stubs/sklearn/utils/fixes.pyi (in fact, all re-exports here are probably just from codegen, as none match https://scikit-learn.org/stable/api/sklearn.utils.html )
  • Or, in stubs/sklearn/utils/validation.pyi:16, change it for from numpy.exceptions import ComplexWarning as ComplexWarning (would recommend just removing extraneous re-exports entirely for this module)

Created #397 for both above

Stubtest requires actually updating out of date stubs (that's also why it's the last test, to not block anything else, if it's the last thing not passing, I don't see it blocking this PR)

In the above PR I ended up simply updating the running TODO list.

@Avasam

Avasam (Avasam) commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Additionally, for sympy, consider just dropping the stubs in favor of upstream. It's so far out of sync now (with ton of incorrectness anyway), it's only hurting users: sympy/sympy#29747 . And upstream have put (and are still going) into actually typing sympy first-party.

@bschnurr

Bill Schnurr (bschnurr) commented Sep 18, 2026

Copy link
Copy Markdown
Member

🔒 Automated review in progress — Bill Schnurr (@bschnurr) is auto-reviewing this PR.

@bschnurr

Copy link
Copy Markdown
Member

Result: ⚠️ partially-verified

Verification details

Verification: Isolated verification observed failures that were not classified as caused by this PR: Environment and test discovery.

Summary: A targeted validator parsed both YAML files and confirmed the Dependabot settings plus all six full-length SHA pins. No tests were added by the PR, and repository tests do not target workflow configuration. Git metadata and online SHA/action execution were unavailable, so verification is partial.

Test runs: 1 passed, 1 failed

  • Failed | unrelated to this PR | Environment and test discovery | printf 'sandbox=%s\n' "${AUTOMATION_SANDBOX_PROFILE:-unset}"; git diff --name-status HEAD^ HEAD; printf '%s\n' '--- manifests and test/config files ---'; find . -maxdepth 3 -type f ( -name 'pyproject.toml' -o -name 'uv.lock' -o -name 'requirements.txt' -o -name 'package.json' -o -path './tests/' -o -path './.github/' ) -print | sort; printf '%s\n' '--- available tools ---'; command -v python || true; command -v ruby || true; command -v actionlint || true; command -v pinact || true; command -v yamllint || true; git status --short
  • Passed | Workflow pin and Dependabot configuration validation | python - <<'PY'
    from pathlib import Path
    import re
    import yaml

workflow_path = Path('.github/workflows/test.yml')
dependabot_path = Path('.github/dependabot.yml')
workflow_text = workflow_path.read_text()
dependabot_text = dependabot_path.read_text()
workflow = yaml.safe_load(workflow_text)
dependabot = yaml.safe_load(dependabot_text)
assert isinstance(workflow, dict) and 'jobs' in workflow
assert isinstance(dependabot, dict) and dependabot['version'] == 2
updates = dependabot['updates']
assert len(updates) == 1
entry = updates[0]
assert entry['package-ecosystem'] == 'github-actions'
assert entry['directory'] == '/'
assert entry['schedule']['interval'] == 'weekly'
assert entry['groups']['github-actions']['patterns'] == ['']
assert entry['cooldown']['default-days'] == 7
uses = re.findall(r'^\s
-?\suses:\s([^\s#]+)(?:\s+#\s*(\S+))?\s*$', workflow_text, re.M)
assert uses, 'no action references found'
expected = {
'actions/checkout': ('0717577d45739eb3c851188b29f50ed6c0b2194e', 'v2.8.0', 2),
'actions/setup-python': ('e9aba2c848f5ebd159c070c61ea2c4e2b122355e', 'v2.3.4', 1),
'actions/cache': ('0057852bfaa89a56745cba8c7296529d2fc39830', 'v4.3.0', 1),
'jakebailey/pyright-action': ('6cabc0f01c4994be48fd45cd9dbacdd6e1ee6e5e', 'v2.3.3', 1),
'astral-sh/ruff-action': ('4919ec5cf1f49eff0871dbcea0da843445b837e6', 'v3.6.1', 1),
}
seen = {name: 0 for name in expected}
for reference, comment in uses:
name, revision = reference.rsplit('@', 1)
assert re.fullmatch(r'[0-9a-f]{40}', revision), f'unpinned action: {reference}'
assert name in expected, f'unexpected action: {name}'
sha, version, _ = expected[name]
assert (revision, comment) == (sha, version), (reference, comment)
seen[name] += 1
assert seen == {name: count for name, (*_, count) in expected.items()}, seen
print(f'Parsed both YAML files; validated Dependabot cooldown/group/schedule and {len(uses)} SHA-pinned action references.')
PY

Environment and test discovery diagnostic output
sandbox=generic
error: Could not access 'HEAD^'
Found both changed configuration files, pyproject.toml, and repository tests.
fatal: not a git repository (or any parent up to mount point /)

@bschnurr Bill Schnurr (bschnurr) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved via Review Center.

@bschnurr Bill Schnurr (bschnurr) added the review-auto:approved Automated review: no blocking findings (approval posted). label Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review-auto:approved Automated review: no blocking findings (approval posted).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants