You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/04/17 21:17:07 UTC
(groovy) branch master updated: add a github action (backed by a gradle task) to fail when proposed actions aren't on the ASF approved allow list
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new d7e2ca1419 add a github action (backed by a gradle task) to fail when proposed actions aren't on the ASF approved allow list
d7e2ca1419 is described below
commit d7e2ca1419fa99d800846229a56d7c28734bdfa7
Author: Paul King <pa...@asert.com.au>
AuthorDate: Sat Apr 18 07:16:59 2026 +1000
add a github action (backed by a gradle task) to fail when proposed actions aren't on the ASF approved allow list
---
.github/workflows/validate-actions.yml | 48 +++++
.../groovy/gradle/ValidateActionsTask.groovy | 219 +++++++++++++++++++++
build.gradle | 8 +
3 files changed, 275 insertions(+)
diff --git a/.github/workflows/validate-actions.yml b/.github/workflows/validate-actions.yml
new file mode 100644
index 0000000000..cf264375da
--- /dev/null
+++ b/.github/workflows/validate-actions.yml
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: Validate GitHub Actions
+
+on:
+ pull_request:
+ paths:
+ - '.github/workflows/**'
+ - 'build-logic/src/main/groovy/org/apache/groovy/gradle/ValidateActionsTask.groovy'
+ push:
+ branches: [master]
+ paths:
+ - '.github/workflows/**'
+ - 'build-logic/src/main/groovy/org/apache/groovy/gradle/ValidateActionsTask.groovy'
+ schedule:
+ - cron: '0 6 * * 1' # weekly Monday 06:00 UTC — catches ASF delisting a pinned SHA
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-java@v5
+ with:
+ distribution: 'zulu'
+ java-version: 21
+ check-latest: true
+ - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
+ - name: Validate Actions
+ run: ./gradlew validateActions --no-daemon
+ timeout-minutes: 5
diff --git a/build-logic/src/main/groovy/org/apache/groovy/gradle/ValidateActionsTask.groovy b/build-logic/src/main/groovy/org/apache/groovy/gradle/ValidateActionsTask.groovy
new file mode 100644
index 0000000000..8dc1b3e20e
--- /dev/null
+++ b/build-logic/src/main/groovy/org/apache/groovy/gradle/ValidateActionsTask.groovy
@@ -0,0 +1,219 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.gradle
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.TaskAction
+
+import java.nio.file.Path
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+/**
+ * Validates that every `uses:` entry in the project's GitHub Actions workflow files
+ * complies with the ASF approved-actions policy
+ * (https://github.com/apache/infrastructure-actions).
+ *
+ * Policy:
+ * - Actions under the `actions/*`, `github/*`, or `apache/*` namespaces are auto-approved.
+ * - Any other action must either match an `@*` (any-version) allowlist entry or be pinned
+ * to a full-SHA commit that appears in `approved_patterns.yml`.
+ *
+ * Violations are reported as ERRORs and fail the build. A trailing `# vX.Y.Z` comment
+ * that disagrees with the tag ASF recorded for the pinned SHA gives a WARNING.
+ *
+ * Note: the code for this task would be simpler using groovy-yaml or similar,
+ * but our YAML parsing requirements are minimal,
+ * and we wanted to avoid extra dependencies in build-logic.
+ */
+@CompileStatic
+class ValidateActionsTask extends DefaultTask {
+
+ private static final Pattern USES_LINE = Pattern.compile(
+ /^\s*(?:-\s*)?uses:\s*["']?([^"'\s#]+)["']?\s*(?:#\s*(.*?))?\s*$/)
+ private static final Pattern SHA40 = Pattern.compile('[0-9a-f]{40}')
+
+ private static final Set<String> AUTO_APPROVED_OWNERS = ['actions', 'github', 'apache'] as Set
+
+ private static final String ALLOWLIST_URL =
+ 'https://raw.githubusercontent.com/apache/infrastructure-actions/main/approved_patterns.yml'
+ private static final String ACTIONS_URL =
+ 'https://raw.githubusercontent.com/apache/infrastructure-actions/main/actions.yml'
+
+ @InputFiles
+ final ConfigurableFileCollection workflowFiles = project.files()
+
+ @Internal
+ String description = 'Validates that every uses: entry in GitHub workflow files is on the ASF approved list'
+
+ @TaskAction
+ void validate() {
+ Map<String, Set<String>> allowed = parseAllowlist(fetch(ALLOWLIST_URL))
+ Map<String, Map<String, String>> tagsBySha = parseActions(fetch(ACTIONS_URL))
+
+ List<String> errors = []
+ List<String> warnings = []
+ Path rootPath = project.rootDir.toPath()
+
+ workflowFiles.files.toSorted().each { File file ->
+ String rel = rootPath.relativize(file.toPath()).toString()
+ file.readLines('UTF-8').eachWithIndex { String line, int idx ->
+ Matcher m = USES_LINE.matcher(line)
+ if (!m.matches()) return
+ checkEntry(rel, idx + 1, m.group(1), m.group(2), allowed, tagsBySha, errors, warnings)
+ }
+ }
+
+ warnings.each { logger.warn("[validateActions] WARN {}", it) }
+ if (errors) {
+ errors.each { logger.error("[validateActions] FAIL {}", it) }
+ throw new GradleException("${errors.size()} GitHub Actions policy violation(s); see log.")
+ }
+ logger.lifecycle("[validateActions] Checked {} workflow file(s) \u2014 all compliant.", workflowFiles.files.size())
+ }
+
+ private static void checkEntry(String file, int line, String ref, String comment,
+ Map<String, Set<String>> allowed,
+ Map<String, Map<String, String>> tagsBySha,
+ List<String> errors, List<String> warnings) {
+ // Local composite actions (./..., ../...) are implicitly trusted
+ if (ref.startsWith('./') || ref.startsWith('../')) return
+
+ int at = ref.indexOf('@')
+ if (at < 0) {
+ if (ref.startsWith('docker://')) return
+ errors << "$file:$line: unrecognised form '$ref' (missing @ref)".toString()
+ return
+ }
+ String identifier = ref.substring(0, at)
+ String refPart = ref.substring(at + 1)
+
+ String owner = identifier.contains('/') ? identifier.split('/', 2)[0] : identifier
+ if (AUTO_APPROVED_OWNERS.contains(owner)) return
+
+ Set<String> shas = allowed.get(identifier)
+ if (shas == null) {
+ errors << "$file:$line: '$identifier' is not on the ASF approved list".toString()
+ return
+ }
+ if (shas.contains('*')) return
+
+ if (!SHA40.matcher(refPart).matches()) {
+ errors << ("$file:$line: '$ref' must be pinned to a full SHA (allowlist has " +
+ "${shas.size()} approved SHA(s) for '$identifier')").toString()
+ return
+ }
+ if (!shas.contains(refPart)) {
+ errors << ("$file:$line: SHA for '$identifier' is not among the " +
+ "${shas.size()} approved SHA(s) in the allowlist").toString()
+ return
+ }
+ // All hard checks passed. Comment handling is WARN-only.
+ String expectedTag = tagsBySha.get(identifier)?.get(refPart)
+ if (expectedTag == null) return
+ if (comment == null || comment.trim().isEmpty()) {
+ warnings << "$file:$line: missing trailing '# $expectedTag' comment for SHA-pinned action".toString()
+ return
+ }
+ String commentTag = comment.trim().split(/\s+/)[0]
+ if (commentTag != expectedTag) {
+ warnings << ("$file:$line: trailing comment '# $commentTag' does not match the " +
+ "ASF-recorded tag '# $expectedTag' for this SHA").toString()
+ }
+ }
+
+ private static String fetch(String url) {
+ try {
+ URLConnection conn = new URL(url).openConnection()
+ conn.connectTimeout = 10_000
+ conn.readTimeout = 30_000
+ return conn.inputStream.withReader('UTF-8') { Reader r -> r.text }
+ } catch (Exception e) {
+ throw new GradleException("Failed to fetch $url: ${e.message}", e)
+ }
+ }
+
+ /** Parses approved_patterns.yml: a YAML list of `owner/repo[/subpath]@ref-or-star` strings. */
+ private static Map<String, Set<String>> parseAllowlist(String yaml) {
+ Map<String, Set<String>> out = [:]
+ yaml.eachLine { String raw ->
+ String line = raw.trim()
+ if (!line.startsWith('- ')) return
+ String entry = line.substring(2).trim()
+ int at = entry.lastIndexOf('@')
+ if (at < 0) return
+ String id = entry.substring(0, at)
+ String refValue = entry.substring(at + 1)
+ Set<String> set = out.get(id)
+ if (set == null) {
+ set = new LinkedHashSet<String>()
+ out.put(id, set)
+ }
+ set.add(refValue)
+ }
+ out
+ }
+
+ /** Parses actions.yml for SHA-to-tag mapping. Schema:
+ * owner/repo[/subpath]:
+ * <sha>:
+ * tag: vX.Y.Z
+ * expires_at: YYYY-MM-DD
+ * '*':
+ * keep: true
+ * We only care about identifier + SHA + tag.
+ */
+ private static Map<String, Map<String, String>> parseActions(String yaml) {
+ Map<String, Map<String, String>> out = [:]
+ String currentId = null
+ String currentSha = null
+ yaml.eachLine { String raw ->
+ int hash = raw.indexOf('#')
+ String line = hash >= 0 ? raw.substring(0, hash) : raw
+ if (line.trim().isEmpty()) return
+
+ if (!line.startsWith(' ') && line.trim().endsWith(':')) {
+ currentId = line.trim()
+ currentId = currentId.substring(0, currentId.length() - 1)
+ out.putIfAbsent(currentId, new LinkedHashMap<String, String>())
+ currentSha = null
+ return
+ }
+ if (currentId != null && line.startsWith(' ') && !line.startsWith(' ') && line.trim().endsWith(':')) {
+ String key = line.trim()
+ key = key.substring(0, key.length() - 1)
+ if (key.startsWith("'") && key.endsWith("'")) key = key.substring(1, key.length() - 1)
+ currentSha = key
+ return
+ }
+ if (currentId != null && currentSha != null && line.trim().startsWith('tag:')) {
+ String tag = line.trim().substring('tag:'.length()).trim()
+ if (tag.startsWith("'") && tag.endsWith("'")) tag = tag.substring(1, tag.length() - 1)
+ if (tag.startsWith('"') && tag.endsWith('"')) tag = tag.substring(1, tag.length() - 1)
+ out.get(currentId).put(currentSha, tag)
+ }
+ }
+ out
+ }
+}
diff --git a/build.gradle b/build.gradle
index 46dbba002f..bc1f6a3338 100644
--- a/build.gradle
+++ b/build.gradle
@@ -286,3 +286,11 @@ artifacts {
gparsRuntimeElements file: jar.archiveFile.get().asFile, type: 'jar'
loggingRuntimeElements file: jar.archiveFile.get().asFile, type: 'jar'
}
+
+tasks.register('validateActions', org.apache.groovy.gradle.ValidateActionsTask) {
+ group = 'verification'
+ workflowFiles.from(fileTree(rootProject.file('.github/workflows')) {
+ include '*.yml'
+ include '*.yaml'
+ })
+}