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
62 changes: 62 additions & 0 deletions .github/workflows/cache.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: exact external content cache
on:
pull_request:
branches: [dev]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: external-cache-${{ github.ref }}
cancel-in-progress: true
jobs:
warm:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
timeout-minutes: 7
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: rootform-dev/action/setup@aa619a35a674a0be901650379db3b0551ee645ad
with:
version: 0.1.0-pr.117.1
- name: Publish synthetic current-schema content to isolated TLS registry
run: node scripts/prepare-registry.mjs
- name: Seed excluded derived content before source-cache save
run: node scripts/assert-cache.mjs seed
- name: Prepare fresh home and persist exact source payload
uses: rootform-dev/action/init@aa619a35a674a0be901650379db3b0551ee645ad
with:
project: external-project
locked: 'true'
- name: Persist only synthetic selection lock for next job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: synthetic-lock
path: external-project/rootform.lock
retention-days: 1
if-no-files-found: error
- name: Stop owned registry
if: always()
run: docker rm --force rootform-action-registry || true
offline:
needs: warm
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: synthetic-lock
path: external-project
- name: Restore and reverify source without running registry
uses: rootform-dev/action/init@aa619a35a674a0be901650379db3b0551ee645ad
with:
version: 0.1.0-pr.117.1
project: external-project
locked: 'true'
offline: 'true'
- name: Prove source cache and exclusion of derived contents
run: node scripts/assert-cache.mjs verify
138 changes: 138 additions & 0 deletions .github/workflows/native.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
name: published CLI consumers
on:
pull_request:
branches: [dev]
paths-ignore:
- 'README.md'
- 'evidence/**'
- '.github/workflows/cache.yml'
- '.github/workflows/report.yml'
- 'scripts/assert-cache.mjs'
- 'scripts/prepare-registry.mjs'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: native-${{ github.ref }}
cancel-in-progress: true
jobs:
native:
strategy:
fail-fast: false
matrix:
include:
- target: linux-amd64
runner: ubuntu-24.04
- target: linux-arm64
runner: ubuntu-24.04-arm
- target: darwin-amd64
runner: macos-15-intel
- target: darwin-arm64
runner: macos-15
- target: windows-amd64
runner: windows-2025
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: rootform-dev/rootform
ref: 74bd643f3001a37f8b4f881c0ae33db047b6aba3
path: fixtures/rootform
sparse-checkout: |
examples/playground/shared-data-platform
policy-packs/baseline
cli/detect/testdata
persist-credentials: false
- name: Autonomous analysis and verified installation
id: analyze
uses: rootform-dev/action/analyze@aa619a35a674a0be901650379db3b0551ee645ad
with:
version: 0.1.0-pr.117.1
input: fixtures/rootform/examples/playground/shared-data-platform/base/plan.json
plan-file: fixtures/rootform/examples/playground/shared-data-platform/base/plan.tfplan
artifact-name: native-${{ matrix.target }}
- name: Inspect actual analysis outputs
env:
ROOTFORM_OUTPUTS: ${{ toJSON(steps.analyze.outputs) }}
ROOTFORM_EXPECT_ARTIFACT: 'true'
run: node scripts/assert-output.mjs analyze
- name: Optional setup reuses exact verified bytes
id: setup
uses: rootform-dev/action/setup@aa619a35a674a0be901650379db3b0551ee645ad
- name: Verify CLI version and executable digest
shell: bash
env:
EXPECTED_VERSION: ${{ steps.setup.outputs.version }}
EXPECTED_SHA: ${{ steps.setup.outputs.sha256 }}
run: |
set -euo pipefail
test "$(rootform version)" = "rootform $EXPECTED_VERSION"
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{64}$ ]]
- name: Record original Form digest before reuse
env:
ORIGINAL_FORM: ${{ steps.analyze.outputs.form }}
run: node -e "const fs=require('node:fs'),crypto=require('node:crypto');fs.appendFileSync(process.env.GITHUB_ENV,'ROOTFORM_PREVIOUS_SHA='+crypto.createHash('sha256').update(fs.readFileSync(process.env.ORIGINAL_FORM)).digest('hex')+'\n')"
- name: Reopen the provided Form
id: reopen
uses: rootform-dev/action/analyze@aa619a35a674a0be901650379db3b0551ee645ad
with:
input: ${{ steps.analyze.outputs.form }}
upload-artifact: 'false'
summary: 'false'
- name: Check saved Form path and content reuse
env:
ROOTFORM_OUTPUTS: ${{ toJSON(steps.reopen.outputs) }}
ROOTFORM_PREVIOUS_FORM: ${{ steps.analyze.outputs.form }}
run: node scripts/assert-output.mjs analyze
- name: Compare Form with independently paired plan
id: compare
uses: rootform-dev/action/compare@aa619a35a674a0be901650379db3b0551ee645ad
with:
before: ${{ steps.analyze.outputs.form }}
after: fixtures/rootform/examples/playground/shared-data-platform/head/plan.json
after-plan-file: fixtures/rootform/examples/playground/shared-data-platform/head/plan.tfplan
upload-artifact: 'false'
- name: Inspect actual comparison outputs
env:
ROOTFORM_OUTPUTS: ${{ toJSON(steps.compare.outputs) }}
run: node scripts/assert-output.mjs compare
- name: Evaluate both saved comparison sides
id: check
uses: rootform-dev/action/check@aa619a35a674a0be901650379db3b0551ee645ad
with:
input: ${{ steps.compare.outputs.form }}
side: both
policy-pack: fixtures/rootform/policy-packs/baseline
upload-artifact: 'false'
- name: Inspect successful Policy outputs
env:
ROOTFORM_OUTPUTS: ${{ toJSON(steps.check.outputs) }}
run: node scripts/assert-output.mjs check 0
- name: Genuine negative verdict retains all evidence
id: negative
continue-on-error: true
uses: rootform-dev/action/check@aa619a35a674a0be901650379db3b0551ee645ad
with:
input: ${{ steps.analyze.outputs.form }}
policy-pack: fixtures/negative
artifact-name: negative-${{ matrix.target }}
- name: Inspect failed gate evidence
env:
ROOTFORM_OUTPUTS: ${{ toJSON(steps.negative.outputs) }}
ROOTFORM_EXPECT_ARTIFACT: 'true'
STEP_OUTCOME: ${{ steps.negative.outcome }}
run: |
node -e "require('node:assert/strict').equal(process.env.STEP_OUTCOME,'failure')"
node scripts/assert-output.mjs check 1
- name: Download actual negative artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ steps.negative.outputs.artifact-id }}
path: downloaded-negative
merge-multiple: true
- name: Check exact artifact allowlist
run: node scripts/verify-artifact.mjs downloaded-negative form.json,report.md,result.json,results.sarif
56 changes: 56 additions & 0 deletions .github/workflows/report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: integrated PR report
on:
pull_request:
branches: [dev]
workflow_dispatch:
permissions:
contents: read
jobs:
report:
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
actions: read
pull-requests: write
concurrency:
group: rootform-report-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: rootform-dev/rootform
ref: 74bd643f3001a37f8b4f881c0ae33db047b6aba3
path: fixtures/rootform
sparse-checkout: examples/playground/shared-data-platform
persist-credentials: false
- name: Integrated analysis and genuine negative gate
id: rootform
continue-on-error: true
uses: rootform-dev/action@aa619a35a674a0be901650379db3b0551ee645ad
with:
version: 0.1.0-pr.117.1
input: fixtures/rootform/examples/playground/shared-data-platform/base/plan.json
plan-file: fixtures/rootform/examples/playground/shared-data-platform/base/plan.tfplan
policy-pack: fixtures/negative
comment: 'true'
artifact-name: integrated-evidence
- name: Verify integrated union and failed gate evidence
env:
ROOTFORM_OUTPUTS: ${{ toJSON(steps.rootform.outputs) }}
ROOTFORM_EXPECT_ARTIFACT: 'true'
STEP_OUTCOME: ${{ steps.rootform.outcome }}
run: |
node -e "require('node:assert/strict').equal(process.env.STEP_OUTCOME,'failure')"
node scripts/assert-output.mjs analyze
node scripts/assert-output.mjs check 1
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ steps.rootform.outputs.artifact-id }}
path: downloaded-integrated
merge-multiple: true
- name: Verify exact integrated artifact inventory
run: node scripts/verify-artifact.mjs downloaded-integrated form.json,report.md,explorer.html,result.json,results.sarif
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
.terraform/
*.tfplan
*.tfstate
*.pem
*.key
.rootform/
.rootform-results/
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Public qualification repository

This repository contains public synthetic consumers for Rootform Actions.

- Read README.md and inspected workflows before editing.
- Use only public fixed Rootform release assets and public fixture commits.
- Never commit or upload downloaded executables, raw plans/states, saved binary
plans, credentials, TLS keys, environment dumps or local notes.
- Rootform never runs Terraform/OpenTofu or uses cloud/provider/backend credentials.
- Action source references use exact commits. Major-ref publication is qualified
separately after the producer's reviewed merge.
- Keep PR reporting in one serialized job. Never grant write credentials to forks.
- Validate workflow syntax and execute fixture scripts locally before hosted CI.
- Change your own branch and open a PR into dev; no direct dev/main pushes.
- Record exact source, release, run, artifact and comment identities in public
evidence, without absolute personal paths or secrets.
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,18 @@
# action-qualification
Public synthetic consumers and end-to-end qualification for Rootform GitHub Actions
# Rootform Action public qualification

Synthetic external consumers for `rootform-dev/action`. Every workflow runs on
this public repository and installs the actual published Rootform release.
No compiler source, locally rebuilt binary, Terraform execution or cloud
credentials are involved.

Fixtures come from the public Rootform distribution commit
`74bd643f3001a37f8b4f881c0ae33db047b6aba3`; the exact CLI release is
`0.1.0-pr.117.1`. Workflows pin the Action source commit being qualified.

The negative Policy Pack exists only to prove that reports, Forms, artifacts
and Summary survive a genuine Policy violation. The isolated TLS registry
exists only to prove exact external-content preparation and cache reuse in an
offline job. Its TLS key and raw input fixtures are never artifacts.

Review job permissions and artifact inventories before changing tests. Keep
one serialized root reporter per PR; no fork or privileged-target comment.
7 changes: 7 additions & 0 deletions fixtures/negative/deny-database.rf.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
policy "deny-database" {
target {
concept = rf.concept.managed-database
}
assert = false
message = "Synthetic negative qualification policy."
}
3 changes: 3 additions & 0 deletions fixtures/negative/pack.rf.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
policy_pack "qualification" {
version = "0.1.0"
}
16 changes: 16 additions & 0 deletions scripts/assert-cache.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

const home = process.env.ROOTFORM_HOME || join(process.env.RUNNER_TEMP, 'rootform-home');
const marker = '.qualification-excluded-marker';
if (process.argv[2] === 'seed') {
for (const name of ['cache', 'tmp']) {
mkdirSync(join(home, name), { recursive: true });
writeFileSync(join(home, name, marker), 'Synthetic derived content must not enter source cache.\n');
}
} else {
assert.ok(readdirSync(join(home, 'policy-packs')).length, 'restored source payload missing');
for (const name of ['cache', 'tmp']) assert.equal(existsSync(join(home, name, marker)), false, `${name} content leaked through cache`);
console.log('Verified source restored; synthetic derived contents excluded.');
}
30 changes: 30 additions & 0 deletions scripts/assert-output.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { basename } from 'node:path';

const outputs = JSON.parse(process.env.ROOTFORM_OUTPUTS || '{}');
const operation = process.argv[2];
const expectedCode = process.argv[3];
const expected = operation === 'check' ? ['form', 'result', 'report', 'sarif'] : ['form', 'report', 'html'];
for (const field of expected) {
assert.equal(typeof outputs[field], 'string', `missing ${field} path`);
assert.ok(existsSync(outputs[field]), `${field} file missing`);
assert.ok(statSync(outputs[field]).size > 0, `${field} file empty`);
}
execFileSync('rootform', ['validate', 'form', outputs.form], { stdio: 'pipe' });
assert.equal(outputs.version, '0.1.0-pr.117.1');
if (expectedCode !== undefined) assert.equal(outputs['exit-code'], expectedCode);
if (process.env.ROOTFORM_EXPECT_ARTIFACT === 'true') {
assert.match(outputs['artifact-id'], /^\d+$/);
assert.match(outputs['artifact-url'], /^https:\/\/github\.com\/rootform-dev\/action-qualification\/actions\/runs\/\d+\/artifacts\/\d+$/);
}
if (process.env.ROOTFORM_PREVIOUS_FORM) {
assert.equal(outputs.form, process.env.ROOTFORM_PREVIOUS_FORM, 'saved Form path must be reused');
assert.equal(createHash('sha256').update(readFileSync(outputs.form)).digest('hex'), process.env.ROOTFORM_PREVIOUS_SHA, 'saved Form changed');
}
const files = Object.fromEntries(expected.map((field) => [field, { name: basename(outputs[field]), bytes: statSync(outputs[field]).size, sha256: createHash('sha256').update(readFileSync(outputs[field])).digest('hex') }]));
const evidence = { operation, version: outputs.version, exitCode: outputs['exit-code'] ?? null, artifactId: outputs['artifact-id'] ?? null, artifactUrl: outputs['artifact-url'] ?? null, files };
if (process.env.ROOTFORM_EVIDENCE_FILE) writeFileSync(process.env.ROOTFORM_EVIDENCE_FILE, JSON.stringify(evidence, null, 2)+'\n');
console.log(JSON.stringify(evidence));
29 changes: 29 additions & 0 deletions scripts/prepare-registry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

const root = process.env.RUNNER_TEMP;
if (!root) throw new Error('RUNNER_TEMP is required');
const tls = join(root, 'rootform-registry-tls'); mkdirSync(tls);
const env = { ...process.env };
for (const key of Object.keys(env)) if (/^(?:INPUT_|GITHUB_TOKEN$|GH_TOKEN$|ACTIONS_RUNTIME_TOKEN$|ACTIONS_ID_TOKEN_REQUEST_TOKEN$)/i.test(key)) delete env[key];
function command(program, args, additions = {}) {
return execFileSync(program, args, { env: { ...env, ...additions }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 });
}
command('openssl', ['req','-x509','-newkey','rsa:2048','-nodes','-days','1','-subj','/CN=localhost','-addext','subjectAltName=DNS:localhost,IP:127.0.0.1','-keyout',join(tls,'key.pem'),'-out',join(tls,'cert.pem')]);
command('docker',['run','--detach','--name','rootform-action-registry','--publish','127.0.0.1:5000:5000','--mount',`type=bind,source=${tls},target=/tls,readonly`,'-e','REGISTRY_HTTP_TLS_CERTIFICATE=/tls/cert.pem','-e','REGISTRY_HTTP_TLS_KEY=/tls/key.pem','registry:3@sha256:ddf754342cfc8acc51a56d5d0ab6af06826461864460636d8bd5c546dab2a7b8']);
const home = join(root, 'selection-builder-home'); mkdirSync(home);
const project = 'external-project'; mkdirSync(project);
const layout = join(root, 'registry-layout');
const auth = { ROOTFORM_HOME: home, SSL_CERT_FILE: join(tls,'cert.pem') };
command('rootform',['package','policy-packs','fixtures/negative','--to',layout,'--source-url','https://github.com/rootform-dev/action-qualification','--revision',process.env.GITHUB_SHA,'--licenses','Apache-2.0'],auth);
// Wait only for this owned registry's TLS readiness, with a bounded interval.
for (let attempt=0; attempt<20; attempt++) {
try { command('curl',['--fail','--silent','--cacert',join(tls,'cert.pem'),'https://localhost:5000/v2/']); break; }
catch { if (attempt===19) throw new Error('Owned TLS registry did not become ready'); await new Promise((resolve)=>setTimeout(resolve,250)); }
}
command('rootform',['publish','policy-packs',layout,'--to','localhost:5000/rootform-action/policies','--format','json'],auth);
command('rootform',['add','policy-packs','localhost:5000/rootform-action/policies:policy-pack-qualification-0.1.0','--project',project,'--format','json'],auth);
writeFileSync(process.env.GITHUB_ENV,`SSL_CERT_FILE=${join(tls,'cert.pem')}\n`,{flag:'a'});
const lock = JSON.parse(readFileSync(join(project,'rootform.lock'),'utf8'));
console.log(JSON.stringify({ sourceManifest: lock.policy_packs[0].source.oci.manifest_digest, contentDigest: lock.policy_packs[0].content_digest }));
Loading
Loading