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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .github/workflows/runner-catalog-trusted.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
name: Trusted runner catalog provenance

on:
pull_request_target:
paths:
- RUNNERS.md
- runner-catalog-manifest.json
- runner-profiles.json
- runner-profiles.yaml
- script/validate_runner_catalog.rb
- script/check_runner_catalog_lifecycle.rb
- script/check_runner_catalog_trust_boundary.rb
- .github/workflows/runner-catalog.yml
- .github/workflows/runner-catalog-trusted.yml
push:
branches: [main]
paths:
- RUNNERS.md
- runner-catalog-manifest.json
- runner-profiles.json
- runner-profiles.yaml
- script/validate_runner_catalog.rb
- script/check_runner_catalog_lifecycle.rb
- script/check_runner_catalog_trust_boundary.rb
- test/runner_catalog_test.rb
- test/runner_catalog_lifecycle_test.rb
- test/runner_catalog_trust_boundary_test.rb
- .github/workflows/runner-catalog.yml
- .github/workflows/runner-catalog-trusted.yml

permissions:
contents: read

jobs:
bootstrap:
if: github.event_name == 'push'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Exercise trusted validator contract
run: ruby test/runner_catalog_test.rb
- name: Exercise catalog lifecycle contract
run: ruby test/runner_catalog_lifecycle_test.rb
- name: Exercise trust boundary contract
run: ruby test/runner_catalog_trust_boundary_test.rb

verify-published:
if: github.event_name == 'push'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Materialize previous main tree
env:
BEFORE: ${{ github.event.before }}
run: |
mkdir -p .previous
if [[ -z "$BEFORE" || "$BEFORE" == "0000000000000000000000000000000000000000" ]]; then
exit 0
fi
git cat-file -e "${BEFORE}^{commit}"
for path in RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml; do
if git cat-file -e "${BEFORE}:${path}" 2>/dev/null; then
mkdir -p ".previous/$(dirname "$path")"
git show "${BEFORE}:${path}" > ".previous/$path"
fi
done
- name: Check catalog lifecycle
id: lifecycle
run: echo "action=$(ruby script/check_runner_catalog_lifecycle.rb --previous-root .previous --current-root .)" >> "$GITHUB_OUTPUT"
- name: Resolve canonical source revision
if: steps.lifecycle.outputs.action == 'validate'
id: catalog-provenance
shell: ruby {0}
run: |
require "json"

catalog = JSON.parse(File.read("runner-profiles.json"))
provenance = catalog.fetch("metadata").fetch("provenance")
abort "missing source revision" unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/)
File.open(ENV.fetch("GITHUB_OUTPUT"), "a") { |output| output.puts("revision=#{provenance.fetch("revision")}") }
- name: Checkout canonical runner source
if: steps.lifecycle.outputs.action == 'validate'
uses: actions/checkout@v6
with:
repository: akua-dev/gitops
ref: ${{ steps.catalog-provenance.outputs.revision }}
token: ${{ secrets.GITOPS_READ_TOKEN }}
path: .gitops-source
sparse-checkout: clusters/agentos/runner-platform/profiles.yaml
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Validate generated catalog against canonical source
if: steps.lifecycle.outputs.action == 'validate'
run: ruby script/validate_runner_catalog.rb --candidate-root . --source-root .gitops-source

verify:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-24.04
steps:
- name: Checkout trusted validator from base
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.sha }}
path: .trusted
persist-credentials: false
- name: Checkout candidate catalog
uses: actions/checkout@v6
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: .candidate
persist-credentials: false
- name: Verify trusted boundary
run: ruby .trusted/script/check_runner_catalog_trust_boundary.rb --trusted-root .trusted --candidate-root .candidate
- name: Exercise base-owned infrastructure
run: ruby .trusted/test/runner_catalog_test.rb && ruby .trusted/test/runner_catalog_lifecycle_test.rb && ruby .trusted/test/runner_catalog_trust_boundary_test.rb
- name: Check catalog lifecycle
id: lifecycle
run: echo "action=$(ruby .trusted/script/check_runner_catalog_lifecycle.rb --previous-root .trusted --current-root .candidate)" >> "$GITHUB_OUTPUT"
- name: Resolve canonical source revision
if: steps.lifecycle.outputs.action == 'validate'
id: catalog-provenance
shell: ruby {0}
run: |
require "json"

catalog = JSON.parse(File.read(".candidate/runner-profiles.json"))
provenance = catalog.fetch("metadata").fetch("provenance")
abort "missing source revision" unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/)
File.open(ENV.fetch("GITHUB_OUTPUT"), "a") { |output| output.puts("revision=#{provenance.fetch("revision")}") }
- name: Checkout canonical runner source
if: steps.lifecycle.outputs.action == 'validate'
uses: actions/checkout@v6
with:
repository: akua-dev/gitops
ref: ${{ steps.catalog-provenance.outputs.revision }}
token: ${{ secrets.GITOPS_READ_TOKEN }}
path: .gitops-source
sparse-checkout: clusters/agentos/runner-platform/profiles.yaml
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Validate generated catalog against canonical source
if: steps.lifecycle.outputs.action == 'validate'
run: ruby .trusted/script/validate_runner_catalog.rb --candidate-root .candidate --source-root .gitops-source
50 changes: 50 additions & 0 deletions .github/workflows/runner-catalog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Runner catalog validator bootstrap

on:
pull_request:
paths:
- script/validate_runner_catalog.rb
- script/check_runner_catalog_lifecycle.rb
- script/check_runner_catalog_trust_boundary.rb
- test/runner_catalog_test.rb
- test/runner_catalog_lifecycle_test.rb
- test/runner_catalog_trust_boundary_test.rb
- .github/workflows/runner-catalog.yml
- .github/workflows/runner-catalog-trusted.yml
- RUNNERS.md
- runner-catalog-manifest.json
- runner-profiles.json
- runner-profiles.yaml
push:
branches: [main]
paths:
- script/validate_runner_catalog.rb
- script/check_runner_catalog_lifecycle.rb
- script/check_runner_catalog_trust_boundary.rb
- test/runner_catalog_test.rb
- test/runner_catalog_lifecycle_test.rb
- test/runner_catalog_trust_boundary_test.rb
- .github/workflows/runner-catalog.yml
- .github/workflows/runner-catalog-trusted.yml
- RUNNERS.md
- runner-catalog-manifest.json
- runner-profiles.json
- runner-profiles.yaml

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Exercise trusted validator contract
run: ruby test/runner_catalog_test.rb
- name: Exercise catalog lifecycle contract
run: ruby test/runner_catalog_lifecycle_test.rb
- name: Exercise trust boundary contract
run: ruby test/runner_catalog_trust_boundary_test.rb
- name: Validate published catalog when present
if: hashFiles('runner-profiles.json') != ''
run: ruby script/validate_runner_catalog.rb
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,14 @@ This repository maintains the public GitHub organization profile for `akua-dev`.

The rendered organization profile lives in
[profile/README.md](profile/README.md).

This change installs the base-owned, secret-free validation infrastructure for
the provider-neutral runner catalog. It intentionally publishes no runner
catalog yet.

After this bootstrap merges to `main`, create the follow-up catalog publication
change from that merge commit. That change adds `RUNNERS.md`,
`runner-profiles.yaml`, `runner-profiles.json`, and
`runner-catalog-manifest.json`; its pull request is then checked by the
base-owned [`runner-catalog-trusted.yml`](.github/workflows/runner-catalog-trusted.yml)
workflow against the private `akua-dev/gitops` source.
59 changes: 59 additions & 0 deletions script/check_runner_catalog_lifecycle.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env ruby

require "optparse"

class RunnerCatalogLifecycleError < StandardError; end

class RunnerCatalogLifecycle
FILES = %w[RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml].freeze

def initialize(previous_root:, current_root:)
@previous_root = File.expand_path(previous_root)
@current_root = File.expand_path(current_root)
end

def action
previous = state(@previous_root)
current = state(@current_root)
case [previous, current]
when %w[absent absent], %w[absent complete], %w[complete complete]
previous == "absent" && current == "absent" ? "skip" : "validate"
when ["partial", "absent"], ["partial", "partial"], ["partial", "complete"]
fail_with("previous catalog is partial")
when ["absent", "partial"]
fail_with("current catalog is partial")
when ["complete", "absent"], ["complete", "partial"]
fail_with("published catalog cannot be deleted or partial")
else
fail_with("unsupported catalog lifecycle transition")
end
end

private

def state(root)
present = FILES.count { |path| File.file?(File.join(root, path)) }
return "absent" if present.zero?
return "complete" if present == FILES.length

"partial"
end

def fail_with(message)
raise RunnerCatalogLifecycleError, message
end
end

options = {}
OptionParser.new do |parser|
parser.on("--previous-root PATH") { |path| options[:previous_root] = path }
parser.on("--current-root PATH") { |path| options[:current_root] = path }
end.parse!

begin
abort "missing lifecycle roots" unless options.keys.sort == %i[current_root previous_root]
puts RunnerCatalogLifecycle.new(**options).action
rescue RunnerCatalogLifecycleError => error
warn error.message
exit 1
end
112 changes: 112 additions & 0 deletions script/check_runner_catalog_trust_boundary.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env ruby

require "digest"
require "optparse"
require "yaml"

class RunnerCatalogTrustBoundaryError < StandardError; end

class RunnerCatalogTrustBoundary
TRUST_FILES = [
".github/workflows/runner-catalog-trusted.yml",
"script/check_runner_catalog_trust_boundary.rb",
"script/check_runner_catalog_lifecycle.rb",
"script/validate_runner_catalog.rb"
].freeze
CATALOG_FILES = %w[RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml].freeze
TRUSTED_WORKFLOW_PATHS = [
*CATALOG_FILES,
"script/validate_runner_catalog.rb",
"script/check_runner_catalog_lifecycle.rb",
"script/check_runner_catalog_trust_boundary.rb",
".github/workflows/runner-catalog.yml",
".github/workflows/runner-catalog-trusted.yml"
].freeze

def initialize(trusted_root:, candidate_root:)
@trusted_root = File.expand_path(trusted_root)
@candidate_root = File.expand_path(candidate_root)
end

def validate!
trusted_workflow = parse_workflow(@trusted_root)
candidate_workflow = parse_workflow(@candidate_root)
validate_workflow_model!(trusted_workflow)
fail_with("trusted workflow semantic drift") unless workflow_model(trusted_workflow) == workflow_model(candidate_workflow)
TRUST_FILES.each do |path|
trusted = File.join(@trusted_root, path)
candidate = File.join(@candidate_root, path)
fail_with("trusted boundary file missing: #{path}") unless File.file?(trusted) && File.file?(candidate)
fail_with("trusted boundary file modified: #{path}") unless Digest::SHA256.file(trusted).hexdigest == Digest::SHA256.file(candidate).hexdigest
end
true
rescue KeyError, Psych::Exception, Errno::ENOENT => error
raise RunnerCatalogTrustBoundaryError, error.message
end

private

def parse_workflow(root)
path = File.join(root, TRUST_FILES.first)
fail_with("trusted workflow missing") unless File.file?(path)
YAML.safe_load(File.read(path), aliases: false)
end

def workflow_model(workflow)
trigger = workflow.fetch(true)
verify = workflow.fetch("jobs").fetch("verify")
{
"pullRequestTargetPaths" => trigger.fetch("pull_request_target").fetch("paths").sort,
"pushPaths" => trigger.fetch("push").fetch("paths").sort,
"verifyIf" => verify.fetch("if"),
"verifySteps" => verify.fetch("steps").map { |step| step_model(step) }
}
end

def step_model(step)
{
"name" => step.fetch("name", nil),
"uses" => step.fetch("uses", nil),
"run" => step.fetch("run", nil),
"if" => step.fetch("if", nil),
"with" => step.fetch("with", {}).slice("ref", "path", "repository", "token", "persist-credentials", "sparse-checkout", "sparse-checkout-cone-mode")
}
end

def validate_workflow_model!(workflow)
model = workflow_model(workflow)
expected_paths = TRUSTED_WORKFLOW_PATHS.sort
fail_with("trusted workflow catalog paths drift") unless model.fetch("pullRequestTargetPaths") == expected_paths
fail_with("trusted workflow push paths drift") unless model.fetch("pushPaths") == (expected_paths + ["test/runner_catalog_lifecycle_test.rb", "test/runner_catalog_test.rb", "test/runner_catalog_trust_boundary_test.rb"]).sort
fail_with("trusted workflow verify trigger drift") unless model.fetch("verifyIf") == "github.event_name == 'pull_request_target'"
steps = workflow.fetch("jobs").fetch("verify").fetch("steps")
boundary_index = steps.index { |step| step.fetch("name", "") == "Verify trusted boundary" }
tests_index = steps.index { |step| step.fetch("name", "") == "Exercise base-owned infrastructure" }
source_index = steps.index { |step| step.fetch("with", {}).fetch("repository", nil) == "akua-dev/gitops" }
fail_with("trusted workflow boundary step missing") unless boundary_index
fail_with("base-owned infrastructure tests missing") unless tests_index
fail_with("trusted source checkout missing") unless source_index
fail_with("trusted workflow boundary ordering drift") unless boundary_index < source_index && tests_index < source_index
fail_with("trusted boundary must use base-owned files") unless steps.fetch(boundary_index).fetch("run") == "ruby .trusted/script/check_runner_catalog_trust_boundary.rb --trusted-root .trusted --candidate-root .candidate"
fail_with("base-owned tests must use trusted files") unless steps.fetch(tests_index).fetch("run") == "ruby .trusted/test/runner_catalog_test.rb && ruby .trusted/test/runner_catalog_lifecycle_test.rb && ruby .trusted/test/runner_catalog_trust_boundary_test.rb"
end

def fail_with(message)
raise RunnerCatalogTrustBoundaryError, message
end
end

options = {}
OptionParser.new do |parser|
parser.on("--trusted-root PATH") { |path| options[:trusted_root] = path }
parser.on("--candidate-root PATH") { |path| options[:candidate_root] = path }
end.parse!

begin
abort "missing trust-boundary roots" unless options.keys.sort == %i[candidate_root trusted_root]
RunnerCatalogTrustBoundary.new(**options).validate!
puts "trusted"
rescue RunnerCatalogTrustBoundaryError => error
warn error.message
exit 1
end
Loading