diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1381709..c206084 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,19 +21,19 @@ jobs: # GitHub-hosted, change this line to: runs-on: ubuntu-latest runs-on: [self-hosted, orion] steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # JDK 21: the io.nextflow.nextflow-plugin toolchain builds with a JDK 21 # toolchain (sources still target Java 17). Provide 21 so Gradle uses it # rather than downloading one. - name: Setup JDK 21 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4 + uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4.4.3 - name: Build run: ./gradlew assemble --no-daemon @@ -43,3 +43,14 @@ jobs: # main safety net and were previously never executed in CI. - name: Test run: ./gradlew test --no-daemon + + # The half of pin hygiene that needs the network, so it can't live in the + # (hermetic) Spock suite: does each pin's `# vX.Y.Z` comment actually name + # the tag its SHA carries? A wrong label is worse than none — it makes a + # major-version jump read as a routine same-line bump, which is exactly how + # a checkout v6 -> v7.0.1 bump nearly landed labelled `# v6`. + # + # Read-only (git ls-remote), no auth. Runs on PRs, so a Dependabot bump that + # mislabels a pin is caught before merge rather than after. + - name: Verify pin comments match their SHAs + run: ./scripts/verify-pins.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a94da19..33d4f11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,17 +15,17 @@ jobs: name: Build and publish plugin runs-on: [self-hosted, orion] steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # JDK 21 — see ci.yml (the nextflow-plugin toolchain builds on JDK 21). - name: Setup JDK 21 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4 + uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4.4.3 # Sanity-check the tag matches build.gradle's version, so the artifact and # the release tag never drift. @@ -46,7 +46,7 @@ jobs: # Use the action rather than the gh CLI — the self-hosted runner container # has no gh installed. - name: Publish release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: build/distributions/nf-spawn-*.zip generate_release_notes: true diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 0552a10..d3219ca 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -18,7 +18,7 @@ jobs: name: Secret Scan (gitleaks) runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # full history so leaked-then-removed secrets are caught # Run the gitleaks BINARY directly (MIT-licensed, free for orgs). The @@ -37,7 +37,7 @@ jobs: name: Trivy Security Scan runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Trivy filesystem scan (vulns + secrets) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: @@ -60,7 +60,7 @@ jobs: container: image: semgrep/semgrep steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Enforcing: fails the build on findings. Triaged false-positives are # annotated inline with `# nosemgrep: -- ` (#368). - name: Semgrep scan diff --git a/CHANGELOG.md b/CHANGELOG.md index c1a3aa5..ad9f8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `nextflow-plugin` toolchain, already a release behind. A new `SpawnCiHygieneTest` fails the build if a pin is reverted or an entry dropped. +### Changed +- **Actions bumped, and every pin's version comment now states the truth (#83).** + `actions/checkout` → v7.0.1, `actions/setup-java` → v5.6.0, + `softprops/action-gh-release` → v3.0.2. The bumps arrived from Dependabot + labelled `# v6` on all five `checkout` refs — but the SHA it moved to + (`3d3c42e`) is **v7.0.1**, so a three-major jump read as a routine same-line + bump; `gradle/actions/setup-gradle` had likewise carried a bare `# v4` while + pinning v4.4.3. Comments now name exact versions. Nothing in the majors affects + this repo: `action-gh-release@v3` requires the Node 24 runtime, which the orion + runner (2.336.0) provides and `checkout@v6.0.3` was already using, and + `checkout@v7`'s one behavior change only blocks fork checkouts under + `pull_request_target`/`workflow_run`, neither of which appears here. + ### Fixed +- **A pin's version comment can no longer silently misstate what CI runs (#83).** + `SpawnCiHygieneTest` required only that *some* `# vN` comment be present, so the + mislabelled `checkout` bump above passed it. It now requires an exact `vX.Y.Z`, + and a new `scripts/verify-pins.sh` — wired into CI, so it gates PRs — resolves + each SHA against the tag its comment claims and fails if they disagree. The + network half lives in the script to keep the Spock suite hermetic. Both accept + annotated-tag pins (the tag object *or* the commit it points at); an earlier + draft compared against only one form and called a correct pin mislabelled. - **`./gradlew test` no longer reports success without running the tests when only CI config changed (#80).** Gradle's up-to-date check considered just the Groovy sources, so a commit touching only `.github/` or `build.gradle` would skip diff --git a/scripts/verify-pins.sh b/scripts/verify-pins.sh new file mode 100755 index 0000000..37db8ad --- /dev/null +++ b/scripts/verify-pins.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Verify every action pin's `# vX.Y.Z` comment actually names the tag its SHA +# carries. +# +# This is the half of pin hygiene that needs the network, so it is NOT in the +# Spock suite (which must stay offline and hermetic). SpawnCiHygieneTest enforces +# the offline half: every pin is a 40-hex SHA with an exact vX.Y.Z comment. +# +# Why it exists: a pin's comment can be silently false. Dependabot bumped +# actions/checkout to 3d3c42e — which is v7.0.1 — while leaving `# v6` on all +# five refs, and a stale `# v4` on gradle/actions/setup-gradle (really v4.4.3) +# had sat on main for months. Both read as fine. A wrong label is worse than no +# label: it makes a major-version jump look like a routine same-line bump. +# +# Read-only: git ls-remote only. No auth, no writes, no side effects. +# +# Usage: scripts/verify-pins.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +fail=0 +found=0 + +while IFS= read -r line; do + # file:lineno: ... uses: owner/action@ # vX.Y.Z + loc=${line%%:*} + rest=${line#*:} + lineno=${rest%%:*} + + ref=$(printf '%s\n' "$line" | sed -nE 's/.*uses:[[:space:]]*([^[:space:]]+@[0-9a-f]{40}).*/\1/p') + ver=$(printf '%s\n' "$line" | sed -nE 's/.*@[0-9a-f]{40}[[:space:]]*#[[:space:]]*(v[0-9][^[:space:]]*).*/\1/p') + [ -n "$ref" ] && [ -n "$ver" ] || continue + + action=${ref%@*} + sha=${ref#*@} + # gradle/actions/setup-gradle is a subdirectory action: the repo is the first + # two path segments, so trim anything deeper before querying. + repo=$(printf '%s\n' "$action" | cut -d/ -f1,2) + + found=$((found + 1)) + + # Resolve the claimed tag to BOTH acceptable SHAs and accept either. + # + # For an annotated tag, `refs/tags/vX` is the tag OBJECT and `refs/tags/vX^{}` + # the commit it points at, and the two differ. Both are legitimate pins — + # gradle/actions/setup-gradle@48b5f21 is v4.4.3's tag object, actions/checkout + # pins the commit — and GitHub resolves either. Comparing against just one form + # reports a correct pin as mislabeled (which this script did on its first run, + # absurdly printing "comment says v4.4.3 ... SHA is actually v4.4.3"). + claimed=$(git ls-remote "https://github.com/$repo" \ + "refs/tags/$ver" "refs/tags/$ver^{}" 2>/dev/null | awk '{print $1}') + + if [ -z "$claimed" ]; then + printf ' MISSING TAG %s:%s %s @ %.12s — tag %s does not exist in %s\n' \ + "$loc" "$lineno" "$action" "$sha" "$ver" "$repo" + fail=1 + elif ! printf '%s\n' "$claimed" | grep -qFx "$sha"; then + # Name what the SHA really is, so the fix is obvious rather than a puzzle. + actual=$(git ls-remote --tags "https://github.com/$repo" 2>/dev/null \ + | awk -v s="$sha" '$1==s {gsub("refs/tags/",""); gsub(/\^\{\}$/,"",$2); print $2}' \ + | sort -u | paste -sd, -) + printf ' MISLABELED %s:%s %s @ %.12s — comment says %s (that tag is %.12s); SHA is actually %s\n' \ + "$loc" "$lineno" "$action" "$sha" "$ver" "$(printf '%s' "$claimed" | head -1)" \ + "${actual:-untagged}" + fail=1 + else + printf ' ok %s:%s %s %s\n' "$loc" "$lineno" "$action" "$ver" + fi +done < <(grep -rn 'uses:.*@[0-9a-f]\{40\}' .github/workflows/) + +# Anti-vacuous: a grep that stops matching would otherwise report success forever. +if [ "$found" -eq 0 ]; then + echo "verify-pins: found no SHA-pinned actions under .github/workflows — the check" >&2 + echo "matched nothing, which is a parser problem, not a clean bill of health." >&2 + exit 1 +fi + +if [ "$fail" -ne 0 ]; then + echo + echo "verify-pins: FAIL — a pin's version comment does not match its SHA." >&2 + echo "Fix the comment to name the tag the SHA really carries (or move the SHA)." >&2 + exit 1 +fi + +echo +echo "verify-pins: OK — all $found pins' comments match their SHAs." diff --git a/src/test/groovy/io/nextflow/spawn/SpawnCiHygieneTest.groovy b/src/test/groovy/io/nextflow/spawn/SpawnCiHygieneTest.groovy index 694b0af..2c63e45 100644 --- a/src/test/groovy/io/nextflow/spawn/SpawnCiHygieneTest.groovy +++ b/src/test/groovy/io/nextflow/spawn/SpawnCiHygieneTest.groovy @@ -83,6 +83,35 @@ class SpawnCiHygieneTest extends Specification { '\nUse: uses: owner/action@<40-hex-sha> # vX.Y.Z' } + def 'every version comment names an exact patch version, not a bare major'() { + // The comment is the only human-readable part of a pin, and a bare `# v6` + // is not just vague — it can be actively false, which is worse than absent. + // Dependabot bumped checkout to 3d3c42e (which is v7.0.1) while leaving the + // comment reading `# v6` on all five refs. The pin was correct; the label + // said it was a major version behind what it actually ran, and the previous + // spec passed because it only required SOME `# vN` to be present. A stale + // `# v4` on setup-gradle (really v4.4.3) had sat on main the same way. + // + // Requiring X.Y.Z is the strongest version of this claim checkable offline: + // a bare major can silently keep meaning something new as the pin moves, + // whereas an exact version either matches the SHA or is a visible lie, and + // reviewers can resolve it. Verifying comment-vs-tag needs the network, so + // it stays out of the unit suite — see scripts/verify-pins.sh. + given: + def exact = Pattern.compile(/^[^@\s]+@[0-9a-f]{40}\s+#\s*v\d+\.\d+\.\d+\s*$/) + def refs = usesRefs() + + expect: 'anti-vacuous — if the parser stops matching, this would pass forever' + !refs.isEmpty() + + and: + def vague = refs.findAll { !exact.matcher(it[2] as String).matches() } + assert vague.isEmpty(), 'these pins do not name an exact vX.Y.Z, so the comment ' + + 'cannot be checked against the SHA and may silently misstate what CI runs:\n' + + vague.collect { " ${it[0]}:${it[1]} ${it[2]}" }.join('\n') + + '\nResolve the SHA to its tag and write that: # vX.Y.Z' + } + def 'Dependabot has a github-actions entry whose group patterns cover every action'() { // The other half of pinning. A SHA never moves, including past a security // fix, so pinning without Dependabot trades a mutable-tag hole for a