Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/coop-deployment-scripts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Coop deployment scripts

on:
push:
paths:
- 'apps/faf-legacy-deployment/scripts/**'
- '.github/workflows/coop-deployment-scripts.yml'
pull_request:
paths:
- 'apps/faf-legacy-deployment/scripts/**'
- '.github/workflows/coop-deployment-scripts.yml'

jobs:
verify:

runs-on: ubuntu-latest
container:
# same image the deployment CronJobs use
image: gradle:9.4-jdk21

steps:
- uses: actions/checkout@v6

- name: Check out the coop missions
uses: actions/checkout@v6
with:
repository: FAForever/faf-coop-maps
path: faf-coop-maps
Comment on lines +22 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow files matching coop =="
fd -a 'coop-deployment-scripts\.ya?ml$' .github/workflows 2>/dev/null || true

echo "== target workflow excerpt =="
if [ -f .github/workflows/coop-deployment-scripts.yml ]; then
  cat -n .github/workflows/coop-deployment-scripts.yml
fi

echo "== checkout steps in target workflow =="
if rg -n "actions/checkout|permissions:|contents:|gradle|kotlin|run:" .github/workflows/coop-deployment-scripts.yml; then
  true
fi

echo "== diff stat/name-only if available =="
git diff -- .github/workflows/coop-deployment-scripts.yml | sed -n '1,140p' || true

Repository: FAForever/gitops-stack

Length of output: 1860


🌐 Web query:

actions/checkout persist-credentials default true README

💡 Result:

The persist-credentials input in the actions/checkout GitHub Action currently has a default value of true [1][2][3]. When set to true (the default), the action configures the provided token or SSH key with the local git configuration, which allows your scripts to execute authenticated git commands (such as git push or git fetch) during the job [1][4][5]. These credentials are automatically removed by the action during post-job cleanup [1][4][5]. Although a pull request has been proposed to change this default to false to enhance security and reduce the risk of unintentional credential exposure, it remains set to true in the current official releases [6][1]. Users who wish to opt out of this behavior and prevent the action from configuring their credentials in the git config can explicitly set persist-credentials to false in their workflow configuration [1][4].

Citations:


🌐 Web query:

GitHub Actions contents: read checkout token persists Git config

💡 Result:

The GitHub Actions actions/checkout action persists authentication credentials (such as the GITHUB_TOKEN or a provided SSH key/PAT) by default to enable subsequent steps in your workflow to run authenticated Git commands [1][2][3]. Key technical details regarding this behavior include: Credential Storage Mechanism: Historically, actions/checkout configured these credentials directly in the local Git configuration (.git/config) [1][4]. However, beginning with version 6.0.0, the action was updated to store these credentials in a separate, isolated file located under the $RUNNER_TEMP directory [5][3][6]. This change uses Git's includeIf mechanism to conditionally include the credentials only when necessary, significantly improving security by separating them from the main repository configuration [4]. Persistence Lifecycle: The credentials remain persisted for the duration of the job [1][3]. A post-job cleanup step is responsible for removing these credentials once the job completes [1][2]. Opting Out: If you do not require authenticated Git commands in subsequent steps, you can prevent this behavior by setting the persist-credentials input to false [1][2]: - uses: actions/checkout@v4 with: persist-credentials: false Security Recommendations: Because the persist-credentials option defaults to true, any subsequent steps in the same job can potentially access these credentials [1][7]. To follow the principle of least privilege, it is recommended to: 1. Limit the permissions of your GITHUB_TOKEN to the minimum necessary, typically contents: read [8][9]. 2. Set persist-credentials: false if your workflow does not require further authenticated Git operations [4][7]. 3. Use fine-grained tokens or service accounts with specific scopes if you need to provide an alternative authentication method [2][10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== lines count =="
wc -l .github/workflows/coop-deployment-scripts.yml

echo "== complete workflow =="
cat -n .github/workflows/coop-deployment-scripts.yml

echo "== matched script files =="
fd '(\.gradle|gradle|kotlin|build\.kt|build\.gradle|.*\.gradle)' apps/faf-legacy-deployment/scripts . 2>/dev/null | head -200 || true

echo "== gradle run/build files excerpts =="
if [ -d apps/faf-legacy-deployment/scripts ]; then
  find apps/faf-legacy-deployment/scripts -maxdepth 3 \
    \( -name 'build.gradle*' -o -name '*.gradle.kts' -o -name '*.gradle' -o -name '*.kt' \) -print -exec sed -n '1,180p' {} \;
fi

echo "== any git commands in scripts/graders =="
rg -n "git\\b|GITHUB_TOKEN|github::token|persist-credentials|actions/github-script|fetch|push|checkout" apps apps/faf-legacy-deployment .github 2>/dev/null || true

Repository: FAForever/gitops-stack

Length of output: 37004


🌐 Web query:

Eclipse JGit Git credentials provider includeIf runner temp GitHub token

💡 Result:

JGit does not currently support git config conditional includes (the includeIf directive) [1][2]. Consequently, you cannot use this mechanism to automatically inject configuration or credentials based on the file path or other conditions [3]. When working with GitHub Actions and the ephemeral GITHUB_TOKEN, attempting to use UsernamePasswordCredentialsProvider with just the token often fails [4]. The following approaches are recommended for authentication in this context: 1. Recommended Approach: Use TransportConfigCallback Modern versions of JGit (5.10+) often prioritize http.extraheader configuration, which can interfere with UsernamePasswordCredentialsProvider [5]. To ensure your credentials are used, implement a TransportConfigCallback to set the authentication explicitly [5]. For GitHub Actions, pass the repository owner as the username and the GITHUB_TOKEN as the password [4]: final String repoOwner = System.getenv("GITHUB_REPOSITORY_OWNER"); final String githubToken = System.getenv("GITHUB_TOKEN"); CredentialsProvider credentialsProvider = new UsernamePasswordCredentialsProvider(repoOwner, githubToken); TransportConfigCallback transportConfigCallback = transport -> { if (transport instanceof TransportHttp) { ((TransportHttp) transport).setPreemptiveBasicAuthentication(repoOwner, githubToken); } }; Git.cloneRepository.setURI("https://github.com/" + System.getenv("GITHUB_REPOSITORY") + ".git").setCredentialsProvider(credentialsProvider).setTransportConfigCallback(transportConfigCallback).call; 2. Limitations and Context - GITHUB_TOKEN: This token is automatically created for each workflow job and expires when the job finishes [6]. It is an installation access token, not a standard user PAT [6]. - Conditional Includes: Because JGit does not implement includeIf [1], any logic relying on conditional configuration files in your .gitconfig will be ignored. - Configuration Precedence: Be aware that if your environment contains http.extraheader settings (often injected in CI environments), they may override the CredentialsProvider [5]. Using the TransportConfigCallback is the most reliable way to bypass these overrides and force the use of your provided credentials [5].

Citations:


Do not persist checkout credentials into the Gradle runtime.

Both actions/checkout@v6 steps leave the job token available for the rest of the job by default, and the Gradle tasks run repository-controlled Kotlin/JGit code that can read repo checkouts. Set persist-credentials: false on both checkout steps and limit the workflow token to contents: read.

🧰 Tools
🪛 zizmor (1.26.1)

[warning] 22-22: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[warning] 24-28: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/coop-deployment-scripts.yml around lines 22 - 28, Update
both checkout steps in the workflow, including the default checkout and the
“Check out the coop missions” step, to set persist-credentials to false. Add
workflow-level permissions restricting the token to contents: read, while
preserving the existing checkout paths and behavior.

Source: Linters/SAST tools


- name: Compile
working-directory: apps/faf-legacy-deployment/scripts
run: gradle --no-daemon compileKotlin

- name: Round trip the path fixer over every mission map
working-directory: apps/faf-legacy-deployment/scripts
env:
MAPS_REPO: ${{ github.workspace }}/faf-coop-maps
run: gradle --no-daemon verifyScmapFixer
82 changes: 81 additions & 1 deletion apps/faf-legacy-deployment/scripts/CoopMapDeployer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import com.faforever.FafDatabase
import com.faforever.GitRepo
import com.faforever.Log
import com.faforever.extractChecksumsFromZip
import com.faforever.fixScmapPaths
import com.faforever.generateChecksums
import com.faforever.isScmap
import com.faforever.referencesOwnMapFolder
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import kotlin.io.path.copyTo
import kotlin.io.path.createDirectories
import kotlin.io.path.isDirectory
Expand Down Expand Up @@ -151,9 +155,17 @@ private fun processCoopMap(
val newVersion = currentVersion + 1
log.info("$map updated → v$newVersion")

verifyRelease(map, newVersion, files, tmp)

if (!simulate) {
val finalZip = Path.of(mapsDir, map.zipName(newVersion))
createZip(map, newVersion, files, tmp, finalZip)
val partialZip = Path.of(mapsDir, "${map.zipName(newVersion)}.part")
try {
createZip(map, newVersion, files, tmp, partialZip)
Files.move(partialZip, finalZip, StandardCopyOption.REPLACE_EXISTING)
} finally {
Files.deleteIfExists(partialZip)
}
db.update(map, newVersion)
}
} finally {
Expand All @@ -180,6 +192,17 @@ private fun generateChecksumsForMap(
* Get file content with path rewriting for text files.
*/
private fun getFileContent(file: Path, map: CoopMap, version: Int): ByteArray {
if (file.isScmapFile()) {
val bytes = file.readBytes()
// Only missions whose map references assets in their own folder need the version
// inserted. Everything else - including the placeholder .scmap files of the missions
// that use a base game map - is passed through and never parsed.
return if (bytes.referencesOwnMapFolder(map.folderName)) {
fixScmapPaths(bytes, map.folderName, version).bytes
} else {
bytes
}
}
Comment on lines +195 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Missing isScmap() guard here, unlike verifyRelease — placeholder .scmap files can hard-fail deployment.

verifyRelease (Line 253) guards with bytes.isScmap() && bytes.referencesOwnMapFolder(...), but getFileContent only checks referencesOwnMapFolder. A placeholder .scmap (not a real map file) that happens to contain the text /maps/<folder> reaches fixScmapPaths, which throws IllegalArgumentException on the header check. That contradicts the comment right above claiming placeholders are "never parsed", and it breaks checksum generation and zip creation for that mission.

🐛 Proposed fix
-        return if (bytes.referencesOwnMapFolder(map.folderName)) {
+        return if (bytes.isScmap() && bytes.referencesOwnMapFolder(map.folderName)) {
             fixScmapPaths(bytes, map.folderName, version).bytes
         } else {
             bytes
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (file.isScmapFile()) {
val bytes = file.readBytes()
// Only missions whose map references assets in their own folder need the version
// inserted. Everything else - including the placeholder .scmap files of the missions
// that use a base game map - is passed through and never parsed.
return if (bytes.referencesOwnMapFolder(map.folderName)) {
fixScmapPaths(bytes, map.folderName, version).bytes
} else {
bytes
}
}
if (file.isScmapFile()) {
val bytes = file.readBytes()
// Only missions whose map references assets in their own folder need the version
// inserted. Everything else - including the placeholder .scmap files of the missions
// that use a base game map - is passed through and never parsed.
return if (bytes.isScmap() && bytes.referencesOwnMapFolder(map.folderName)) {
fixScmapPaths(bytes, map.folderName, version).bytes
} else {
bytes
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/faf-legacy-deployment/scripts/CoopMapDeployer.kt` around lines 195 -
205, Update the scmap handling in getFileContent to require bytes.isScmap() as
well as referencesOwnMapFolder(...) before calling fixScmapPaths. Preserve
returning the original bytes for placeholders and other files, matching the
guard used by verifyRelease.

return if (file.isTextFile()) {
var text = file.readText()
.replace(
Expand All @@ -196,6 +219,61 @@ private fun getFileContent(file: Path, map: CoopMap, version: Int): ByteArray {
}
}

/**
* Checks that every path the release points at actually exists in the release.
*
* The paths rewritten inside a .scmap are checked hard: if one of them does not resolve to
* a file that ends up in the zip, the mission is not deployed at all. That is the case the
* whole path rewriting exists for, and getting it wrong ships a map with missing textures
* that nothing else would notice.
*
* References in text files are only reported. Some of them have been broken for years -
* typos in comment headers - and failing on those would block releases for cosmetic reasons.
*
* @throws IllegalStateException if a rewritten map path does not resolve
*/
private fun verifyRelease(map: CoopMap, version: Int, files: List<Path>, base: Path) {
val prefix = "/maps/${map.folderName(version)}/".lowercase()
val shipped = files
.map { prefix + base.relativize(it).toString().replace("\\", "/").lowercase() }
.toSet()

// over capture is possible when a path is read out of binary data, so a reference counts
// as resolved when it starts with a shipped file
fun resolves(reference: String) =
shipped.any { reference.lowercase() == it || reference.lowercase().startsWith(it) }

val broken = mutableListOf<String>()

files.forEach { file ->
val relative = base.relativize(file).toString().replace("\\", "/")

if (file.isScmapFile()) {
val bytes = file.readBytes()
if (bytes.isScmap() && bytes.referencesOwnMapFolder(map.folderName)) {
fixScmapPaths(bytes, map.folderName, version).rewritten
.filterNot(::resolves)
.forEach { broken += "$relative points at $it, which is not in the release" }
}
} else if (file.isTextFile()) {
val text = String(getFileContent(file, map, version), Charsets.ISO_8859_1)
MAP_REFERENCE.findAll(text)
.map { it.value }
.filter { it.lowercase().startsWith("/maps/${map.folderName.lowercase()}") }
.filterNot(::resolves)
.distinct()
.forEach { log.warn("$map: $relative points at $it, which is not in the release") }
}
}

check(broken.isEmpty()) {
broken.forEach { log.error("$map: $it") }
"$map: ${broken.size} rewritten map path(s) do not resolve, not deploying this mission"
}
}

private val MAP_REFERENCE = Regex("""/maps/[^"'\s,)]+""", RegexOption.IGNORE_CASE)

private fun createZip(
map: CoopMap,
version: Int,
Expand Down Expand Up @@ -238,6 +316,8 @@ private fun createZip(

private fun Path.isTextFile() = listOf(".md", ".lua", ".json", ".txt").any { toString().endsWith(it) }

private fun Path.isScmapFile() = toString().lowercase().endsWith(".scmap")

fun main(args: Array<String>) {
Log.init()

Expand Down
Loading