app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
A.github/workflows/package.yml | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.github/workflows/source.yml | 46++++++++++++++++++++++++++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt | 18++++++++++++++++--
Mbuild.gradle.kts | 1+
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt | 5+++++
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/VerificationLanes.kt | 54++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mconfig/verification/lanes-v1.properties | 8+++++++-
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