commit 37cf617f79609d683786574f4b6d9b1cadc3ca9b
parent d0d70bceec6704386de3983c553c897794421ad0
Author: triesap <tyson@radroots.org>
Date: Mon, 10 Aug 2026 16:48:52 +0000
ci: execute the public verification lanes
- add immutable least-privilege source and package workflows
- run repository-owned checks across the supported host matrix
- derive build provenance from the checked-out commit and manifest
- validate workflow commands, permissions, runners, and action pins
Diffstat:
7 files changed, 184 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
@@ -0,0 +1,57 @@
+name: Package verification
+
+on:
+ pull_request:
+ push:
+ branches:
+ - dev
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ package:
+ strategy:
+ fail-fast: false
+ matrix:
+ os:
+ - ubuntu-latest
+ - macos-latest
+ - windows-latest
+ runs-on: ${{ matrix.os }}
+ steps:
+ - name: Check out source
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Install Java 21
+ uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
+ with:
+ distribution: temurin
+ java-version: "21"
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772
+ with:
+ toolchain: 1.97.1
+ components: clippy,rustfmt
+ - name: Install cargo-deny
+ uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2
+ with:
+ tool: cargo-deny
+ - name: Install Windows package tools
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: choco install make wixtoolset --no-progress --yes
+ - name: Export source provenance
+ shell: bash
+ run: |
+ radroots_revision=$(sed -n 's/^canonical_radroots_revision = "\([0-9a-f]\{40\}\)"$/\1/p' core/provenance/studio-import-v1.toml)
+ test ${#radroots_revision} -eq 40
+ echo "HARVESTCIRCLE_BUILD_SOURCE_COMMIT=$GITHUB_SHA" >> "$GITHUB_ENV"
+ echo "HARVESTCIRCLE_BUILD_SOURCE_DIRTY=false" >> "$GITHUB_ENV"
+ echo "HARVESTCIRCLE_BUILD_RADROOTS_REVISION=$radroots_revision" >> "$GITHUB_ENV"
+ echo "SOURCE_DATE_EPOCH=$(git show -s --format=%ct "$GITHUB_SHA")" >> "$GITHUB_ENV"
+ - name: Verify host package
+ run: make package-check
diff --git a/.github/workflows/source.yml b/.github/workflows/source.yml
@@ -0,0 +1,46 @@
+name: Source verification
+
+on:
+ pull_request:
+ push:
+ branches:
+ - dev
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ source:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out source
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Install Java 21
+ uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
+ with:
+ distribution: temurin
+ java-version: "21"
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772
+ with:
+ toolchain: 1.97.1
+ components: clippy,rustfmt
+ - name: Install cargo-deny
+ uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2
+ with:
+ tool: cargo-deny
+ - name: Export source provenance
+ shell: bash
+ run: |
+ radroots_revision=$(sed -n 's/^canonical_radroots_revision = "\([0-9a-f]\{40\}\)"$/\1/p' core/provenance/studio-import-v1.toml)
+ test ${#radroots_revision} -eq 40
+ echo "HARVESTCIRCLE_BUILD_SOURCE_COMMIT=$GITHUB_SHA" >> "$GITHUB_ENV"
+ echo "HARVESTCIRCLE_BUILD_SOURCE_DIRTY=false" >> "$GITHUB_ENV"
+ echo "HARVESTCIRCLE_BUILD_RADROOTS_REVISION=$radroots_revision" >> "$GITHUB_ENV"
+ echo "SOURCE_DATE_EPOCH=$(git show -s --format=%ct "$GITHUB_SHA")" >> "$GITHUB_ENV"
+ - name: Verify source
+ run: make source-check
diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt
@@ -97,11 +97,12 @@ class ProductNamespaceGuardTest {
"rs",
"sql",
"toml",
+ "txt",
"xml",
"yaml",
"yml",
)
- val textNames = setOf("Makefile", ".gitignore", "gradlew", "gradlew.bat")
+ val textNames = setOf(".gitattributes", ".gitignore", "AGENTS.md", "LICENSE", "Makefile", "NOTICE", "gradlew", "gradlew.bat")
val findings =
trackedFiles(root).flatMap { relative ->
buildList {
@@ -115,7 +116,20 @@ class ProductNamespaceGuardTest {
val path = root.resolve(relative)
if (relative != provenanceException && (path.extension in textExtensions || path.name in textNames)) {
- val inspected = path.readText().replace(repositoryUrlException, "")
+ var inspected = path.readText().replace(repositoryUrlException, "")
+ if (relative == "NOTICE") {
+ val legacyDisplayName = legacyProduct.replaceFirstChar { it.uppercase() }
+ inspected =
+ inspected
+ .replace("Radroots $legacyDisplayName application work", "")
+ .replace(provenanceException, "")
+ }
+ if (relative == "spec/harvestcircle_mvp_v1/UI_SURFACE_MAP.md") {
+ inspected = inspected.replace("round_${legacyProduct}_screen", "")
+ }
+ if (relative == ".github/workflows/source.yml" || relative == ".github/workflows/package.yml") {
+ inspected = inspected.replace(provenanceException, "")
+ }
if (inspected.lowercase().contains(legacyProduct)) {
add("$relative: legacy product name in tracked text")
}
diff --git a/build.gradle.kts b/build.gradle.kts
@@ -37,6 +37,7 @@ val verifyVerificationLanes by tasks.registering(VerifyVerificationLanes::class)
group = "verification"
description = "Validates forge-agnostic verification lanes and least-privilege policy."
policyFile.set(verificationLanesFile)
+ repositoryRoot.set(layout.projectDirectory)
}
val verifyFoundationBoundaries by tasks.registering(VerifyFoundationBoundaries::class) {
diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt
@@ -191,6 +191,9 @@ private class FoundationBoundaryAudit(
if (relative == "spec/harvestcircle_mvp_v1/UI_SURFACE_MAP.md") {
inspected = inspected.replace("round_${legacyProduct}_screen", "")
}
+ if (relative == ".github/workflows/source.yml" || relative == ".github/workflows/package.yml") {
+ inspected = inspected.replace("core/provenance/$legacyProduct-import-v1.toml", "")
+ }
if (inspected.lowercase().contains(legacyProduct)) {
findings += "$relative: legacy product name outside the exact provenance allowlist"
}
@@ -250,6 +253,8 @@ private class FoundationBoundaryAudit(
"docs/decisions/ADR-0008-public-specs-and-ci.md",
"docs/decisions/ADR-0009-canonical-manifest-digests.md",
"docs/decisions/ADR-0010-gap-aware-snapshot-delivery.md",
+ ".github/workflows/source.yml",
+ ".github/workflows/package.yml",
)
(requiredPublicFiles - inventory.toSet()).sorted().forEach { relative ->
findings += "$relative: required public repository file is missing"
diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/VerificationLanes.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/VerificationLanes.kt
@@ -1,8 +1,10 @@
package org.harvestcircle.gradle
import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
@@ -11,15 +13,21 @@ object VerificationLanes {
private val expected =
linkedMapOf(
"schema" to "harvestcircle.verification-lanes.v1",
- "orchestration" to "external-forge-agnostic",
+ "orchestration" to "github-actions",
"source.command" to "make source-check",
"source.runner" to "linux",
+ "source.workflow" to ".github/workflows/source.yml",
"source.permissions" to "contents:read",
"source.credentials" to "none",
"package.command" to "make package-check",
"package.runners" to "linux,macos,windows",
+ "package.workflow" to ".github/workflows/package.yml",
"package.permissions" to "contents:read",
"package.credentials" to "none",
+ "provenance.commit" to "HARVESTCIRCLE_BUILD_SOURCE_COMMIT",
+ "provenance.dirty" to "HARVESTCIRCLE_BUILD_SOURCE_DIRTY",
+ "provenance.radroots" to "HARVESTCIRCLE_BUILD_RADROOTS_REVISION",
+ "provenance.epoch" to "SOURCE_DATE_EPOCH",
"signing.command" to "make signing-check",
"signing.runner" to "macos",
"signing.permissions" to "contents:read",
@@ -56,13 +64,55 @@ abstract class VerifyVerificationLanes : DefaultTask() {
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val policyFile: RegularFileProperty
+ @get:Internal
+ abstract val repositoryRoot: DirectoryProperty
+
@TaskAction
fun verify() {
val source = policyFile.get().asFile.readText()
- check(VerificationLanes.parse(source).size == 18)
+ val policy = VerificationLanes.parse(source)
+ check(policy.size == 24)
check(runCatching { VerificationLanes.parse(source + "source.permissions=write") }.isFailure)
check(runCatching { VerificationLanes.parse(source.replace("contents:read", "contents:write")) }.isFailure)
check(runCatching { VerificationLanes.parse(source.replace("credentials=none", "credentials=all")) }.isFailure)
check(runCatching { VerificationLanes.parse(source.replace("source.runner=linux", "source.runner=macos")) }.isFailure)
+ val root = repositoryRoot.get().asFile.toPath()
+ val sourceWorkflow = root.resolve(policy.getValue("source.workflow")).toFile().readText()
+ val packageWorkflow = root.resolve(policy.getValue("package.workflow")).toFile().readText()
+ verifyWorkflow(sourceWorkflow, policy.getValue("source.command"))
+ verifyWorkflow(packageWorkflow, policy.getValue("package.command"))
+ check(sourceWorkflow.contains("runs-on: ubuntu-latest"))
+ listOf("ubuntu-latest", "macos-latest", "windows-latest").forEach { runner ->
+ check(packageWorkflow.contains("- $runner")) { "Package workflow is missing $runner" }
+ }
+ listOf(
+ policy.getValue("provenance.commit"),
+ policy.getValue("provenance.dirty"),
+ policy.getValue("provenance.radroots"),
+ policy.getValue("provenance.epoch"),
+ ).forEach { variable ->
+ check(sourceWorkflow.contains(variable)) { "Source workflow is missing provenance variable $variable" }
+ check(packageWorkflow.contains(variable)) { "Package workflow is missing provenance variable $variable" }
+ }
+ }
+
+ private fun verifyWorkflow(
+ source: String,
+ command: String,
+ ) {
+ check(Regex("(?m)^permissions:\\s*\\n\\s{2}contents: read$").containsMatchIn(source)) {
+ "Workflow permissions must be contents: read"
+ }
+ check(source.contains("run: $command")) { "Workflow does not invoke $command" }
+ check(source.contains("persist-credentials: false")) { "Checkout credentials must not persist" }
+ val actionPins = Regex("(?m)^\\s*uses:\\s+[^@\\s]+@([0-9a-f]{40})(?:\\s+#.*)?$").findAll(source).toList()
+ check(actionPins.isNotEmpty()) { "Workflow does not use any pinned actions" }
+ check(source.lineSequence().filter { "uses:" in it }.count() == actionPins.size) {
+ "Every workflow action must use a full immutable commit SHA"
+ }
+ val forbidden = listOf("contents: write", "id-token: write", "pull-requests: write", "secrets.", "publish", "deploy")
+ forbidden.forEach { token ->
+ check(!source.contains(token, ignoreCase = true)) { "Workflow contains forbidden capability $token" }
+ }
}
}
diff --git a/config/verification/lanes-v1.properties b/config/verification/lanes-v1.properties
@@ -1,13 +1,19 @@
schema=harvestcircle.verification-lanes.v1
-orchestration=external-forge-agnostic
+orchestration=github-actions
source.command=make source-check
source.runner=linux
+source.workflow=.github/workflows/source.yml
source.permissions=contents:read
source.credentials=none
package.command=make package-check
package.runners=linux,macos,windows
+package.workflow=.github/workflows/package.yml
package.permissions=contents:read
package.credentials=none
+provenance.commit=HARVESTCIRCLE_BUILD_SOURCE_COMMIT
+provenance.dirty=HARVESTCIRCLE_BUILD_SOURCE_DIRTY
+provenance.radroots=HARVESTCIRCLE_BUILD_RADROOTS_REVISION
+provenance.epoch=SOURCE_DATE_EPOCH
signing.command=make signing-check
signing.runner=macos
signing.permissions=contents:read