build: make toolchain inputs reproducible
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: monthly
|
||||||
@@ -42,10 +42,6 @@ jobs:
|
|||||||
|
|
||||||
flutter pub get
|
flutter pub get
|
||||||
|
|
||||||
- name: Install saf_util development dependencies
|
|
||||||
working-directory: packages/saf_util
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Verify generated files committed
|
- name: Verify generated files committed
|
||||||
run: scripts/codegen.sh --check
|
run: scripts/codegen.sh --check
|
||||||
|
|
||||||
@@ -55,8 +51,14 @@ jobs:
|
|||||||
- name: Verify workflow and script guards
|
- name: Verify workflow and script guards
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/check_build_workflow.py
|
python3 scripts/check_build_workflow.py
|
||||||
|
python3 scripts/check_apple_spm_locks.py
|
||||||
|
python3 scripts/test_check_apple_spm_locks.py
|
||||||
python3 scripts/check_workflow_security.py
|
python3 scripts/check_workflow_security.py
|
||||||
python3 scripts/test_check_workflow_security.py
|
python3 scripts/test_check_workflow_security.py
|
||||||
|
python3 scripts/check_workflow_action_pins.py
|
||||||
|
python3 scripts/test_check_workflow_action_pins.py
|
||||||
|
python3 scripts/test_check_codegen.py
|
||||||
|
python3 scripts/test_format_native.py
|
||||||
python3 scripts/test_run_maestro.py
|
python3 scripts/test_run_maestro.py
|
||||||
python3 scripts/check_update_packages_workflow.py
|
python3 scripts/check_update_packages_workflow.py
|
||||||
python3 scripts/test_pubspec_version.py
|
python3 scripts/test_pubspec_version.py
|
||||||
|
|||||||
+139
-22
@@ -1,5 +1,54 @@
|
|||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
fun verifySha256(file: File, expected: String, identity: String) {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
file.inputStream().buffered().use { input ->
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
val count = input.read(buffer)
|
||||||
|
if (count < 0) break
|
||||||
|
digest.update(buffer, 0, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val actual = digest.digest().joinToString("") {
|
||||||
|
(it.toInt() and 0xff).toString(16).padStart(2, '0')
|
||||||
|
}
|
||||||
|
if (actual != expected) {
|
||||||
|
throw GradleException("SHA-256 mismatch for $identity: expected $expected, got $actual")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun promoteDirectory(staging: File, destination: File) {
|
||||||
|
val backup = File(destination.parentFile, "${destination.name}.backup-${UUID.randomUUID()}")
|
||||||
|
val hadDestination = destination.exists()
|
||||||
|
try {
|
||||||
|
if (hadDestination) {
|
||||||
|
Files.move(destination.toPath(), backup.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.move(staging.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||||
|
} catch (promotionFailure: Exception) {
|
||||||
|
if (hadDestination && backup.exists()) {
|
||||||
|
try {
|
||||||
|
Files.move(backup.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||||
|
} catch (restoreFailure: Exception) {
|
||||||
|
promotionFailure.addSuppressed(restoreFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw promotionFailure
|
||||||
|
}
|
||||||
|
if (hadDestination && backup.exists() && !backup.deleteRecursively()) {
|
||||||
|
throw GradleException("Failed to remove obsolete native artifact backup at ${backup.absolutePath}")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
staging.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
@@ -9,17 +58,35 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val mpvVersion = "v1.0.7"
|
val mpvVersion = "v1.0.7"
|
||||||
|
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
|
||||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||||
val mpvAar = "libmpv-release.aar"
|
val mpvAar = "libmpv-release.aar"
|
||||||
|
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||||
|
|
||||||
val downloadLibmpv by tasks.registering {
|
val downloadLibmpv by tasks.registering {
|
||||||
val stamp = File(mpvDir, ".version")
|
val aar = File(mpvDir, mpvAar)
|
||||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
|
val manifest = File(mpvDir, ".manifest")
|
||||||
|
inputs.property("version", mpvVersion)
|
||||||
|
inputs.property("sourceUrl", mpvUrl)
|
||||||
|
inputs.property("sha256", mpvSha256)
|
||||||
|
outputs.files(aar, manifest)
|
||||||
doLast {
|
doLast {
|
||||||
mpvDir.mkdirs()
|
mpvDir.parentFile.mkdirs()
|
||||||
val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
val staging = File(mpvDir.parentFile, "${mpvDir.name}.staging-${UUID.randomUUID()}")
|
||||||
exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) }
|
try {
|
||||||
stamp.writeText(mpvVersion)
|
staging.mkdirs()
|
||||||
|
val stagedAar = File(staging, mpvAar)
|
||||||
|
try {
|
||||||
|
exec { commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath) }
|
||||||
|
} catch (error: Exception) {
|
||||||
|
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||||
|
}
|
||||||
|
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||||
|
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||||
|
promoteDirectory(staging, mpvDir)
|
||||||
|
} finally {
|
||||||
|
staging.deleteRecursively()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,28 +117,78 @@ val extractMpvLibcxx by tasks.registering {
|
|||||||
|
|
||||||
val doviVersion = "2.3.1"
|
val doviVersion = "2.3.1"
|
||||||
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
|
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
|
||||||
val doviAbis = mapOf(
|
val doviArtifacts = mapOf(
|
||||||
"arm64-v8a" to "aarch64-linux-android",
|
"arm64-v8a" to Pair(
|
||||||
"armeabi-v7a" to "armv7-linux-androideabi",
|
"aarch64-linux-android",
|
||||||
"x86" to "i686-linux-android",
|
"9d2983fc86f2f9e6da54c3c84ba8ea3a528690619f312ff4620198071b84e9ae"
|
||||||
"x86_64" to "x86_64-linux-android"
|
),
|
||||||
|
"armeabi-v7a" to Pair(
|
||||||
|
"armv7-linux-androideabi",
|
||||||
|
"ed6fec8bf744e41c661b97f5fc4bf1197ebe9b09a140cbde369728e790ee3a68"
|
||||||
|
),
|
||||||
|
"x86" to Pair(
|
||||||
|
"i686-linux-android",
|
||||||
|
"50f0a5606e617dff8976b9e7930a23272f4804882a35a6f0f2b2f2d3f8ed7135"
|
||||||
|
),
|
||||||
|
"x86_64" to Pair(
|
||||||
|
"x86_64-linux-android",
|
||||||
|
"eba59678f89b792f5c6f802962e237542fe8328f6aa03a0a90ee77353dac3194"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
val doviBaseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
|
||||||
|
|
||||||
val downloadLibdovi by tasks.registering {
|
val downloadLibdovi by tasks.registering {
|
||||||
val stamp = File(doviDir, ".version")
|
val manifest = File(doviDir, ".manifest")
|
||||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
|
inputs.property("version", doviVersion)
|
||||||
|
inputs.property("baseUrl", doviBaseUrl)
|
||||||
|
doviArtifacts.forEach { (abi, artifact) ->
|
||||||
|
inputs.property("$abi.triple", artifact.first)
|
||||||
|
inputs.property("$abi.sha256", artifact.second)
|
||||||
|
inputs.property("$abi.sourceUrl", "$doviBaseUrl/libdovi-${artifact.first}.tar.gz")
|
||||||
|
}
|
||||||
|
outputs.files(doviArtifacts.keys.map { abi -> File(doviDir, "$abi/lib/libdovi.a") } + manifest)
|
||||||
doLast {
|
doLast {
|
||||||
doviDir.mkdirs()
|
doviDir.parentFile.mkdirs()
|
||||||
val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
|
val staging = File(doviDir.parentFile, "${doviDir.name}.staging-${UUID.randomUUID()}")
|
||||||
doviAbis.forEach { (abi, triple) ->
|
try {
|
||||||
val archive = File(doviDir, "$triple.tar.gz")
|
staging.mkdirs()
|
||||||
exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) }
|
val downloads = File(staging, ".downloads").apply { mkdirs() }
|
||||||
val outDir = File(doviDir, "$abi/lib")
|
doviArtifacts.forEach { (abi, artifact) ->
|
||||||
outDir.mkdirs()
|
val (triple, expectedSha256) = artifact
|
||||||
|
val archiveName = "libdovi-$triple.tar.gz"
|
||||||
|
val archive = File(downloads, archiveName)
|
||||||
|
val sourceUrl = "$doviBaseUrl/$archiveName"
|
||||||
|
try {
|
||||||
|
exec { commandLine("curl", "-sfL", sourceUrl, "-o", archive.absolutePath) }
|
||||||
|
} catch (error: Exception) {
|
||||||
|
throw GradleException("Failed to download $archiveName v$doviVersion", error)
|
||||||
|
}
|
||||||
|
verifySha256(archive, expectedSha256, "$archiveName v$doviVersion")
|
||||||
|
|
||||||
|
val outDir = File(staging, "$abi/lib").apply { mkdirs() }
|
||||||
|
try {
|
||||||
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
|
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
|
||||||
archive.delete()
|
} catch (error: Exception) {
|
||||||
|
throw GradleException("Failed to extract $archiveName", error)
|
||||||
|
}
|
||||||
|
if (!File(outDir, "libdovi.a").isFile) {
|
||||||
|
throw GradleException("$archiveName did not contain the expected libdovi.a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!downloads.deleteRecursively()) {
|
||||||
|
throw GradleException("Failed to clean staged libdovi archives")
|
||||||
|
}
|
||||||
|
val manifestText = buildString {
|
||||||
|
append("version=$doviVersion\n")
|
||||||
|
doviArtifacts.forEach { (abi, artifact) ->
|
||||||
|
append("$abi=${artifact.first},${artifact.second}\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
File(staging, ".manifest").writeText(manifestText)
|
||||||
|
promoteDirectory(staging, doviDir)
|
||||||
|
} finally {
|
||||||
|
staging.deleteRecursively()
|
||||||
}
|
}
|
||||||
stamp.writeText(doviVersion)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ distributionPath=wrapper/dists
|
|||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||||
|
distributionSha256Sum=7ebdac923867a3cec0098302416d1e3c6c0c729fc4e2e05c10637a8af33a76c5
|
||||||
|
|||||||
@@ -58,8 +58,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
|
"revision" : "dad229c665bfd043c5d80ac7aa77717cbd19a1c3",
|
||||||
"version" : "8.58.0"
|
"version" : "8.58.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
|
"revision" : "dad229c665bfd043c5d80ac7aa77717cbd19a1c3",
|
||||||
"version" : "8.58.0"
|
"version" : "8.58.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
|
"revision" : "dad229c665bfd043c5d80ac7aa77717cbd19a1c3",
|
||||||
"version" : "8.58.0"
|
"version" : "8.58.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -715,6 +715,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.18.0"
|
||||||
|
mgenware_dart_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: mgenware_dart_lints
|
||||||
|
sha256: "14f47c0ba0073c1980298ee10f602c19fa244151c68ec75fde602642c3de3a4e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ dev_dependencies:
|
|||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
# Root analysis traverses packages/saf_util and consumes its lint profile.
|
||||||
|
mgenware_dart_lints: ^8.0.0
|
||||||
fake_async: ^1.3.3
|
fake_async: ^1.3.3
|
||||||
build_runner: ^2.13.0
|
build_runner: ^2.13.0
|
||||||
json_serializable: ^6.7.1
|
json_serializable: ^6.7.1
|
||||||
|
|||||||
Executable
+161
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate duplicate Apple SwiftPM locks against resolved Flutter plugins."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
LOCK_PAIRS = {
|
||||||
|
"iOS": (
|
||||||
|
Path("ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"),
|
||||||
|
Path("ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved"),
|
||||||
|
),
|
||||||
|
"macOS": (
|
||||||
|
Path("macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"),
|
||||||
|
Path("macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
SENTRY_IDENTITY = "sentry-cocoa"
|
||||||
|
EXACT_REQUIREMENT = re.compile(
|
||||||
|
r"\.package\s*\(\s*url\s*:\s*[\"'][^\"']*sentry-cocoa(?:\.git)?[\"']\s*,\s*exact\s*:\s*[\"']([^\"']+)[\"']",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pin_map(path: Path, errors: list[str]) -> dict[str, dict]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
errors.append(f"{path}: cannot read version-2 SwiftPM lock: {error}")
|
||||||
|
return {}
|
||||||
|
if not isinstance(payload, dict) or payload.get("version") != 2 or not isinstance(payload.get("pins"), list):
|
||||||
|
errors.append(f"{path}: expected a version-2 SwiftPM lock with a pins array")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
pins: dict[str, dict] = {}
|
||||||
|
for pin in payload["pins"]:
|
||||||
|
identity = pin.get("identity") if isinstance(pin, dict) else None
|
||||||
|
if not isinstance(identity, str):
|
||||||
|
errors.append(f"{path}: pin without a string identity")
|
||||||
|
continue
|
||||||
|
canonical = {
|
||||||
|
"kind": pin.get("kind"),
|
||||||
|
"location": pin.get("location"),
|
||||||
|
"state": pin.get("state"),
|
||||||
|
}
|
||||||
|
if identity in pins:
|
||||||
|
errors.append(f"{path}: duplicate pin identity {identity}")
|
||||||
|
pins[identity] = canonical
|
||||||
|
return pins
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_sentry_root(root: Path, errors: list[str]) -> Path | None:
|
||||||
|
config_path = root / ".dart_tool/package_config.json"
|
||||||
|
try:
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
errors.append(f"{config_path}: unavailable ({error}); run flutter pub get first")
|
||||||
|
return None
|
||||||
|
packages = config.get("packages") if isinstance(config, dict) else None
|
||||||
|
if not isinstance(packages, list):
|
||||||
|
errors.append(f"{config_path}: missing packages list; run flutter pub get first")
|
||||||
|
return None
|
||||||
|
package = next(
|
||||||
|
(item for item in packages if isinstance(item, dict) and item.get("name") == "sentry_flutter"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if package is None or not isinstance(package.get("rootUri"), str):
|
||||||
|
errors.append(f"{config_path}: sentry_flutter is not resolved; run flutter pub get first")
|
||||||
|
return None
|
||||||
|
|
||||||
|
uri = package["rootUri"]
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
if parsed.scheme == "file":
|
||||||
|
return Path(unquote(parsed.path))
|
||||||
|
if parsed.scheme:
|
||||||
|
errors.append(f"{config_path}: unsupported sentry_flutter root URI {uri!r}")
|
||||||
|
return None
|
||||||
|
return (config_path.parent / unquote(uri)).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_requirement(path: Path, errors: list[str]) -> str | None:
|
||||||
|
try:
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as error:
|
||||||
|
errors.append(f"{path}: unavailable ({error}); run flutter pub get first")
|
||||||
|
return None
|
||||||
|
versions = EXACT_REQUIREMENT.findall(source)
|
||||||
|
if len(versions) != 1:
|
||||||
|
errors.append(f"{path}: expected exactly one exact sentry-cocoa requirement, found {len(versions)}")
|
||||||
|
return None
|
||||||
|
return versions[0]
|
||||||
|
|
||||||
|
|
||||||
|
def validate(root: Path) -> list[str]:
|
||||||
|
root = root.resolve()
|
||||||
|
errors: list[str] = []
|
||||||
|
lock_maps: dict[Path, dict[str, dict]] = {}
|
||||||
|
for platform, relative_paths in LOCK_PAIRS.items():
|
||||||
|
first_path, second_path = (root / path for path in relative_paths)
|
||||||
|
first = _load_pin_map(first_path, errors)
|
||||||
|
second = _load_pin_map(second_path, errors)
|
||||||
|
lock_maps[first_path] = first
|
||||||
|
lock_maps[second_path] = second
|
||||||
|
if first != second:
|
||||||
|
identities = sorted(set(first) | set(second))
|
||||||
|
differing = [identity for identity in identities if first.get(identity) != second.get(identity)]
|
||||||
|
errors.append(
|
||||||
|
f"{platform} SwiftPM locks differ between {first_path} and {second_path}: "
|
||||||
|
+ ", ".join(differing)
|
||||||
|
)
|
||||||
|
|
||||||
|
sentry_root = _resolved_sentry_root(root, errors)
|
||||||
|
required_versions: list[str] = []
|
||||||
|
if sentry_root is not None:
|
||||||
|
for platform in ("ios", "macos"):
|
||||||
|
version = _manifest_requirement(sentry_root / platform / "sentry_flutter/Package.swift", errors)
|
||||||
|
if version is not None:
|
||||||
|
required_versions.append(version)
|
||||||
|
if len(set(required_versions)) > 1:
|
||||||
|
errors.append("iOS and macOS sentry_flutter manifests require different sentry-cocoa versions")
|
||||||
|
|
||||||
|
required_version = required_versions[0] if required_versions and len(set(required_versions)) == 1 else None
|
||||||
|
sentry_pins: list[tuple[Path, dict]] = []
|
||||||
|
for path, pins in lock_maps.items():
|
||||||
|
pin = pins.get(SENTRY_IDENTITY)
|
||||||
|
if pin is None:
|
||||||
|
errors.append(f"{path}: missing {SENTRY_IDENTITY} pin")
|
||||||
|
continue
|
||||||
|
sentry_pins.append((path, pin))
|
||||||
|
state = pin.get("state")
|
||||||
|
version = state.get("version") if isinstance(state, dict) else None
|
||||||
|
if required_version is not None and version != required_version:
|
||||||
|
errors.append(f"{path}: {SENTRY_IDENTITY} is {version!r}, manifest requires exactly {required_version}")
|
||||||
|
|
||||||
|
if sentry_pins:
|
||||||
|
canonical_path, canonical_pin = sentry_pins[0]
|
||||||
|
for path, pin in sentry_pins[1:]:
|
||||||
|
if pin != canonical_pin:
|
||||||
|
errors.append(f"{path}: {SENTRY_IDENTITY} state differs from {canonical_path}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
errors = validate(args.root)
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
print(f"error: {error}")
|
||||||
|
return 1
|
||||||
|
print("Apple SwiftPM locks match each other and the resolved Sentry manifests.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+144
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check generated outputs in an isolated worktree without mutating the caller."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
EXPLICIT_GENERATED = {"server/relay_protocol_gen.go"}
|
||||||
|
DEPENDENCY_STATE = ("package_config.json", "package_graph.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(root: Path, *args: str) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
return subprocess.run(args, cwd=root, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_generated(relative: str) -> bool:
|
||||||
|
return relative in EXPLICIT_GENERATED or (
|
||||||
|
relative.startswith("lib/") and (relative.endswith(".g.dart") or relative.endswith(".freezed.dart"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generated_state(root: Path) -> dict[str, bytes]:
|
||||||
|
state = {}
|
||||||
|
lib = root / "lib"
|
||||||
|
if lib.exists():
|
||||||
|
for path in lib.rglob("*.dart"):
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
if _is_generated(relative) and path.is_file():
|
||||||
|
state[relative] = path.read_bytes()
|
||||||
|
for relative in EXPLICIT_GENERATED:
|
||||||
|
path = root / relative
|
||||||
|
if path.is_file():
|
||||||
|
state[relative] = path.read_bytes()
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _nul_paths(result: bytes) -> list[str]:
|
||||||
|
return [os.fsdecode(value) for value in result.split(b"\0") if value]
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_overlay_path(source_root: Path, target_root: Path, relative: str) -> None:
|
||||||
|
source = source_root / relative
|
||||||
|
target = target_root / relative
|
||||||
|
if not source.exists() and not source.is_symlink():
|
||||||
|
if target.is_dir() and not target.is_symlink():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
else:
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.is_dir() and not target.is_symlink():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
else:
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
if source.is_symlink():
|
||||||
|
target.symlink_to(os.readlink(source))
|
||||||
|
else:
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_dependency_state(source_root: Path, target_root: Path) -> None:
|
||||||
|
source = source_root / ".dart_tool"
|
||||||
|
target = target_root / ".dart_tool"
|
||||||
|
for relative in DEPENDENCY_STATE:
|
||||||
|
source_path = source / relative
|
||||||
|
if source_path.is_file():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(source_path, target / relative)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_isolated_checkout(root: Path, destination: Path) -> None:
|
||||||
|
_run(root, "git", "worktree", "add", "--detach", "--quiet", str(destination), "HEAD")
|
||||||
|
changed = _nul_paths(_run(root, "git", "diff", "--name-only", "-z", "HEAD", "--").stdout)
|
||||||
|
untracked = _nul_paths(_run(root, "git", "ls-files", "--others", "--exclude-standard", "-z").stdout)
|
||||||
|
for relative in sorted(set(changed + untracked)):
|
||||||
|
_copy_overlay_path(root, destination, relative)
|
||||||
|
_copy_dependency_state(root, destination)
|
||||||
|
|
||||||
|
|
||||||
|
def _dirty_generated_paths(root: Path) -> set[str]:
|
||||||
|
changed = _nul_paths(_run(root, "git", "diff", "--name-only", "-z", "--").stdout)
|
||||||
|
untracked = _nul_paths(
|
||||||
|
_run(
|
||||||
|
root,
|
||||||
|
"git",
|
||||||
|
"ls-files",
|
||||||
|
"--others",
|
||||||
|
"--exclude-standard",
|
||||||
|
"-z",
|
||||||
|
"--",
|
||||||
|
"lib",
|
||||||
|
"server/relay_protocol_gen.go",
|
||||||
|
).stdout
|
||||||
|
)
|
||||||
|
return {relative for relative in changed + untracked if _is_generated(relative)}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
root = Path.cwd().resolve()
|
||||||
|
caller_state = _generated_state(root)
|
||||||
|
temporary = Path(tempfile.mkdtemp(prefix="plezy-codegen-check-"))
|
||||||
|
checkout = temporary / "checkout"
|
||||||
|
registered = True
|
||||||
|
try:
|
||||||
|
_create_isolated_checkout(root, checkout)
|
||||||
|
result = subprocess.run(["bash", "scripts/codegen.sh", *arguments], cwd=checkout, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
expected_state = _generated_state(checkout)
|
||||||
|
stale = {
|
||||||
|
relative
|
||||||
|
for relative in caller_state.keys() | expected_state.keys()
|
||||||
|
if caller_state.get(relative) != expected_state.get(relative)
|
||||||
|
}
|
||||||
|
stale.update(_dirty_generated_paths(root))
|
||||||
|
if stale:
|
||||||
|
print("Generated files are out of date:", file=sys.stderr)
|
||||||
|
for relative in sorted(stale):
|
||||||
|
print(f" {relative}", file=sys.stderr)
|
||||||
|
print("Run 'scripts/codegen.sh' and commit the result.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
if registered:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "worktree", "remove", "--force", str(checkout)],
|
||||||
|
cwd=root,
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
shutil.rmtree(temporary, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+373
@@ -0,0 +1,373 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Require immutable commit pins for remote GitHub Actions dependencies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
WORKFLOWS = ROOT / ".github" / "workflows"
|
||||||
|
MAPPING_RE = re.compile(
|
||||||
|
r"""^\s*(?:-\s*)?(?P<key>uses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*:\s*(?P<value>.*?)\s*$"""
|
||||||
|
)
|
||||||
|
EXPLICIT_KEY_RE = re.compile(
|
||||||
|
r"""^\s*(?:-\s*)?\?\s*(?P<key>uses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*$"""
|
||||||
|
)
|
||||||
|
EXPLICIT_VALUE_RE = re.compile(r"^\s*:\s*(?P<value>.*?)\s*$")
|
||||||
|
REMOTE_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_./-]+)?@[0-9a-fA-F]{40}$")
|
||||||
|
BLOCK_SCALAR_RE = re.compile(r":\s*[|>](?:[1-9][+-]?|[+-][1-9]?)?\s*(?:#.*)?$")
|
||||||
|
BLOCK_SCALAR_VALUE_RE = re.compile(r"^[|>](?:[1-9][+-]?|[+-][1-9]?)?$")
|
||||||
|
YAML_DOUBLE_ESCAPES = {
|
||||||
|
"0": "\0",
|
||||||
|
"a": "\a",
|
||||||
|
"b": "\b",
|
||||||
|
"t": "\t",
|
||||||
|
"\t": "\t",
|
||||||
|
"n": "\n",
|
||||||
|
"v": "\v",
|
||||||
|
"f": "\f",
|
||||||
|
"r": "\r",
|
||||||
|
"e": "\x1b",
|
||||||
|
" ": " ",
|
||||||
|
'"': '"',
|
||||||
|
"/": "/",
|
||||||
|
"\\": "\\",
|
||||||
|
"N": "\u0085",
|
||||||
|
"_": "\u00a0",
|
||||||
|
"L": "\u2028",
|
||||||
|
"P": "\u2029",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def iter_workflow_files(directory: Path = WORKFLOWS):
|
||||||
|
yield from sorted((*directory.glob("*.yml"), *directory.glob("*.yaml")))
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_yaml_comment(value: str) -> str:
|
||||||
|
quote = None
|
||||||
|
escaped = False
|
||||||
|
for index, char in enumerate(value):
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
continue
|
||||||
|
if char == "\\" and quote == '"':
|
||||||
|
escaped = True
|
||||||
|
continue
|
||||||
|
if char in ("'", '"'):
|
||||||
|
if quote is None:
|
||||||
|
quote = char
|
||||||
|
elif quote == char:
|
||||||
|
quote = None
|
||||||
|
continue
|
||||||
|
if char == "#" and quote is None and (index == 0 or value[index - 1].isspace()):
|
||||||
|
return value[:index].rstrip()
|
||||||
|
return value.rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_quoted_yaml_string(value: str) -> str | None:
|
||||||
|
if len(value) < 2 or value[0] != value[-1] or value[0] not in ("'", '"'):
|
||||||
|
return None
|
||||||
|
if value[0] == "'":
|
||||||
|
return value[1:-1].replace("''", "'")
|
||||||
|
|
||||||
|
decoded = []
|
||||||
|
index = 1
|
||||||
|
end = len(value) - 1
|
||||||
|
while index < end:
|
||||||
|
char = value[index]
|
||||||
|
if char != "\\":
|
||||||
|
decoded.append(char)
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
index += 1
|
||||||
|
if index >= end:
|
||||||
|
return None
|
||||||
|
escape = value[index]
|
||||||
|
if escape in YAML_DOUBLE_ESCAPES:
|
||||||
|
decoded.append(YAML_DOUBLE_ESCAPES[escape])
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
width = {"x": 2, "u": 4, "U": 8}.get(escape)
|
||||||
|
if width is None or index + width >= end:
|
||||||
|
return None
|
||||||
|
digits = value[index + 1 : index + 1 + width]
|
||||||
|
if not re.fullmatch(rf"[0-9a-fA-F]{{{width}}}", digits):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
decoded.append(chr(int(digits, 16)))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
index += width + 1
|
||||||
|
return "".join(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
def _unquote(value: str) -> str:
|
||||||
|
decoded = _decode_quoted_yaml_string(value)
|
||||||
|
return value if decoded is None else decoded
|
||||||
|
|
||||||
|
|
||||||
|
def _flow_value(line: str, start: int, mapping_depth: int) -> str:
|
||||||
|
index = start
|
||||||
|
quote = None
|
||||||
|
escaped = False
|
||||||
|
depth = mapping_depth
|
||||||
|
while index < len(line):
|
||||||
|
char = line[index]
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif char == "\\" and quote == '"':
|
||||||
|
escaped = True
|
||||||
|
elif quote is not None:
|
||||||
|
if char == quote:
|
||||||
|
quote = None
|
||||||
|
elif char in ("'", '"'):
|
||||||
|
quote = char
|
||||||
|
elif char in ("{", "["):
|
||||||
|
depth += 1
|
||||||
|
elif char in ("}", "]"):
|
||||||
|
if depth == mapping_depth:
|
||||||
|
break
|
||||||
|
depth -= 1
|
||||||
|
elif char == "," and depth == mapping_depth:
|
||||||
|
break
|
||||||
|
index += 1
|
||||||
|
return _unquote(line[start:index].strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _has_unsupported_block_mapping_key(line: str) -> bool:
|
||||||
|
candidate = line.lstrip()
|
||||||
|
if candidate.startswith("-") and not candidate.startswith("---"):
|
||||||
|
candidate = candidate[1:].lstrip()
|
||||||
|
if not candidate:
|
||||||
|
return False
|
||||||
|
if candidate[0] in "!&*":
|
||||||
|
return True
|
||||||
|
if candidate[0] not in ("'", '"'):
|
||||||
|
return False
|
||||||
|
|
||||||
|
quote = candidate[0]
|
||||||
|
escaped = False
|
||||||
|
index = 1
|
||||||
|
while index < len(candidate):
|
||||||
|
char = candidate[index]
|
||||||
|
if quote == "'" and char == "'" and index + 1 < len(candidate) and candidate[index + 1] == "'":
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif quote == '"' and char == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif char == quote:
|
||||||
|
return False
|
||||||
|
index += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _flow_uses_references(line: str, initial_depth: int) -> tuple[list[str], int]:
|
||||||
|
references = []
|
||||||
|
depth = initial_depth
|
||||||
|
index = 0
|
||||||
|
while index < len(line):
|
||||||
|
char = line[index]
|
||||||
|
if char in ("'", '"'):
|
||||||
|
quote = char
|
||||||
|
escaped = False
|
||||||
|
end = index + 1
|
||||||
|
while end < len(line):
|
||||||
|
quoted_char = line[end]
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif quoted_char == "\\" and quote == '"':
|
||||||
|
escaped = True
|
||||||
|
elif quoted_char == quote:
|
||||||
|
break
|
||||||
|
end += 1
|
||||||
|
if end >= len(line):
|
||||||
|
if depth > 0:
|
||||||
|
references.append("<unsupported multiline flow scalar>")
|
||||||
|
return references, depth
|
||||||
|
key = _decode_quoted_yaml_string(line[index : end + 1])
|
||||||
|
after_key = end + 1
|
||||||
|
while after_key < len(line) and line[after_key].isspace():
|
||||||
|
after_key += 1
|
||||||
|
if depth > 0 and after_key < len(line) and line[after_key] == ":":
|
||||||
|
if key == "uses":
|
||||||
|
references.append(_flow_value(line, after_key + 1, depth))
|
||||||
|
elif key is None:
|
||||||
|
references.append("<unsupported quoted flow mapping key>")
|
||||||
|
index = end + 1
|
||||||
|
continue
|
||||||
|
if line.startswith("${{", index):
|
||||||
|
expression_end = line.find("}}", index + 3)
|
||||||
|
if expression_end < 0:
|
||||||
|
references.append("<unterminated GitHub expression>")
|
||||||
|
return references, depth
|
||||||
|
index = expression_end + 2
|
||||||
|
continue
|
||||||
|
if char in ("{", "["):
|
||||||
|
depth += 1
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
if char in ("}", "]"):
|
||||||
|
depth = max(0, depth - 1)
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
if depth > 0 and char == "?":
|
||||||
|
references.append("<unsupported explicit flow mapping>")
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
if depth > 0 and char in "!&*":
|
||||||
|
references.append("<unsupported tagged, anchored, or aliased flow mapping>")
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
if depth > 0 and (char.isalpha() or char == "_"):
|
||||||
|
end = index + 1
|
||||||
|
while end < len(line) and (line[end].isalnum() or line[end] in "_-"):
|
||||||
|
end += 1
|
||||||
|
after_key = end
|
||||||
|
while after_key < len(line) and line[after_key].isspace():
|
||||||
|
after_key += 1
|
||||||
|
if line[index:end] == "uses" and after_key < len(line) and line[after_key] == ":":
|
||||||
|
references.append(_flow_value(line, after_key + 1, depth))
|
||||||
|
index = end
|
||||||
|
continue
|
||||||
|
index += 1
|
||||||
|
return references, depth
|
||||||
|
|
||||||
|
|
||||||
|
def iter_uses_references(path: Path):
|
||||||
|
block_parent_indent = None
|
||||||
|
block_content_indent = None
|
||||||
|
block_uses_line = None
|
||||||
|
block_uses_content: list[str] = []
|
||||||
|
explicit_uses_line = None
|
||||||
|
flow_start_line = None
|
||||||
|
flow_depth = 0
|
||||||
|
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
stripped = raw_line.lstrip()
|
||||||
|
indent = len(raw_line) - len(stripped)
|
||||||
|
if block_parent_indent is not None:
|
||||||
|
if not stripped:
|
||||||
|
if block_uses_line is not None:
|
||||||
|
block_uses_content.append("")
|
||||||
|
continue
|
||||||
|
if indent <= block_parent_indent:
|
||||||
|
if block_uses_line is not None:
|
||||||
|
yield block_uses_line, "\n".join(block_uses_content).strip()
|
||||||
|
block_parent_indent = None
|
||||||
|
block_content_indent = None
|
||||||
|
block_uses_line = None
|
||||||
|
block_uses_content = []
|
||||||
|
elif block_content_indent is None:
|
||||||
|
block_content_indent = indent
|
||||||
|
if block_uses_line is not None:
|
||||||
|
block_uses_content.append(raw_line[block_content_indent:])
|
||||||
|
continue
|
||||||
|
elif indent >= block_content_indent:
|
||||||
|
if block_uses_line is not None:
|
||||||
|
block_uses_content.append(raw_line[block_content_indent:])
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if block_uses_line is not None:
|
||||||
|
yield block_uses_line, "\n".join(block_uses_content).strip()
|
||||||
|
block_parent_indent = None
|
||||||
|
block_content_indent = None
|
||||||
|
block_uses_line = None
|
||||||
|
block_uses_content = []
|
||||||
|
if stripped.startswith("#") or not stripped:
|
||||||
|
continue
|
||||||
|
active_line = _strip_yaml_comment(raw_line)
|
||||||
|
if explicit_uses_line is not None:
|
||||||
|
explicit_value = EXPLICIT_VALUE_RE.match(active_line)
|
||||||
|
if explicit_value is None:
|
||||||
|
yield explicit_uses_line, "<missing explicit mapping value>"
|
||||||
|
else:
|
||||||
|
value = explicit_value.group("value").strip()
|
||||||
|
if BLOCK_SCALAR_VALUE_RE.fullmatch(value):
|
||||||
|
block_parent_indent = indent
|
||||||
|
block_content_indent = None
|
||||||
|
block_uses_line = explicit_uses_line
|
||||||
|
block_uses_content = []
|
||||||
|
else:
|
||||||
|
yield explicit_uses_line, _unquote(value)
|
||||||
|
explicit_uses_line = None
|
||||||
|
continue
|
||||||
|
explicit_uses_line = None
|
||||||
|
explicit_key = EXPLICIT_KEY_RE.match(active_line)
|
||||||
|
if explicit_key:
|
||||||
|
if _unquote(explicit_key.group("key")) == "uses":
|
||||||
|
explicit_uses_line = line_number
|
||||||
|
continue
|
||||||
|
if re.match(r"^\s*(?:-\s*)?\?", active_line):
|
||||||
|
yield line_number, "<unsupported explicit mapping key>"
|
||||||
|
continue
|
||||||
|
if _has_unsupported_block_mapping_key(active_line):
|
||||||
|
yield line_number, "<unsupported multiline, tagged, anchored, or aliased mapping key>"
|
||||||
|
continue
|
||||||
|
match = MAPPING_RE.match(active_line) if flow_depth == 0 else None
|
||||||
|
if match:
|
||||||
|
key = _unquote(match.group("key"))
|
||||||
|
value = match.group("value").strip()
|
||||||
|
if BLOCK_SCALAR_VALUE_RE.fullmatch(value):
|
||||||
|
block_parent_indent = indent
|
||||||
|
block_content_indent = None
|
||||||
|
if key == "uses":
|
||||||
|
block_uses_line = line_number
|
||||||
|
block_uses_content = []
|
||||||
|
continue
|
||||||
|
if key == "uses":
|
||||||
|
yield line_number, _unquote(value)
|
||||||
|
if BLOCK_SCALAR_RE.search(raw_line):
|
||||||
|
block_parent_indent = indent
|
||||||
|
block_content_indent = None
|
||||||
|
continue
|
||||||
|
previous_flow_depth = flow_depth
|
||||||
|
flow_references, flow_depth = _flow_uses_references(active_line, flow_depth)
|
||||||
|
for reference in flow_references:
|
||||||
|
yield line_number, reference
|
||||||
|
if previous_flow_depth == 0 and flow_depth > 0:
|
||||||
|
flow_start_line = line_number
|
||||||
|
elif flow_depth == 0:
|
||||||
|
flow_start_line = None
|
||||||
|
if explicit_uses_line is not None:
|
||||||
|
yield explicit_uses_line, "<missing explicit mapping value>"
|
||||||
|
if flow_depth > 0:
|
||||||
|
yield flow_start_line or 1, "<unterminated flow collection>"
|
||||||
|
if block_uses_line is not None:
|
||||||
|
yield block_uses_line, "\n".join(block_uses_content).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_reference(reference: str) -> str | None:
|
||||||
|
if reference.startswith("./"):
|
||||||
|
return None
|
||||||
|
if REMOTE_RE.fullmatch(reference):
|
||||||
|
return None
|
||||||
|
return "remote actions must use a full 40-character commit SHA"
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
paths = [Path(value) for value in args] if args else list(iter_workflow_files())
|
||||||
|
violations = []
|
||||||
|
for path in paths:
|
||||||
|
for line_number, reference in iter_uses_references(path):
|
||||||
|
reason = validate_reference(reference)
|
||||||
|
if reason:
|
||||||
|
try:
|
||||||
|
display_path = path.resolve().relative_to(ROOT)
|
||||||
|
except ValueError:
|
||||||
|
display_path = path
|
||||||
|
violations.append(f"{display_path}:{line_number}: {reference!r}: {reason}")
|
||||||
|
if violations:
|
||||||
|
print("Mutable or malformed GitHub Actions references:", file=sys.stderr)
|
||||||
|
for violation in violations:
|
||||||
|
print(f" {violation}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"Workflow action pins verified ({len(paths)} files).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -83,8 +83,14 @@ fi
|
|||||||
# 4. Workflow and script regression guards
|
# 4. Workflow and script regression guards
|
||||||
section "workflow and script guards"
|
section "workflow and script guards"
|
||||||
if python3 scripts/check_build_workflow.py &&
|
if python3 scripts/check_build_workflow.py &&
|
||||||
|
python3 scripts/check_apple_spm_locks.py &&
|
||||||
|
python3 scripts/test_check_apple_spm_locks.py &&
|
||||||
python3 scripts/check_workflow_security.py &&
|
python3 scripts/check_workflow_security.py &&
|
||||||
python3 scripts/test_check_workflow_security.py &&
|
python3 scripts/test_check_workflow_security.py &&
|
||||||
|
python3 scripts/check_workflow_action_pins.py &&
|
||||||
|
python3 scripts/test_check_workflow_action_pins.py &&
|
||||||
|
python3 scripts/test_check_codegen.py &&
|
||||||
|
python3 scripts/test_format_native.py &&
|
||||||
python3 scripts/check_update_packages_workflow.py &&
|
python3 scripts/check_update_packages_workflow.py &&
|
||||||
python3 scripts/test_pubspec_version.py &&
|
python3 scripts/test_pubspec_version.py &&
|
||||||
python3 scripts/test_clean_translations.py &&
|
python3 scripts/test_clean_translations.py &&
|
||||||
@@ -111,7 +117,7 @@ out="$(mktemp)"
|
|||||||
if scripts/format_native.sh --check >"$out" 2>&1; then
|
if scripts/format_native.sh --check >"$out" 2>&1; then
|
||||||
ok "native files correctly formatted"
|
ok "native files correctly formatted"
|
||||||
else
|
else
|
||||||
fail "native formatting issues"
|
fail "native formatting check failed"
|
||||||
sed 's/^/ /' "$out"
|
sed 's/^/ /' "$out"
|
||||||
FAILED=1
|
FAILED=1
|
||||||
fi
|
fi
|
||||||
|
|||||||
+1
-19
@@ -2,29 +2,11 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
|
||||||
check=false
|
|
||||||
if [[ "${1:-}" == "--check" ]]; then
|
if [[ "${1:-}" == "--check" ]]; then
|
||||||
check=true
|
|
||||||
shift
|
shift
|
||||||
|
exec python3 scripts/check_codegen.py "$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 scripts/generate_relay_protocol.py
|
python3 scripts/generate_relay_protocol.py
|
||||||
dart run slang
|
dart run slang
|
||||||
dart run build_runner build --delete-conflicting-outputs "$@"
|
dart run build_runner build --delete-conflicting-outputs "$@"
|
||||||
|
|
||||||
if $check; then
|
|
||||||
generated_changes="$({
|
|
||||||
git diff --name-only -- lib server/relay_protocol_gen.go
|
|
||||||
git ls-files --others --exclude-standard -- \
|
|
||||||
':(glob)lib/**/*.g.dart' \
|
|
||||||
':(glob)lib/**/*.freezed.dart' \
|
|
||||||
server/relay_protocol_gen.go
|
|
||||||
} | grep -E '(\.(g|freezed)\.dart|relay_protocol_gen\.go)$' || true)"
|
|
||||||
|
|
||||||
if [[ -n "$generated_changes" ]]; then
|
|
||||||
echo "Generated files are out of date:" >&2
|
|
||||||
printf ' %s\n' "$generated_changes" >&2
|
|
||||||
echo "Run 'scripts/codegen.sh' and commit the result." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|||||||
+120
-10
@@ -20,9 +20,22 @@ case "${1:---check}" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
KTLINT_VERSION="${KTLINT_VERSION:-1.5.0}"
|
KTLINT_VERSION="1.5.0"
|
||||||
|
KTLINT_SHA256="a16be01dcc480aab2f55f444b620142152f66e31564b3b9376506d624c28a2ad"
|
||||||
|
KTLINT_URL="https://github.com/ktlint/ktlint/releases/download/$KTLINT_VERSION/ktlint"
|
||||||
KTLINT_BIN="$ROOT/.dart_tool/native-format/ktlint-$KTLINT_VERSION"
|
KTLINT_BIN="$ROOT/.dart_tool/native-format/ktlint-$KTLINT_VERSION"
|
||||||
|
KTLINT_TMP=""
|
||||||
|
JAVA_BIN_DIR=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$KTLINT_TMP" ]; then
|
||||||
|
rm -f -- "$KTLINT_TMP"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
has_command() {
|
has_command() {
|
||||||
command -v "$1" >/dev/null 2>&1
|
command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
@@ -51,22 +64,118 @@ run_swift_format() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
java_diagnostic() {
|
||||||
|
echo "A working JDK 17+ is required for Kotlin formatting. Set JAVA_HOME to a JDK 17+ installation or put java on PATH." >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_java() {
|
||||||
|
local candidate output version major
|
||||||
|
if [ -n "${JAVA_HOME:-}" ]; then
|
||||||
|
candidate="$JAVA_HOME/bin/java"
|
||||||
|
else
|
||||||
|
candidate="$(command -v java 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [ -z "$candidate" ] || [ ! -x "$candidate" ]; then
|
||||||
|
java_diagnostic
|
||||||
|
return 127
|
||||||
|
fi
|
||||||
|
if [[ "$candidate" != /* ]]; then
|
||||||
|
candidate="$PWD/$candidate"
|
||||||
|
fi
|
||||||
|
if ! output="$("$candidate" -version 2>&1)"; then
|
||||||
|
java_diagnostic
|
||||||
|
return 127
|
||||||
|
fi
|
||||||
|
if [[ "$output" =~ version[[:space:]]+\"([^\"]+)\" ]]; then
|
||||||
|
version="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
java_diagnostic
|
||||||
|
return 127
|
||||||
|
fi
|
||||||
|
if [[ "$version" =~ ^1\.([0-9]+)([._-]|$) ]]; then
|
||||||
|
major="${BASH_REMATCH[1]}"
|
||||||
|
elif [[ "$version" =~ ^([0-9]+)([._-]|$) ]]; then
|
||||||
|
major="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
java_diagnostic
|
||||||
|
return 127
|
||||||
|
fi
|
||||||
|
if ((major < 17)); then
|
||||||
|
java_diagnostic
|
||||||
|
return 127
|
||||||
|
fi
|
||||||
|
JAVA_BIN_DIR="${candidate%/*}"
|
||||||
|
}
|
||||||
|
|
||||||
|
sha256_file() {
|
||||||
|
local output
|
||||||
|
if has_command shasum; then
|
||||||
|
output="$(shasum -a 256 "$1")" || {
|
||||||
|
echo "Failed to calculate the ktlint SHA-256 digest with shasum." >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
elif has_command sha256sum; then
|
||||||
|
output="$(sha256sum "$1")" || {
|
||||||
|
echo "Failed to calculate the ktlint SHA-256 digest with sha256sum." >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo "A SHA-256 tool is required to verify ktlint (shasum or sha256sum)." >&2
|
||||||
|
return 127
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${output%%[[:space:]]*}"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_ktlint() {
|
||||||
|
local digest
|
||||||
|
digest="$(sha256_file "$1")" || return $?
|
||||||
|
[[ "$digest" =~ ^[0-9a-f]{64}$ ]] && [ "$digest" = "$KTLINT_SHA256" ]
|
||||||
|
}
|
||||||
|
|
||||||
ensure_ktlint() {
|
ensure_ktlint() {
|
||||||
if [ -x "$KTLINT_BIN" ]; then
|
local status
|
||||||
|
if ! has_command shasum && ! has_command sha256sum; then
|
||||||
|
echo "A SHA-256 tool is required to verify ktlint (shasum or sha256sum)." >&2
|
||||||
|
return 127
|
||||||
|
fi
|
||||||
|
if [ -f "$KTLINT_BIN" ]; then
|
||||||
|
if verify_ktlint "$KTLINT_BIN"; then
|
||||||
|
chmod +x "$KTLINT_BIN"
|
||||||
return 0
|
return 0
|
||||||
|
else
|
||||||
|
status=$?
|
||||||
|
if [ "$status" -ne 1 ]; then
|
||||||
|
return "$status"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if ! has_command curl; then
|
if ! has_command curl; then
|
||||||
echo "curl not found. Install curl to download ktlint." >&2
|
echo "curl not found. Install curl to download ktlint." >&2
|
||||||
return 127
|
return 127
|
||||||
fi
|
fi
|
||||||
if ! has_command java; then
|
|
||||||
echo "java not found. Install JDK 17+ to run ktlint." >&2
|
|
||||||
return 127
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$KTLINT_BIN")"
|
mkdir -p "$(dirname "$KTLINT_BIN")"
|
||||||
curl -fsSL "https://github.com/pinterest/ktlint/releases/download/$KTLINT_VERSION/ktlint" -o "$KTLINT_BIN"
|
KTLINT_TMP="$(mktemp "$(dirname "$KTLINT_BIN")/.ktlint-$KTLINT_VERSION.XXXXXX")"
|
||||||
chmod +x "$KTLINT_BIN"
|
if ! curl -fsSL "$KTLINT_URL" -o "$KTLINT_TMP"; then
|
||||||
|
echo "Failed to download ktlint $KTLINT_VERSION." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if verify_ktlint "$KTLINT_TMP"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
status=$?
|
||||||
|
if [ "$status" -eq 1 ]; then
|
||||||
|
echo "Downloaded ktlint $KTLINT_VERSION failed SHA-256 verification." >&2
|
||||||
|
fi
|
||||||
|
return "$status"
|
||||||
|
fi
|
||||||
|
chmod +x "$KTLINT_TMP"
|
||||||
|
mv -f "$KTLINT_TMP" "$KTLINT_BIN"
|
||||||
|
KTLINT_TMP=""
|
||||||
|
}
|
||||||
|
|
||||||
|
run_ktlint() {
|
||||||
|
PATH="$JAVA_BIN_DIR:$PATH" "$KTLINT_BIN" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
append_native_files() {
|
append_native_files() {
|
||||||
@@ -106,11 +215,12 @@ append_native_files \
|
|||||||
FAILED=0
|
FAILED=0
|
||||||
|
|
||||||
if [ "${#ktlint_files[@]}" -gt 0 ]; then
|
if [ "${#ktlint_files[@]}" -gt 0 ]; then
|
||||||
|
resolve_java
|
||||||
ensure_ktlint
|
ensure_ktlint
|
||||||
if [ "$MODE" = "fix" ]; then
|
if [ "$MODE" = "fix" ]; then
|
||||||
"$KTLINT_BIN" -F "${ktlint_files[@]}"
|
run_ktlint -F "${ktlint_files[@]}"
|
||||||
else
|
else
|
||||||
"$KTLINT_BIN" "${ktlint_files[@]}" || FAILED=1
|
run_ktlint "${ktlint_files[@]}" || FAILED=1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "No Kotlin files found."
|
echo "No Kotlin files found."
|
||||||
|
|||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).with_name("check_apple_spm_locks.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("check_apple_spm_locks", SCRIPT)
|
||||||
|
CHECKER = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader is not None
|
||||||
|
SPEC.loader.exec_module(CHECKER)
|
||||||
|
|
||||||
|
GOOD_VERSION = "8.58.3"
|
||||||
|
GOOD_REVISION = "dad229c665bfd043c5d80ac7aa77717cbd19a1c3"
|
||||||
|
|
||||||
|
|
||||||
|
class AppleSpmLockCheckerTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self._write_package_config()
|
||||||
|
self._write_manifests(GOOD_VERSION)
|
||||||
|
self._write_all_locks(GOOD_VERSION, GOOD_REVISION)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def _write_package_config(self) -> None:
|
||||||
|
path = self.root / ".dart_tool/package_config.json"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"configVersion": 2,
|
||||||
|
"packages": [{"name": "sentry_flutter", "rootUri": "../packages/sentry_flutter"}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _write_manifests(self, version: str) -> None:
|
||||||
|
source = f'.package(url: "https://github.com/getsentry/sentry-cocoa", exact: "{version}")\n'
|
||||||
|
for platform in ("ios", "macos"):
|
||||||
|
path = self.root / f"packages/sentry_flutter/{platform}/sentry_flutter/Package.swift"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text(source, encoding="utf-8")
|
||||||
|
|
||||||
|
def _lock_payload(self, version: str, revision: str) -> dict:
|
||||||
|
return {
|
||||||
|
"originHash": "fixture",
|
||||||
|
"pins": [
|
||||||
|
{
|
||||||
|
"identity": "sentry-cocoa",
|
||||||
|
"kind": "remoteSourceControl",
|
||||||
|
"location": "https://github.com/getsentry/sentry-cocoa",
|
||||||
|
"state": {"revision": revision, "version": version},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _write_lock(self, relative: Path, version: str, revision: str) -> None:
|
||||||
|
path = self.root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(self._lock_payload(version, revision)), encoding="utf-8")
|
||||||
|
|
||||||
|
def _write_all_locks(self, version: str, revision: str) -> None:
|
||||||
|
for paths in CHECKER.LOCK_PAIRS.values():
|
||||||
|
for path in paths:
|
||||||
|
self._write_lock(path, version, revision)
|
||||||
|
|
||||||
|
def test_consistent_graph_passes_library_and_cli(self) -> None:
|
||||||
|
self.assertEqual([], CHECKER.validate(self.root))
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), "--root", str(self.root)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, completed.returncode, completed.stdout + completed.stderr)
|
||||||
|
self.assertIn("Apple SwiftPM locks match", completed.stdout)
|
||||||
|
|
||||||
|
def test_reports_project_workspace_version_mismatch(self) -> None:
|
||||||
|
workspace = CHECKER.LOCK_PAIRS["iOS"][1]
|
||||||
|
self._write_lock(workspace, "8.58.0", "old")
|
||||||
|
|
||||||
|
errors = CHECKER.validate(self.root)
|
||||||
|
|
||||||
|
self.assertTrue(any(str(workspace) in error and "sentry-cocoa" in error for error in errors))
|
||||||
|
self.assertTrue(any("iOS SwiftPM locks differ" in error for error in errors))
|
||||||
|
|
||||||
|
def test_reports_all_locks_stale_against_manifests(self) -> None:
|
||||||
|
self._write_all_locks("8.58.0", "old")
|
||||||
|
|
||||||
|
errors = CHECKER.validate(self.root)
|
||||||
|
|
||||||
|
self.assertEqual(4, sum("manifest requires exactly 8.58.3" in error for error in errors))
|
||||||
|
|
||||||
|
def test_reports_same_version_with_different_revision(self) -> None:
|
||||||
|
workspace = CHECKER.LOCK_PAIRS["macOS"][1]
|
||||||
|
self._write_lock(workspace, GOOD_VERSION, "different")
|
||||||
|
|
||||||
|
errors = CHECKER.validate(self.root)
|
||||||
|
|
||||||
|
self.assertTrue(any("macOS SwiftPM locks differ" in error and "sentry-cocoa" in error for error in errors))
|
||||||
|
self.assertTrue(any("state differs" in error for error in errors))
|
||||||
|
|
||||||
|
def test_reports_missing_resolved_package_inputs(self) -> None:
|
||||||
|
(self.root / ".dart_tool/package_config.json").unlink()
|
||||||
|
|
||||||
|
errors = CHECKER.validate(self.root)
|
||||||
|
|
||||||
|
self.assertTrue(any("flutter pub get first" in error for error in errors))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
GENERATED_PATHS = (
|
||||||
|
"lib/i18n/strings.g.dart",
|
||||||
|
"lib/models/model.freezed.dart",
|
||||||
|
"lib/models/model.g.dart",
|
||||||
|
"lib/watch_together/services/relay_protocol.g.dart",
|
||||||
|
"server/relay_protocol_gen.go",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def executable(path: Path, contents: str) -> None:
|
||||||
|
path.write_text(contents, encoding="utf-8")
|
||||||
|
path.chmod(path.stat().st_mode | stat.S_IXUSR)
|
||||||
|
|
||||||
|
|
||||||
|
class CodegenCheckTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.codegen_temp = Path(tempfile.mkdtemp(prefix="plezy-codegen-test-temp-"))
|
||||||
|
subprocess.run(["git", "init", "-q", str(self.root)], check=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "fixture@example.invalid"], cwd=self.root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", "Fixture"], cwd=self.root, check=True)
|
||||||
|
|
||||||
|
(self.root / "scripts").mkdir()
|
||||||
|
shutil.copy2(SCRIPT_DIR / "codegen.sh", self.root / "scripts" / "codegen.sh")
|
||||||
|
shutil.copy2(SCRIPT_DIR / "check_codegen.py", self.root / "scripts" / "check_codegen.py")
|
||||||
|
(self.root / "scripts" / "generate_relay_protocol.py").write_text("fixture\n", encoding="utf-8")
|
||||||
|
(self.root / "source.txt").write_text("version one\n", encoding="utf-8")
|
||||||
|
for relative in GENERATED_PATHS:
|
||||||
|
path = self.root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("version one\n", encoding="utf-8")
|
||||||
|
|
||||||
|
self.bin = self.root / "fake-bin"
|
||||||
|
self.bin.mkdir()
|
||||||
|
executable(
|
||||||
|
self.bin / "python3",
|
||||||
|
"""#!/usr/bin/env bash
|
||||||
|
if [[ "$1" == *check_codegen.py ]]; then exec "$REAL_PYTHON" "$@"; fi
|
||||||
|
mkdir -p lib/watch_together/services server
|
||||||
|
cp source.txt lib/watch_together/services/relay_protocol.g.dart
|
||||||
|
cp source.txt server/relay_protocol_gen.go
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
executable(
|
||||||
|
self.bin / "dart",
|
||||||
|
"""#!/usr/bin/env bash
|
||||||
|
if [ "${FAIL_DART:-0}" -ne 0 ]; then exit "$FAIL_DART"; fi
|
||||||
|
case "$*" in
|
||||||
|
"run slang")
|
||||||
|
mkdir -p lib/i18n
|
||||||
|
cp source.txt lib/i18n/strings.g.dart
|
||||||
|
;;
|
||||||
|
*"build_runner"*)
|
||||||
|
mkdir -p lib/models
|
||||||
|
cp source.txt lib/models/model.g.dart
|
||||||
|
cp source.txt lib/models/model.freezed.dart
|
||||||
|
printf '%s\n' "$*" > build-runner-args.txt
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
subprocess.run(["git", "add", "."], cwd=self.root, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-qm", "fixture"], cwd=self.root, check=True)
|
||||||
|
self.env = os.environ | {
|
||||||
|
"PATH": f"{self.bin}:{os.environ['PATH']}",
|
||||||
|
"REAL_PYTHON": sys.executable,
|
||||||
|
"TMPDIR": str(self.codegen_temp),
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temp.cleanup()
|
||||||
|
shutil.rmtree(self.codegen_temp, ignore_errors=True)
|
||||||
|
|
||||||
|
def generated_state(self) -> dict[str, str]:
|
||||||
|
state = {}
|
||||||
|
for relative in GENERATED_PATHS:
|
||||||
|
path = self.root / relative
|
||||||
|
if path.is_file():
|
||||||
|
state[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
for path in (self.root / "lib").rglob("*.dart"):
|
||||||
|
relative = path.relative_to(self.root).as_posix()
|
||||||
|
if relative.endswith(".g.dart") or relative.endswith(".freezed.dart"):
|
||||||
|
state.setdefault(relative, hashlib.sha256(path.read_bytes()).hexdigest())
|
||||||
|
return state
|
||||||
|
|
||||||
|
def git_status(self) -> bytes:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "status", "--porcelain=v1", "-z"],
|
||||||
|
cwd=self.root,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
def run_codegen(self, *arguments: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["bash", "scripts/codegen.sh", *arguments],
|
||||||
|
cwd=self.root,
|
||||||
|
env=self.env if env is None else env,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def assert_isolation_cleaned_up(self) -> None:
|
||||||
|
worktrees = subprocess.run(
|
||||||
|
["git", "worktree", "list", "--porcelain"],
|
||||||
|
cwd=self.root,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout
|
||||||
|
self.assertEqual(worktrees.count("worktree "), 1)
|
||||||
|
self.assertEqual(list(self.codegen_temp.glob("plezy-codegen-check-*")), [])
|
||||||
|
|
||||||
|
def test_stale_check_reports_sorted_paths_without_changing_caller(self) -> None:
|
||||||
|
(self.root / "source.txt").write_text("version two\n", encoding="utf-8")
|
||||||
|
before = self.generated_state()
|
||||||
|
status_before = self.git_status()
|
||||||
|
|
||||||
|
result = self.run_codegen("--check")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
result.stderr.splitlines(),
|
||||||
|
[
|
||||||
|
"Generated files are out of date:",
|
||||||
|
*(f" {relative}" for relative in GENERATED_PATHS),
|
||||||
|
"Run 'scripts/codegen.sh' and commit the result.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(self.generated_state(), before)
|
||||||
|
self.assertEqual(self.git_status(), status_before)
|
||||||
|
self.assert_isolation_cleaned_up()
|
||||||
|
|
||||||
|
def test_generator_failure_propagates_without_partial_writes(self) -> None:
|
||||||
|
(self.root / "source.txt").write_text("version two\n", encoding="utf-8")
|
||||||
|
before = self.generated_state()
|
||||||
|
status_before = self.git_status()
|
||||||
|
|
||||||
|
result = self.run_codegen("--check", env=self.env | {"FAIL_DART": "7"})
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 7)
|
||||||
|
self.assertEqual(self.generated_state(), before)
|
||||||
|
self.assertEqual(self.git_status(), status_before)
|
||||||
|
self.assert_isolation_cleaned_up()
|
||||||
|
|
||||||
|
def test_index_boundary_requires_matching_staged_outputs(self) -> None:
|
||||||
|
(self.root / "source.txt").write_text("version two\n", encoding="utf-8")
|
||||||
|
self.assertEqual(self.run_codegen().returncode, 0)
|
||||||
|
unstaged_state = self.generated_state()
|
||||||
|
self.assertEqual(self.run_codegen("--check").returncode, 1)
|
||||||
|
self.assertEqual(self.generated_state(), unstaged_state)
|
||||||
|
|
||||||
|
subprocess.run(["git", "add", "source.txt", "lib", "server/relay_protocol_gen.go"], cwd=self.root, check=True)
|
||||||
|
self.assertEqual(self.run_codegen("--check").returncode, 0)
|
||||||
|
|
||||||
|
incorrect = self.root / "lib" / "models" / "model.g.dart"
|
||||||
|
incorrect.write_text("incorrect staged output\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "add", str(incorrect.relative_to(self.root))], cwd=self.root, check=True)
|
||||||
|
status_before = self.git_status()
|
||||||
|
|
||||||
|
result = self.run_codegen("--check")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("lib/models/model.g.dart", result.stderr)
|
||||||
|
self.assertEqual(incorrect.read_text(encoding="utf-8"), "incorrect staged output\n")
|
||||||
|
self.assertEqual(self.git_status(), status_before)
|
||||||
|
|
||||||
|
def test_deleted_and_untracked_outputs_are_reported_without_repair(self) -> None:
|
||||||
|
deleted = self.root / "lib" / "models" / "model.g.dart"
|
||||||
|
deleted.unlink()
|
||||||
|
untracked = self.root / "lib" / "models" / "extra.g.dart"
|
||||||
|
untracked.write_text("caller sentinel\n", encoding="utf-8")
|
||||||
|
status_before = self.git_status()
|
||||||
|
|
||||||
|
result = self.run_codegen("--check")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("lib/models/extra.g.dart", result.stderr)
|
||||||
|
self.assertIn("lib/models/model.g.dart", result.stderr)
|
||||||
|
self.assertFalse(deleted.exists())
|
||||||
|
self.assertEqual(untracked.read_text(encoding="utf-8"), "caller sentinel\n")
|
||||||
|
self.assertEqual(self.git_status(), status_before)
|
||||||
|
|
||||||
|
def test_write_mode_updates_outputs_and_forwards_build_runner_arguments(self) -> None:
|
||||||
|
(self.root / "source.txt").write_text("version two\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = self.run_codegen("--build-filter=lib/models/**")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0)
|
||||||
|
for relative in GENERATED_PATHS:
|
||||||
|
self.assertEqual((self.root / relative).read_text(encoding="utf-8"), "version two\n")
|
||||||
|
self.assertEqual(
|
||||||
|
(self.root / "build-runner-args.txt").read_text(encoding="utf-8"),
|
||||||
|
"run build_runner build --delete-conflicting-outputs --build-filter=lib/models/**\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(SCRIPT_DIR))
|
||||||
|
|
||||||
|
from check_workflow_action_pins import iter_uses_references, main, validate_reference
|
||||||
|
|
||||||
|
SHA = "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowActionPinsTest(unittest.TestCase):
|
||||||
|
def test_accepts_local_and_full_sha_references(self) -> None:
|
||||||
|
self.assertIsNone(validate_reference("./.github/actions/local"))
|
||||||
|
self.assertIsNone(validate_reference(f"actions/checkout@{SHA}"))
|
||||||
|
self.assertIsNone(validate_reference(f"owner/repository/sub/action@{SHA}"))
|
||||||
|
|
||||||
|
def test_rejects_mutable_dynamic_and_malformed_references(self) -> None:
|
||||||
|
references = [
|
||||||
|
"actions/checkout@v4",
|
||||||
|
"owner/action@latest",
|
||||||
|
"owner/action@main",
|
||||||
|
"owner/action@0123456",
|
||||||
|
"owner/action@${{ inputs.ref }}",
|
||||||
|
"docker://alpine:3",
|
||||||
|
"not-a-reference",
|
||||||
|
]
|
||||||
|
for reference in references:
|
||||||
|
with self.subTest(reference=reference):
|
||||||
|
self.assertIsNotNone(validate_reference(reference))
|
||||||
|
|
||||||
|
def test_parses_mapping_locations_without_comments_or_block_text(self) -> None:
|
||||||
|
fixture = f'''\
|
||||||
|
name: policy
|
||||||
|
jobs:
|
||||||
|
reusable:
|
||||||
|
uses: "owner/workflows/.github/workflows/check.yml@{SHA}" # reviewed
|
||||||
|
steps:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# - uses: actions/checkout@v4
|
||||||
|
- uses: 'actions/checkout@{SHA}' # v4
|
||||||
|
- run: |
|
||||||
|
echo "uses: owner/action@main"
|
||||||
|
'''
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "workflow.yaml"
|
||||||
|
path.write_text(fixture, encoding="utf-8")
|
||||||
|
references = list(iter_uses_references(path))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
references,
|
||||||
|
[
|
||||||
|
(4, f"owner/workflows/.github/workflows/check.yml@{SHA}"),
|
||||||
|
(9, f"actions/checkout@{SHA}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_mutable_flow_style_reference(self) -> None:
|
||||||
|
fixture = f"""\
|
||||||
|
jobs: {{ pinned: {{ uses: actions/checkout@{SHA} }}, mutable: {{ uses: owner/action@main }} }}
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "workflow.yaml"
|
||||||
|
path.write_text(fixture, encoding="utf-8")
|
||||||
|
references = list(iter_uses_references(path))
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr):
|
||||||
|
status = main([str(path)])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
references,
|
||||||
|
[
|
||||||
|
(1, f"actions/checkout@{SHA}"),
|
||||||
|
(1, "owner/action@main"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("owner/action@main", stderr.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_quoted_and_escaped_block_style_keys(self) -> None:
|
||||||
|
fixture = f"""\
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
steps:
|
||||||
|
- "uses": owner/action@main
|
||||||
|
- "us\\x65s": owner/other@latest
|
||||||
|
- "us\\u0065s": actions/checkout@{SHA}
|
||||||
|
- 'uses': actions/checkout@{SHA}
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "workflow.yaml"
|
||||||
|
path.write_text(fixture, encoding="utf-8")
|
||||||
|
references = list(iter_uses_references(path))
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr):
|
||||||
|
status = main([str(path)])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
references,
|
||||||
|
[
|
||||||
|
(4, "owner/action@main"),
|
||||||
|
(5, "owner/other@latest"),
|
||||||
|
(6, f"actions/checkout@{SHA}"),
|
||||||
|
(7, f"actions/checkout@{SHA}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("owner/action@main", stderr.getvalue())
|
||||||
|
self.assertIn("owner/other@latest", stderr.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_folded_and_literal_uses_scalars(self) -> None:
|
||||||
|
fixture = f"""\
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
steps:
|
||||||
|
- uses: >-
|
||||||
|
owner/folded@main
|
||||||
|
- uses: |
|
||||||
|
owner/literal@latest
|
||||||
|
- uses: >-
|
||||||
|
actions/checkout@{SHA}
|
||||||
|
- uses: |-
|
||||||
|
actions/checkout@{SHA}
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "workflow.yaml"
|
||||||
|
path.write_text(fixture, encoding="utf-8")
|
||||||
|
references = list(iter_uses_references(path))
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr):
|
||||||
|
status = main([str(path)])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
references,
|
||||||
|
[
|
||||||
|
(4, "owner/folded@main"),
|
||||||
|
(6, "owner/literal@latest"),
|
||||||
|
(8, f"actions/checkout@{SHA}"),
|
||||||
|
(10, f"actions/checkout@{SHA}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("owner/folded@main", stderr.getvalue())
|
||||||
|
self.assertIn("owner/literal@latest", stderr.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_explicit_mapping_uses_keys(self) -> None:
|
||||||
|
fixture = f"""\
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
steps:
|
||||||
|
- ? uses
|
||||||
|
: owner/explicit@main
|
||||||
|
- ? "us\\x65s"
|
||||||
|
: actions/checkout@{SHA}
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "workflow.yaml"
|
||||||
|
path.write_text(fixture, encoding="utf-8")
|
||||||
|
references = list(iter_uses_references(path))
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr):
|
||||||
|
status = main([str(path)])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
references,
|
||||||
|
[
|
||||||
|
(4, "owner/explicit@main"),
|
||||||
|
(6, f"actions/checkout@{SHA}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("owner/explicit@main", stderr.getvalue())
|
||||||
|
|
||||||
|
def test_parses_flow_sequence_mapping_pairs(self) -> None:
|
||||||
|
fixture = (
|
||||||
|
f"steps: [uses: owner/sequence@main, uses: actions/checkout@{SHA}]\n"
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "workflow.yaml"
|
||||||
|
path.write_text(fixture, encoding="utf-8")
|
||||||
|
references = list(iter_uses_references(path))
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr):
|
||||||
|
status = main([str(path)])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
references,
|
||||||
|
[
|
||||||
|
(1, "owner/sequence@main"),
|
||||||
|
(1, f"actions/checkout@{SHA}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("owner/sequence@main", stderr.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
def test_fails_closed_on_multiline_aliased_and_tagged_keys(self) -> None:
|
||||||
|
fixture = """\
|
||||||
|
uses_key: &uses-key uses
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
steps:
|
||||||
|
- "u\\
|
||||||
|
ses": owner/multiline@main
|
||||||
|
- *uses-key: owner/alias@main
|
||||||
|
- !!str uses: owner/tagged@main
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "workflow.yaml"
|
||||||
|
path.write_text(fixture, encoding="utf-8")
|
||||||
|
references = list(iter_uses_references(path))
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr):
|
||||||
|
status = main([str(path)])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
references,
|
||||||
|
[
|
||||||
|
(5, "<unsupported multiline, tagged, anchored, or aliased mapping key>"),
|
||||||
|
(7, "<unsupported multiline, tagged, anchored, or aliased mapping key>"),
|
||||||
|
(8, "<unsupported multiline, tagged, anchored, or aliased mapping key>"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("workflow.yaml:5", stderr.getvalue())
|
||||||
|
self.assertIn("workflow.yaml:7", stderr.getvalue())
|
||||||
|
self.assertIn("workflow.yaml:8", stderr.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_treat_github_expressions_as_yaml_flow_mappings(self) -> None:
|
||||||
|
fixture = f"""\
|
||||||
|
if: ${{{{ always() && !contains(needs.*.result, 'failure') }}}}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@{SHA}
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "workflow.yaml"
|
||||||
|
path.write_text(fixture, encoding="utf-8")
|
||||||
|
references = list(iter_uses_references(path))
|
||||||
|
|
||||||
|
self.assertEqual(references, [(3, f"actions/checkout@{SHA}")])
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_scalar_ends_at_inferred_content_indentation(self) -> None:
|
||||||
|
fixture = '''\
|
||||||
|
jobs:
|
||||||
|
steps:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: |
|
||||||
|
Multiline step name
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
'''
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "workflow.yaml"
|
||||||
|
path.write_text(fixture, encoding="utf-8")
|
||||||
|
references = list(iter_uses_references(path))
|
||||||
|
|
||||||
|
self.assertEqual(references, [(7, "actions/checkout@v4")])
|
||||||
|
|
||||||
|
def test_cli_reports_all_violations_with_file_and_line(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
first = root / "first.yml"
|
||||||
|
second = root / "second.yaml"
|
||||||
|
first.write_text("steps:\n - uses: actions/checkout@v4\n", encoding="utf-8")
|
||||||
|
second.write_text("jobs:\n call:\n uses: owner/workflow@main\n", encoding="utf-8")
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(stderr):
|
||||||
|
status = main([str(first), str(second)])
|
||||||
|
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
output = stderr.getvalue()
|
||||||
|
self.assertIn("first.yml:2", output)
|
||||||
|
self.assertIn("second.yaml:3", output)
|
||||||
|
self.assertIn("actions/checkout@v4", output)
|
||||||
|
self.assertIn("owner/workflow@main", output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parent / "format_native.sh"
|
||||||
|
DIGEST = "a16be01dcc480aab2f55f444b620142152f66e31564b3b9376506d624c28a2ad"
|
||||||
|
JAVA_DIAGNOSTIC = (
|
||||||
|
"A working JDK 17+ is required for Kotlin formatting. "
|
||||||
|
"Set JAVA_HOME to a JDK 17+ installation or put java on PATH."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def executable(path: Path, contents: str) -> Path:
|
||||||
|
path.write_text(contents, encoding="utf-8")
|
||||||
|
path.chmod(path.stat().st_mode | stat.S_IXUSR)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class NativeFormatterTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
(self.root / "scripts").mkdir()
|
||||||
|
shutil.copy2(SCRIPT, self.root / "scripts" / "format_native.sh")
|
||||||
|
(self.root / "android").mkdir()
|
||||||
|
(self.root / "android" / "fixture.kt").write_text("class Fixture\n", encoding="utf-8")
|
||||||
|
|
||||||
|
self.bin = self.root / "bin"
|
||||||
|
self.bin.mkdir()
|
||||||
|
executable(self.bin / "git", "#!/bin/bash\nprintf 'android/fixture.kt\\0'\n")
|
||||||
|
executable(
|
||||||
|
self.bin / "shasum",
|
||||||
|
f"""#!/bin/bash
|
||||||
|
for file do :; done
|
||||||
|
case "${{HASH_MODE:-valid}}" in
|
||||||
|
valid) digest={DIGEST} ;;
|
||||||
|
invalid) digest={'0' * 64} ;;
|
||||||
|
cache-invalid)
|
||||||
|
case "$file" in
|
||||||
|
*/ktlint-1.5.0) digest={'0' * 64} ;;
|
||||||
|
*) digest={DIGEST} ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '%s %s\\n' "$digest" "$file"
|
||||||
|
printf 'shasum\\n' >> "$HASH_MARKER"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
self.download = executable(
|
||||||
|
self.root / "download-ktlint",
|
||||||
|
"""#!/bin/bash
|
||||||
|
set -e
|
||||||
|
java -version >/dev/null 2>&1
|
||||||
|
printf '%s\n' "$*" >> "$KTLINT_ARGS_MARKER"
|
||||||
|
printf 'launched\n' >> "$KTLINT_MARKER"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
executable(
|
||||||
|
self.bin / "curl",
|
||||||
|
"""#!/bin/bash
|
||||||
|
printf 'downloaded\n' >> "$CURL_MARKER"
|
||||||
|
out=''
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
if [ "$1" = '-o' ]; then out=$2; shift 2; else shift; fi
|
||||||
|
done
|
||||||
|
cp "$FAKE_DOWNLOAD" "$out"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
self.env = os.environ.copy()
|
||||||
|
self.env.pop("JAVA_HOME", None)
|
||||||
|
self.env.update(
|
||||||
|
{
|
||||||
|
"PATH": f"{self.bin}:{self.env['PATH']}",
|
||||||
|
"FAKE_DOWNLOAD": str(self.download),
|
||||||
|
"CURL_MARKER": str(self.root / "curl.marker"),
|
||||||
|
"HASH_MARKER": str(self.root / "hash.marker"),
|
||||||
|
"JAVA_MARKER": str(self.root / "java.marker"),
|
||||||
|
"KTLINT_ARGS_MARKER": str(self.root / "ktlint-args.marker"),
|
||||||
|
"KTLINT_MARKER": str(self.root / "ktlint.marker"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache(self) -> Path:
|
||||||
|
return self.root / ".dart_tool" / "native-format" / "ktlint-1.5.0"
|
||||||
|
|
||||||
|
def java(self, directory: Path, version: str | None, *, identity: str = "path", status: int = 0) -> Path:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
if version is None:
|
||||||
|
output = "echo 'unrecognized java output' >&2"
|
||||||
|
else:
|
||||||
|
output = f"echo 'openjdk version \"{version}\"' >&2"
|
||||||
|
return executable(
|
||||||
|
directory / "java",
|
||||||
|
f"""#!/bin/bash
|
||||||
|
printf '{identity} %s\\n' "$*" >> "$JAVA_MARKER"
|
||||||
|
{output}
|
||||||
|
exit {status}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def install_cache(self, contents: bytes | None = None) -> None:
|
||||||
|
self.cache.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if contents is None:
|
||||||
|
shutil.copy2(self.download, self.cache)
|
||||||
|
else:
|
||||||
|
self.cache.write_bytes(contents)
|
||||||
|
self.cache.chmod(self.cache.stat().st_mode | stat.S_IXUSR)
|
||||||
|
|
||||||
|
def reset_markers(self) -> None:
|
||||||
|
for marker in self.root.glob("*.marker"):
|
||||||
|
marker.unlink()
|
||||||
|
|
||||||
|
def run_script(
|
||||||
|
self, *arguments: str, env: dict[str, str] | None = None
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["/bin/bash", "scripts/format_native.sh", *arguments],
|
||||||
|
cwd=self.root,
|
||||||
|
env=self.env if env is None else env,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def assert_java_rejection(self, result: subprocess.CompletedProcess[str]) -> None:
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertEqual(result.stderr.splitlines().count(JAVA_DIAGNOSTIC), 1)
|
||||||
|
self.assertNotIn("integer expression expected", result.stderr)
|
||||||
|
self.assertFalse((self.root / "curl.marker").exists())
|
||||||
|
self.assertFalse((self.root / "ktlint.marker").exists())
|
||||||
|
|
||||||
|
def test_unusable_java_rejects_cold_and_warm_cache_before_execution(self) -> None:
|
||||||
|
self.java(self.bin, "17.0.12", status=1)
|
||||||
|
self.assert_java_rejection(self.run_script("--check"))
|
||||||
|
|
||||||
|
self.install_cache()
|
||||||
|
self.reset_markers()
|
||||||
|
self.assert_java_rejection(self.run_script("--check"))
|
||||||
|
|
||||||
|
def test_java_home_runtime_and_ktlint_arguments_are_used_by_check_and_fix(self) -> None:
|
||||||
|
self.java(self.bin, "17.0.12", identity="path-shim", status=1)
|
||||||
|
home = self.root / "jdk home"
|
||||||
|
self.java(home / "bin", "17.0.12", identity="java-home")
|
||||||
|
env = self.env | {"JAVA_HOME": str(home)}
|
||||||
|
|
||||||
|
expected_arguments = {
|
||||||
|
"--check": "android/fixture.kt",
|
||||||
|
"--fix": "-F android/fixture.kt",
|
||||||
|
}
|
||||||
|
for mode, expected in expected_arguments.items():
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
self.reset_markers()
|
||||||
|
result = self.run_script(mode, env=env)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
java_calls = (self.root / "java.marker").read_text(encoding="utf-8").splitlines()
|
||||||
|
self.assertEqual(java_calls, ["java-home -version", "java-home -version"])
|
||||||
|
self.assertEqual(
|
||||||
|
(self.root / "ktlint-args.marker").read_text(encoding="utf-8").strip(),
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_java_version_support_floor_fails_closed(self) -> None:
|
||||||
|
for version in ("17.0.12", "21.0.2"):
|
||||||
|
with self.subTest(accepted=version):
|
||||||
|
self.java(self.bin, version)
|
||||||
|
self.reset_markers()
|
||||||
|
result = self.run_script("--check")
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertTrue((self.root / "ktlint.marker").exists())
|
||||||
|
|
||||||
|
for version, status in (("1.8.0_402", 0), ("16.0.2", 0), (None, 0), ("17.0.12", 1)):
|
||||||
|
with self.subTest(rejected=version, status=status):
|
||||||
|
self.java(self.bin, version, status=status)
|
||||||
|
self.reset_markers()
|
||||||
|
self.assert_java_rejection(self.run_script("--check"))
|
||||||
|
|
||||||
|
def test_cache_verification_replacement_and_mismatch_cleanup(self) -> None:
|
||||||
|
self.java(self.bin, "17.0.12")
|
||||||
|
self.install_cache()
|
||||||
|
valid = self.run_script("--check")
|
||||||
|
self.assertEqual(valid.returncode, 0, valid.stderr)
|
||||||
|
self.assertFalse((self.root / "curl.marker").exists())
|
||||||
|
self.assertEqual((self.root / "ktlint.marker").read_text(encoding="utf-8").splitlines(), ["launched"])
|
||||||
|
|
||||||
|
self.install_cache(b"invalid cache sentinel\n")
|
||||||
|
self.reset_markers()
|
||||||
|
replaced = self.run_script("--check", env=self.env | {"HASH_MODE": "cache-invalid"})
|
||||||
|
self.assertEqual(replaced.returncode, 0, replaced.stderr)
|
||||||
|
self.assertEqual(self.cache.read_bytes(), self.download.read_bytes())
|
||||||
|
self.assertTrue((self.root / "curl.marker").exists())
|
||||||
|
self.assertTrue((self.root / "ktlint.marker").exists())
|
||||||
|
|
||||||
|
sentinel = b"preserve invalid cache\n"
|
||||||
|
self.install_cache(sentinel)
|
||||||
|
self.reset_markers()
|
||||||
|
mismatch = self.run_script("--check", env=self.env | {"HASH_MODE": "invalid"})
|
||||||
|
self.assertNotEqual(mismatch.returncode, 0)
|
||||||
|
self.assertIn("failed SHA-256 verification", mismatch.stderr)
|
||||||
|
self.assertEqual(self.cache.read_bytes(), sentinel)
|
||||||
|
self.assertFalse((self.root / "ktlint.marker").exists())
|
||||||
|
self.assertEqual(list(self.cache.parent.glob(".ktlint-1.5.0.*")), [])
|
||||||
|
|
||||||
|
def test_sha256sum_fallback_verifies_and_executes(self) -> None:
|
||||||
|
self.java(self.bin, "17.0.12")
|
||||||
|
minimal = self.root / "minimal-bin"
|
||||||
|
minimal.mkdir()
|
||||||
|
for name in ("git", "java", "curl"):
|
||||||
|
shutil.copy2(self.bin / name, minimal / name)
|
||||||
|
executable(
|
||||||
|
minimal / "sha256sum",
|
||||||
|
f"#!/bin/bash\nfor file do :; done\nprintf '{DIGEST} %s\\n' \"$file\"\nprintf 'sha256sum\\n' >> \"$HASH_MARKER\"\n",
|
||||||
|
)
|
||||||
|
for name, source in {
|
||||||
|
"chmod": "/bin/chmod",
|
||||||
|
"cp": "/bin/cp",
|
||||||
|
"dirname": "/usr/bin/dirname",
|
||||||
|
"mkdir": "/bin/mkdir",
|
||||||
|
"mktemp": "/usr/bin/mktemp",
|
||||||
|
"mv": "/bin/mv",
|
||||||
|
"rm": "/bin/rm",
|
||||||
|
}.items():
|
||||||
|
(minimal / name).symlink_to(source)
|
||||||
|
|
||||||
|
result = self.run_script("--check", env=self.env | {"PATH": str(minimal)})
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual((self.root / "hash.marker").read_text(encoding="utf-8").splitlines(), ["sha256sum"])
|
||||||
|
self.assertTrue((self.root / "ktlint.marker").exists())
|
||||||
|
|
||||||
|
def test_repository_without_kotlin_does_not_require_java_or_ktlint(self) -> None:
|
||||||
|
executable(self.bin / "git", "#!/bin/bash\nexit 0\n")
|
||||||
|
self.java(self.bin, "17.0.12", status=1)
|
||||||
|
|
||||||
|
result = self.run_script("--check")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
for marker in ("curl.marker", "hash.marker", "java.marker", "ktlint.marker"):
|
||||||
|
self.assertFalse((self.root / marker).exists())
|
||||||
|
self.assertFalse(self.cache.parent.exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user