diff --git a/.github/workflows/cache.yml b/.github/workflows/cache.yml new file mode 100644 index 0000000..2109c95 --- /dev/null +++ b/.github/workflows/cache.yml @@ -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 diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 0000000..c7ce9c9 --- /dev/null +++ b/.github/workflows/native.yml @@ -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 diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml new file mode 100644 index 0000000..666daba --- /dev/null +++ b/.github/workflows/report.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3552ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.terraform/ +*.tfplan +*.tfstate +*.pem +*.key +.rootform/ +.rootform-results/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7926b64 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index d62e3d6..152ff53 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/fixtures/negative/deny-database.rf.hcl b/fixtures/negative/deny-database.rf.hcl new file mode 100644 index 0000000..a409028 --- /dev/null +++ b/fixtures/negative/deny-database.rf.hcl @@ -0,0 +1,7 @@ +policy "deny-database" { + target { + concept = rf.concept.managed-database + } + assert = false + message = "Synthetic negative qualification policy." +} diff --git a/fixtures/negative/pack.rf.hcl b/fixtures/negative/pack.rf.hcl new file mode 100644 index 0000000..ba40a58 --- /dev/null +++ b/fixtures/negative/pack.rf.hcl @@ -0,0 +1,3 @@ +policy_pack "qualification" { + version = "0.1.0" +} diff --git a/scripts/assert-cache.mjs b/scripts/assert-cache.mjs new file mode 100644 index 0000000..ac90512 --- /dev/null +++ b/scripts/assert-cache.mjs @@ -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.'); +} diff --git a/scripts/assert-output.mjs b/scripts/assert-output.mjs new file mode 100644 index 0000000..c112cc9 --- /dev/null +++ b/scripts/assert-output.mjs @@ -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)); diff --git a/scripts/prepare-registry.mjs b/scripts/prepare-registry.mjs new file mode 100644 index 0000000..765da74 --- /dev/null +++ b/scripts/prepare-registry.mjs @@ -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 })); diff --git a/scripts/verify-artifact.mjs b/scripts/verify-artifact.mjs new file mode 100644 index 0000000..8145124 --- /dev/null +++ b/scripts/verify-artifact.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +const directory = process.argv[2]; +const expected = process.argv[3].split(',').sort(); +const actual = readdirSync(directory).sort(); +assert.deepEqual(actual, expected); +for (const file of actual) assert.ok(statSync(join(directory,file)).isFile()); +assert.ok(!actual.some((file) => /(?:tfplan|tfstate|plan\.json|state\.json|\.terraform|operand-)/.test(file))); +console.log(`Artifact allowlist verified: ${actual.join(', ')}`);