Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0672edfc3f | |||
| 1415c85d35 | |||
| 72b359c9a9 | |||
| 711b61d63e |
@@ -48,6 +48,75 @@ The demo is a three-repo, stub-driven system that simulates an autonomous cloud
|
||||
| `acdl-contracts` repo | Developer + agentic entry surface; holds contracts + issue workflow | Triggers main pipeline on push | `acdl` reusable workflow |
|
||||
| `acdl-evidence` repo | Pages host for `audit.json` + `index.html` timeline | Read-only for the pipeline; written at finalize stage | evidence_writer.py output |
|
||||
| Reusable pipeline workflow | Dev → QA → Prod → Finalize stages with environment gates | Gitea Actions; calls core scripts | All core scripts |
|
||||
| `evidence-ui/index.html` | Vanilla-JS timeline UI (Phase 05); fetches `./audit.json` and renders events | Single HTML file with inline CSS+JS; no frameworks (REQ-14) | `audit.json` on `acdl-evidence` main |
|
||||
| `scripts/run_demo.sh` | Phase 05 dry-run simulation of the 4 demo acts; calls core scripts + writes evidence + uploads `audit.json` + `index.html` to `acdl-evidence` | Bash; uses `evidence_writer.py` + `finalize_evidence.py` + the file-contents API | All Phase 03/04 artifacts |
|
||||
|
||||
## Phase 04 pipeline topology (research)
|
||||
|
||||
Gitea Actions limitations (confirmed via research, supersedes any
|
||||
GitHub-Actions assumptions):
|
||||
|
||||
- `actions/upload-artifact@v3` / `download-artifact@v3` work; v4 is NOT supported by act_runner.
|
||||
- Artifacts are scoped to a single workflow run; **re-dispatch starts a new run, so artifacts do NOT survive between dispatches**.
|
||||
- `workflow_dispatch` API: `POST /api/v1/repos/{owner}/{repo}/actions/workflows/{filename}.yml/dispatches` with body `{ "ref": "<branch>", "inputs": {...} }`.
|
||||
- `on: workflow_call` + `uses: <owner>/<repo>/.gitea/workflows/<file>@<ref>` works; pin to `@milestone/v1.0-initial`.
|
||||
- `actions/checkout@v4` supports cross-repo (pass `repository:` + `ref:` + `token: ${{ secrets.GITEA_TOKEN }}` for private repos).
|
||||
- File-contents API: POST to create (201), PUT to update (must include current `sha`, obtained via GET).
|
||||
- `${{ secrets.GITEA_TOKEN }}` is a manually-created PAT secret on the `acdl` + `acdl-contracts` repos; the auto-injected token is current-repo only and cannot cross-repo.
|
||||
- No native approval-gate UI; gates are `workflow_dispatch` inputs (`approve_qa`, `approve_prod`).
|
||||
|
||||
### Approval-gate + state-persistence approach (D-027, D-028 refined)
|
||||
|
||||
Because re-dispatch starts a new run and artifacts do not survive:
|
||||
|
||||
1. The pipeline workflow has `workflow_dispatch` inputs:
|
||||
- `contract-ref` (string; default `main`) — the ref on `acdl-contracts` carrying the contract.
|
||||
- `approve_qa` (boolean; default `false`) — the human sets this to `true` to advance past QA.
|
||||
- `approve_prod` (boolean; default `false`) — the human sets this to `true` to advance past Prod.
|
||||
|
||||
2. Each stage job (`dev`, `qa-gate`, `prod-gate`, `finalize`) writes its evidence to `acdl-evidence` via the file-contents API (PUT `audit.json` with the new event appended). This is the persistent state across re-dispatches.
|
||||
|
||||
3. **Dev stage** (always runs on dispatch): check out `acdl` + `acdl-contracts@<contract-ref>`, run `policy_checker.py` + `confidence_signal.py`; if `score < 0.50`, write a `dev_rejected` evidence event and exit 1 (Act 4). Otherwise run `mock_executor.sh`, write a `dev_applied` evidence event, and exit 0. The run ends here.
|
||||
|
||||
4. **QA gate** (next dispatch with `approve_qa=true`): check out, run `evidence_writer.py --stage qa --event "qa approved"`, commit updated `audit.json` to `acdl-evidence`. Exit 0. The run ends.
|
||||
|
||||
5. **Prod gate** (next dispatch with `approve_prod=true`): same as QA but `--stage prod`.
|
||||
|
||||
6. **Finalize** (same dispatch as Prod, chained via `needs: prod-gate`): write the `finalize` evidence event, commit final `audit.json` to `acdl-evidence`. The raw URL now serves the updated timeline.
|
||||
|
||||
Because each stage is a separate dispatch, the workflow file uses `if:` conditions on each job:
|
||||
- `dev` runs when `inputs.approve_qa != true && inputs.approve_prod != true` (the initial dispatch).
|
||||
- `qa-gate` runs when `inputs.approve_qa == true && inputs.approve_prod != true`.
|
||||
- `prod-gate` runs when `inputs.approve_prod == true`.
|
||||
- `finalize` runs after `prod-gate` (`needs: prod-gate`).
|
||||
|
||||
This means a full pipeline = 3 dispatches (initial, qa-approve, prod-approve). The human drives each via the Gitea UI or the dispatch API.
|
||||
|
||||
## Phase 05 dry-run + UI (research)
|
||||
|
||||
Phase 05 has no act_runner available in this environment, so the "dry run"
|
||||
is a local bash simulation (`scripts/run_demo.sh`) that produces the same
|
||||
`audit.json` shape a real pipeline run would, then uploads it (plus the
|
||||
UI) to `acdl-evidence` via the file-contents API. The simulation covers:
|
||||
|
||||
- **Act 1 — Friction:** a single evidence event "manual 2-week deployment (legacy process)" at `stage: dev` with a red-colored timeline marker.
|
||||
- **Act 2 — Developer Self-Service:** `l2-commodity-price-feed` contract, full pipeline (dev → qa → prod → finalize), 4 evidence events.
|
||||
- **Act 3 — Citizen Developer:** Issue body fed to `l3b_agent_stub.py`, generates the same `l2-commodity-price-feed` contract, identical pipeline, 4 evidence events.
|
||||
- **Act 4 — Safety Net:** `l2-regulatory-reporting` contract with `public-ingress: true`, dev rejects (confidence 0.40 < 0.50), 1 evidence event "dev rejected: POLICY_VIOLATION:PUBLIC_INGRESS".
|
||||
|
||||
The `audit.json` after `run_demo.sh` contains the genesis + all act
|
||||
events (typically ~14 events). The UI fetches `./audit.json` and renders
|
||||
a vertical timeline with stage-colored markers and a per-event hash
|
||||
preview.
|
||||
|
||||
### UI rendering contract (D-032, D-033)
|
||||
|
||||
`evidence-ui/index.html`:
|
||||
- Single file, inline CSS + JS, no external resources.
|
||||
- Fetches `./audit.json` (relative URL; works against any raw-URL origin).
|
||||
- Renders events as a vertical timeline; each event card shows `seq`, `ts`, `stage` (color-coded: `dev` blue, `qa` yellow, `prod` orange, `finalize` green, `genesis` gray, rejected events red), `event` text, and a 12-char hash preview (`hash.slice(0, 12)…`).
|
||||
- Handles fetch failure with a "No audit data yet" message.
|
||||
- Refresh button to re-fetch.
|
||||
|
||||
## Data Flow
|
||||
|
||||
|
||||
@@ -59,12 +59,12 @@ verification_toolchain:
|
||||
|
||||
### frontend-engineer
|
||||
- **Domain:** frontend
|
||||
- **Active:** false
|
||||
- **Reason:** No UI in Phases 01-04. The single UI artifact (`index.html`, vanilla JS) is built in Phase 05. frontend-engineer is reactivated for Phase 05 only (see Phase-specific overrides below).
|
||||
- **Phase-specific:** true (reactivates in Phase 05)
|
||||
- **Active:** true # REACTIVATED for Phase 05 (evidence UI + dry run)
|
||||
- **Reason:** Phase 05 builds the vanilla-JS `index.html` timeline UI (REQ-14) and runs the 4 demo acts end-to-end dry run (REQ-15). Inactive for Phases 01-04 (no UI).
|
||||
- **Phase-specific:** true (this reactivation is for Phase 05 only; will be deactivated again after the milestone ships if the project continues)
|
||||
- **Frameworks:** vanilla-js, dom-api, fetch-api
|
||||
- **Constraints:** no-frameworks, single-file, fetch-from-same-origin-raw-url
|
||||
- **Territory:** `acdl-evidence/index.html` (Phase 05)
|
||||
- **Constraints:** no-frameworks, single-file, fetch-from-same-origin-raw-url, relative-url-for-audit-json
|
||||
- **Territory:** `evidence-ui/**` (the source-of-truth `index.html` in the `acdl` repo; pushed to `acdl-evidence` by `run_demo.sh`)
|
||||
|
||||
## Phase-specific overrides
|
||||
|
||||
|
||||
+57
-59
@@ -1,102 +1,100 @@
|
||||
---
|
||||
phase: 03
|
||||
name: l2-modules-and-core-scripts
|
||||
phase: 05
|
||||
name: evidence-ui-and-demo-dry-run
|
||||
milestone: v1.0
|
||||
milestone_type: feature
|
||||
status: planned
|
||||
requirements: [REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-11]
|
||||
requirements: [REQ-13, REQ-14, REQ-15]
|
||||
must_haves:
|
||||
- "4 L2 module folders exist under modules/l2/ with exact names: l2-invoice-service, l2-commodity-price-feed, l2-energy-analytics-api, l2-regulatory-reporting"
|
||||
- "Each L2 has a manifest.yaml matching the D-020 schema (name, kind: l2, description, l1s: list of {name, inputs: map})"
|
||||
- "Each L2 references 5 L1s by name; all referenced L1 names exist in modules/l1/"
|
||||
- "mock_executor.sh reads a contract.yaml, resolves the L2, invokes each L1 mock_apply.sh, writes state.json per D-022"
|
||||
- "policy_checker.py exits 1 with 'POLICY_VIOLATION:PUBLIC_INGRESS' on public-ingress:true; exits 0 with 'POLICY_PASS' otherwise (D-025)"
|
||||
- "confidence_signal.py prints {score, reason} JSON; score 0.90 on pass, 0.40 on policy fail (D-024)"
|
||||
- "evidence_writer.py appends an event to audit.json with a valid canonical-JSON SHA-256 hash chain (D-023)"
|
||||
- "l3b_agent_stub.py maps the Act 3 example issue text to l2-commodity-price-feed and emits a valid contract.yaml (D-026/D-008)"
|
||||
- "scripts/verify_phase03.sh passes: validates all 4 L2s, runs each core script with a sample contract, confirms hash chain integrity"
|
||||
- "evidence-ui/index.html exists: single HTML file with inline CSS + JS, no external resources, no frameworks (D-032, REQ-14)"
|
||||
- "index.html fetches ./audit.json (relative URL) and renders events as a vertical timeline with stage color-coding (dev/qa/prod/finalize/genesis) + 12-char hash preview"
|
||||
- "scripts/run_demo.sh exists: simulates all 4 acts (Friction, Dev Self-Service, Citizen Developer, Safety Net) by calling the Phase 03 core scripts + evidence_writer.py + finalize_evidence.py; writes a final audit.json; uploads audit.json + evidence-ui/index.html to acdl-evidence main via the Gitea file-contents API (D-031/D-033)"
|
||||
- "scripts/run_demo.sh is idempotent: re-running overwrites both files on acdl-evidence and produces the same audit.json (deterministic hash chain)"
|
||||
- "Act 4 produces a 'dev rejected: POLICY_VIOLATION:PUBLIC_INGRESS' evidence event with score 0.40 (the Safety Net)"
|
||||
- "scripts/verify_phase05.sh passes: validates index.html structure (single file, inline, fetch call), runs run_demo.sh, fetches the raw audit.json + index.html URLs from acdl-evidence and confirms HTTP 200"
|
||||
verification:
|
||||
typecheck: "bash -n scripts/*.sh && python3 -m py_compile scripts/*.py && python3 -c 'import yaml, glob; [yaml.safe_load(open(f)) for f in glob.glob(\"modules/l2/*/manifest.yaml\")]'"
|
||||
test: "scripts/verify_phase03.sh"
|
||||
typecheck: "bash -n scripts/*.sh && python3 -m py_compile scripts/*.py"
|
||||
test: "scripts/verify_phase05.sh"
|
||||
build: no-op
|
||||
---
|
||||
|
||||
# Phase 03 — l2-modules-and-core-scripts PLAN
|
||||
# Phase 05 — evidence-ui-and-demo-dry-run PLAN
|
||||
|
||||
## Goal
|
||||
|
||||
Create the 4 L2 composition modules and the 5 core scripts. After this
|
||||
phase, the demo has every primitive needed for Phase 04 to wire the
|
||||
pipeline and Phase 05 to render the evidence UI.
|
||||
Build the vanilla-JS timeline UI and run the 4 demo acts as a local dry
|
||||
run that produces a real `audit.json` and publishes it (plus the UI) to
|
||||
`acdl-evidence` main. This is the milestone's capstone phase.
|
||||
|
||||
## Requirements covered
|
||||
|
||||
- REQ-04: 4 L2 modules under modules/l2/ composing L1s
|
||||
- REQ-05: L2s compose L1s, max depth 5
|
||||
- REQ-06: mock_executor.sh reads L2 + invokes L1s + writes state.json
|
||||
- REQ-07: policy_checker.py fails on public-ingress:true
|
||||
- REQ-08: confidence_signal.py 0.90/0.40 + gate ≥ 0.50
|
||||
- REQ-11: evidence_writer.py SHA-256 hash chain
|
||||
- REQ-13: `acdl-evidence` is Pages-enabled and serves `audit.json` plus `index.html` (substituted by D-012: raw-URL 200 on both files)
|
||||
- REQ-14: `index.html` uses vanilla JS to fetch `audit.json` and render events as a timeline
|
||||
- REQ-15: All four demo acts reproduce deterministically in a dry run
|
||||
|
||||
## Waves (vertical slices, domain priority order)
|
||||
|
||||
### Wave 1 — infra-stub-engineer (4 L2 manifests)
|
||||
### Wave 1 — frontend-engineer (the UI)
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- **T-3.1** Create `modules/l2/l2-invoice-service/manifest.yaml` (L1s: l1-eks-fargate, l1-iam-role, l1-lambda, l1-sqs, l1-s3)
|
||||
- **T-3.2** Create `modules/l2/l2-commodity-price-feed/manifest.yaml` (L1s: l1-eks-fargate, l1-lambda, l1-api-gateway, l1-eventbridge, l1-s3)
|
||||
- **T-3.3** Create `modules/l2/l2-energy-analytics-api/manifest.yaml` (L1s: l1-eks-fargate, l1-api-gateway, l1-lambda, l1-s3, l1-cloudwatch)
|
||||
- **T-3.4** Create `modules/l2/l2-regulatory-reporting/manifest.yaml` (L1s: l1-eks-fargate, l1-iam-role, l1-lambda, l1-sqs, l1-s3)
|
||||
- **T-5.1** Create `evidence-ui/index.html` — single HTML file with inline `<style>` + `<script>`. The JS fetches `./audit.json` (relative URL), parses the JSON array, and renders a vertical timeline. Each event card shows: `seq` (badge), `ts` (timestamp), `stage` (color-coded chip: dev=blue, qa=yellow, prod=orange, finalize=green, genesis=gray, rejected=red), `event` text, and `hash.slice(0, 12) + "…"` (a 12-char preview). Includes a refresh button that re-fetches. Handles fetch failure with a "No audit data yet" message. No external resources (no CDN, no fetch libraries); vanilla JS only. The file is self-contained.
|
||||
|
||||
Each manifest declares plausible `inputs` per L1 (string map per D-017).
|
||||
Remove `modules/l2/.gitkeep` in T-3.1.
|
||||
**Files owned:** `evidence-ui/index.html`
|
||||
|
||||
**Files owned:** `modules/l2/**`
|
||||
**Commits:** one commit, `phase: 5, status: plan-as-execute, persona: frontend-engineer, task: T-5.1, requirements.covered: [REQ-14]`.
|
||||
|
||||
**Commits:** one per task, `phase: 3, status: plan-as-execute, persona: infra-stub-engineer, task: T-3.x, requirements.covered: [REQ-04, REQ-05]`.
|
||||
|
||||
### Wave 2 — backend-engineer (5 core scripts)
|
||||
### Wave 2 — backend-engineer (the dry-run script)
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- **T-3.5** Create `scripts/policy_checker.py` — reads contract.yaml (argv[1]); if `public-ingress: true`, print `POLICY_VIOLATION:PUBLIC_INGRESS` and exit 1; else print `POLICY_PASS` and exit 0. Use only stdlib (yaml is available). Idempotent, no side effects.
|
||||
- **T-3.6** Create `scripts/confidence_signal.py` — reads contract.yaml (argv[1]); calls policy_checker as a subprocess; if pass → `{"score": 0.90, "reason": "POLICY_PASS"}`, if fail → `{"score": 0.40, "reason": "POLICY_VIOLATION:PUBLIC_INGRESS"}`. Print JSON to stdout. Exit 0 always.
|
||||
- **T-3.7** Create `scripts/evidence_writer.py` — argv flags `--stage`, `--event`, `--audit <path>` (default `./audit.json`). Loads audit.json (or empty list), computes the new event with canonical-JSON SHA-256 hash chain per D-023, appends, writes back atomically (write tmp + rename). Prints `{"seq": N, "hash": "..."}` to stdout. Genesis event automatically inserted if the file is empty/missing.
|
||||
- **T-3.8** Create `scripts/mock_executor.sh` — argv[1] = contract.yaml path. Reads contract.stack, resolves `modules/l2/<stack>/manifest.yaml`, iterates `l1s`, invokes `modules/l1/<name>/mock_apply.sh` for each, captures exit code, writes `state.json` per D-022. Exit 0 if all L1s exit 0; non-zero otherwise.
|
||||
- **T-3.9** Create `scripts/l3b_agent_stub.py` — argv[1] = issue body (or read stdin if absent); optional `-o <path>` (default stdout). Applies the D-008 keyword map; writes a contract.yaml (D-021 schema) with `stack` set to the mapped L2 name and a fixed `inputs:` map per stack. Exit 0 on success, 1 on empty input.
|
||||
- **T-5.2** Create `scripts/run_demo.sh` — the Phase 05 dry-run simulation. It:
|
||||
1. Accepts an optional `--no-upload` flag (for testing without hitting Gitea).
|
||||
2. Creates a clean working directory under `/tmp/acdl_demo_run/`; sets `AUDIT=/tmp/acdl_demo_run/audit.json`.
|
||||
3. Initializes the audit log: `python3 scripts/evidence_writer.py --stage genesis --event "audit log initialized" --audit "$AUDIT"`.
|
||||
4. **Act 1 — Friction:** write a single event `--stage dev --event "Act 1 Friction: manual 2-week deployment (legacy process)"`.
|
||||
5. **Act 2 — Developer Self-Service:** write `contracts/act2.yaml` with `stack: l2-commodity-price-feed`, `public-ingress: false`. Run `policy_checker.py` + `confidence_signal.py` + `mock_executor.sh`. Write events: `dev applied: l2-commodity-price-feed`, `qa approved`, `prod approved`, `finalize: audit.json committed`.
|
||||
6. **Act 3 — Citizen Developer:** feed an Issue body ("We need to ingest natural gas prices from Platts...") to `l3b_agent_stub.py -o contracts/act3.yaml`. Run the same pipeline as Act 2 against the generated contract. Write 4 events.
|
||||
7. **Act 4 — Safety Net:** write `contracts/act4.yaml` with `stack: l2-regulatory-reporting`, `public-ingress: true`. Run `policy_checker.py` (fails) + `confidence_signal.py` (score 0.40). Since score < 0.50, write `dev rejected: POLICY_VIOLATION:PUBLIC_INGRESS` and skip QA/Prod/Finalize.
|
||||
8. Print a summary of all events.
|
||||
9. If `--no-upload` is NOT set: call `python3 scripts/finalize_evidence.py --audit "$AUDIT"` to upload `audit.json` to `acdl-evidence`, then call `finalize_evidence.py --audit evidence-ui/index.html --path index.html --message "chore(ui): update index.html"` to upload the UI. (Reuses `finalize_evidence.py` with `--path` override for `index.html`.)
|
||||
10. Exit 0 if all 4 acts produced the expected evidence events; non-zero otherwise.
|
||||
|
||||
**Files owned:** `scripts/policy_checker.py`, `scripts/confidence_signal.py`, `scripts/evidence_writer.py`, `scripts/mock_executor.sh`, `scripts/l3b_agent_stub.py`
|
||||
Cleanup: write contracts under `contracts/` (gitignored) so the working tree stays clean.
|
||||
|
||||
**Commits:** one per task, `phase: 3, status: plan-as-execute, persona: backend-engineer, task: T-3.x, requirements.covered: [REQ-06/07/08/11/12]`.
|
||||
**Files owned:** `scripts/run_demo.sh`
|
||||
|
||||
**Commits:** one commit, `phase: 5, status: plan-as-execute, persona: backend-engineer, task: T-5.2, requirements.covered: [REQ-13, REQ-15]`.
|
||||
|
||||
### Wave 3 — lead-developer (verify script + traceability)
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- **T-3.10** Create `scripts/verify_phase03.sh`. Checks:
|
||||
1. Exactly 4 L2 folders with the expected names.
|
||||
2. Each L2 manifest.yaml parses, name matches folder, kind=l2, l1s is a list of 5 entries, all referenced L1 names exist in modules/l1/.
|
||||
3. policy_checker.py on a passing contract → exit 0 + `POLICY_PASS`; on `public-ingress: true` contract → exit 1 + `POLICY_VIOLATION:PUBLIC_INGRESS`.
|
||||
4. confidence_signal.py on passing contract → `{"score": 0.90, ...}`; on failing contract → `{"score": 0.40, ...}`. Both exit 0.
|
||||
5. mock_executor.sh on a sample contract → writes state.json with l2 + l1s (all applied=true, exit_code=0) + contract fields.
|
||||
6. evidence_writer.py: append 3 events to a temp audit.json; verify seq increments 0/1/2, prev_hash chain links, each hash matches a recompute.
|
||||
7. l3b_agent_stub.py on the Act 3 example issue text ("We need to ingest natural gas prices from Platts...") → emits a contract.yaml with `stack: l2-commodity-price-feed`.
|
||||
- **T-3.11** Update `.ciagent/REQUIREMENTS.md` (REQ-04/05/06/07/08/11 → covered pending VERIFY) and `.ciagent/ROADMAP.md` (Phase 03 → executing).
|
||||
- **T-5.3** Create `scripts/verify_phase05.sh`. Checks:
|
||||
1. `evidence-ui/index.html` exists, is a single file, contains `<style>` and `<script>` inline tags, contains `fetch('./audit.json'` (relative URL), no `https://` external resource references (no CDN).
|
||||
2. `scripts/run_demo.sh` is `bash -n` clean.
|
||||
3. Run `scripts/run_demo.sh --no-upload` and confirm:
|
||||
- It exits 0.
|
||||
- It produces a non-empty `audit.json` with at least 14 events (genesis + act1 + act2[4] + act3[4] + act4[1] = 11 minimum, but with markers it may be more — use `>= 11`).
|
||||
- The audit chain is valid (re-run the hash check).
|
||||
- The Act 4 event contains "POLICY_VIOLATION:PUBLIC_INGRESS".
|
||||
4. If `ACDL_GITEA_TOKEN` is set: run `scripts/run_demo.sh` (with upload), then curl the raw URLs for `audit.json` and `index.html` on `acdl-evidence` and confirm HTTP 200 + that the audit.json matches the local one (or at least parses as JSON with the expected number of events) + that index.html contains "ACDL Evidence" or "audit.json" reference.
|
||||
5. If `ACDL_GITEA_TOKEN` is NOT set: skip the upload check with a clear "SKIP (no token)" message; the dry-run + structural checks are sufficient.
|
||||
- **T-5.4** Update `.ciagent/REQUIREMENTS.md` (REQ-13/14/15 → covered pending VERIFY) and `.ciagent/ROADMAP.md` (Phase 05 → executing).
|
||||
|
||||
**Files owned:** `scripts/verify_phase03.sh`, `.ciagent/REQUIREMENTS.md`, `.ciagent/ROADMAP.md`
|
||||
**Files owned:** `scripts/verify_phase05.sh`, `.ciagent/REQUIREMENTS.md`, `.ciagent/ROADMAP.md`
|
||||
|
||||
**Commits:** one per task, `phase: 3, status: plan-as-execute, persona: lead-developer, task: T-3.10/3.11`.
|
||||
**Commits:** one per task, `phase: 5, status: plan-as-execute, persona: lead-developer, task: T-5.3/5.4`.
|
||||
|
||||
## Wave ordering
|
||||
|
||||
- Wave 1 (infra-stub-engineer) creates the 4 L2 manifests first so mock_executor.sh has something to resolve.
|
||||
- Wave 2 (backend-engineer) builds the 5 core scripts. policy_checker + confidence_signal have no L2 dependency; mock_executor depends on Wave 1; l3b_agent_stub is independent.
|
||||
- Wave 3 (lead-developer) wires the verify script after both Waves 1 and 2 are complete.
|
||||
- Wave 1 (frontend) builds the UI.
|
||||
- Wave 2 (backend) builds the dry-run script.
|
||||
- Wave 3 (lead-developer) verifies + traceability.
|
||||
|
||||
`data-engineer` and `frontend-engineer` have 0 tasks this phase.
|
||||
Wave 1 and Wave 2 can run in parallel (no file overlap), but per execute.md the domain priority is `coordination → backend → frontend → custom`. Here `frontend-engineer` and `backend-engineer` have no territory overlap, so they can run sequentially in priority order: backend first (Wave 2), then frontend (Wave 1) — but for clarity I'll keep Wave 1 = frontend, Wave 2 = backend (the UI is the visible artifact; the script needs to upload it). Actually, `run_demo.sh` references `evidence-ui/index.html`, so the UI must exist before the script is tested end-to-end. Order: Wave 1 (UI) → Wave 2 (script) → Wave 3 (verify).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Depends on Phase 02 (L1 modules exist so mock_executor can invoke them and verify_phase03 can confirm L2 references resolve).
|
||||
- Phase 04 depends on this phase for the pipeline to call policy_checker, mock_executor, confidence_signal, evidence_writer, and for the issue workflow to call l3b_agent_stub.
|
||||
- Depends on Phases 01-04 (all core scripts, workflows, and the acdl-evidence repo must exist).
|
||||
- This is the last phase in the milestone. After Phase 05 ships, the COMPLETE gate runs: review → ship(milestone v1.1.0) → audit.
|
||||
+8
-1
@@ -85,4 +85,11 @@ Build a runnable demo (Linux + GitHub/Gitea Actions) that walks executives throu
|
||||
| D-023 | `evidence_writer.py` appends events to `audit.json` (a JSON array of event objects). Each event: `{"seq": N, "ts": <iso8601>, "stage": "dev|qa|prod|finalize", "event": "<string>", "prev_hash": "<sha256>", "hash": "<sha256 of canonical json of this event with hash field empty>"}`. The genesis event has `prev_hash: "GENESIS"` and `seq: 0` | D-005 mandates hash-chained ledger; canonical JSON for deterministic hashing | Visible tamper-evidence without overengineering; Phase 05 UI reads the array |
|
||||
| D-024 | `confidence_signal.py` reads `contract.yaml`, calls `policy_checker.py` (as a subprocess or import), returns base 0.90 on pass and 0.40 with reason code on policy failure; prints `{"score": 0.90|0.40, "reason": "<POLICY_VIOLATION:...|>"}` to stdout; exit 0 always | REQ-08 literal: base 0.90, drops to 0.40, gate ≥ 0.50 | Deterministic JSON output for the pipeline to consume |
|
||||
| D-025 | `policy_checker.py` reads `contract.yaml`, fails with exit code 1 and stdout `POLICY_VIOLATION:PUBLIC_INGRESS` if `public-ingress: true`; otherwise exits 0 with stdout `POLICY_PASS` | REQ-07 literal | Single source of policy truth; called by confidence_signal and the pipeline directly |
|
||||
| D-026 | `l3b_agent_stub.py` reads Issue body text from argv[1] (or stdin if no argv), applies the D-008 keyword map, writes a `contract.yaml` to stdout (or to `-o <path>`). Output contract uses the D-021 schema with `stack:` set to the mapped L2 name and a fixed `inputs:` map per L2 | D-008 + Act 3 example; L3B must produce the same contract format as L3A | Deterministic keyword parser; no external APIs |
|
||||
| D-026 | `l3b_agent_stub.py` reads Issue body text from argv[1] (or stdin if no argv), applies the D-008 keyword map, writes a `contract.yaml` to stdout (or to `-o <path>`). Output contract uses the D-021 schema with `stack:` set to the mapped L2 name and a fixed `inputs:` map per L2 | D-008 + Act 3 example; L3B must produce the same contract format as L3A | Deterministic keyword parser; no external APIs |
|
||||
| D-027 | Phase 04 models the pipeline as TWO Gitea Actions workflows: (1) `acdl/.gitea/workflows/pipeline.yml` — `on: workflow_call` + `on: workflow_dispatch` (so it can be both called by the contracts-repo trigger AND manually re-dispatched for approvals); (2) `acdl-contracts/.gitea/workflows/issue-to-contract.yml` — `on: issues [opened]`. Approval gates are implemented as separate workflow_dispatch inputs (`approve_qa: bool`, `approve_prod: bool`) on the pipeline workflow, since Gitea ignores `environment:` blocks (D-013) | Gitea Actions has no environment reviewers, no `repository_dispatch`, no native approval UI | Pipeline can be re-dispatched by a human at each gate; the workflow_dispatch API call from a step (D-014) drives cross-repo triggering |
|
||||
| D-028 | The pipeline workflow runs all 4 stages (dev, qa-gate, prod-gate, finalize) in a single workflow run, with each gate job checking a workflow_dispatch input (`approve_qa`/`approve_prod`). When the input is false (the default), the gate job fails with a clear "awaiting approval" message; the human re-dispatches with `approve_qa=true` to advance. State (state.json, audit.json, contract ref) is passed via workflow artifacts (upload/download between jobs) because Gitea Actions artifacts work the same as GitHub Actions | Gitea Actions supports `actions/upload-artifact` and `actions/download-artifact`; the alternative is committing state between jobs, which is heavier | Deterministic, observable pipeline; artifacts keep the audit trail within one run |
|
||||
| D-029 | The finalize step commits `audit.json` to `acdl-evidence` main via the Gitea file-contents API (POST `/repos/{owner}/{repo}/contents/{path}` with the base64 content + a commit message referencing the pipeline run id), exactly like Phase 01's `gitea_setup.sh` does for `index.html`. It uses `${GITEA_TOKEN}` (a repo secret) for auth | D-012 raw-URL approach requires the file to be on main; the API is the only way to put it there from a workflow step | The evidence timeline (Phase 05 UI) fetches the raw URL after finalize completes |
|
||||
| D-030 | The issue-to-contract workflow in `acdl-contracts` checks out `l3b_agent_stub.py` from the `acdl` repo (pinned to `@milestone/v1.0-initial` per the branch-pin rule), parses the Issue body, commits `contract.yaml` to a new branch `contract/<issue-number>` on `acdl-contracts`, then dispatches the pipeline workflow on the `acdl` repo via `curl POST /actions/workflows/<id>/dispatches` with `inputs: {contract-ref: contract/<issue-number>}` (D-014). The pipeline workflow checks out `acdl-contracts` at that ref to read the contract | Gitea Actions cannot trigger across repos without an explicit API call; the branch carries the contract ref | Reproducible Act 3: Issue → contract.yaml → pipeline run with the same contract as Act 2 |
|
||||
| D-031 | Phase 05 "dry run" = a local bash script (`scripts/run_demo.sh`) that simulates the full pipeline by calling the core scripts in sequence + writing evidence events via `evidence_writer.py` + uploading `audit.json` to `acdl-evidence` via `finalize_evidence.py`. It does NOT use act_runner (no runner is registered in this environment). It exercises all 4 acts: (1) Friction — a static "manual 2-week" log entry; (2) Developer Self-Service — a valid `contract.yaml` for `l2-commodity-price-feed`, full pipeline (dev→qa→prod→finalize), all evidence events; (3) Citizen Developer — an Issue body, `l3b_agent_stub.py` produces the contract, identical pipeline; (4) Safety Net — a malicious `public-ingress: true` contract for `l2-regulatory-reporting`, dev rejects with confidence < 0.50, rejection visible in the timeline | The spec says "4 scripted acts reproduce deterministically in a dry run"; without a runner, the bash simulation IS the deterministic reproduction | The same `audit.json` shape is produced as a real pipeline run would produce, so the `index.html` UI renders the timeline identically |
|
||||
| D-032 | `index.html` (vanilla JS) is committed to the `acdl` repo at `evidence-ui/index.html` as the source of truth, and pushed to `acdl-evidence` main by `scripts/run_demo.sh` (via the file-contents API) alongside `audit.json`. The UI fetches `audit.json` from the same raw-URL origin (D-012). It renders events as a vertical timeline with `seq`, `ts`, `stage` (color-coded), `event` text, and a truncated `hash` per event. No frameworks; one HTML file with inline CSS + JS | D-012 raw-URL approach; the UI must be a single file (no separate JS/CSS) for simplicity; vanilla JS per REQ-14 | The UI loads in any browser by visiting the raw URL; it fetches the sibling `audit.json` and renders |
|
||||
| D-033 | The `audit.json` raw URL on `acdl-evidence` main is the single source of truth for the timeline. `run_demo.sh` writes the final `audit.json` (after all 4 acts) and the final `index.html` in two API calls. Re-running `run_demo.sh` overwrites both (idempotent). The UI's fetch URL is relative (`./audit.json`) so the same `index.html` works against any raw-URL origin | D-012 + D-029; relative URL avoids hardcoding the host | Deterministic re-run; UI always reflects the latest audit |
|
||||
+11
-11
@@ -60,15 +60,15 @@
|
||||
| REQ-01 | 1 | complete (v1.0.1) |
|
||||
| REQ-02 | 2 | complete (v1.0.2) |
|
||||
| REQ-03 | 2 | complete (v1.0.2) |
|
||||
| REQ-04 | 3 | covered (pending VERIFY) |
|
||||
| REQ-05 | 3 | covered (pending VERIFY) |
|
||||
| REQ-06 | 3 | covered (pending VERIFY) |
|
||||
| REQ-07 | 3 | covered (pending VERIFY) |
|
||||
| REQ-08 | 3 | covered (pending VERIFY) |
|
||||
| REQ-04 | 3 | complete (v1.0.3) |
|
||||
| REQ-05 | 3 | complete (v1.0.3) |
|
||||
| REQ-06 | 3 | complete (v1.0.3) |
|
||||
| REQ-07 | 3 | complete (v1.0.3) |
|
||||
| REQ-08 | 3 | complete (v1.0.3) |
|
||||
| REQ-09 | 1 | complete (v1.0.1) |
|
||||
| REQ-10 | 4 | partial (skeleton in Phase 01 v1.0.1; full impl in Phase 04) |
|
||||
| REQ-11 | 3 | covered (pending VERIFY) |
|
||||
| REQ-12 | 4 | partial (skeleton in Phase 01 v1.0.1; l3b_agent_stub in Phase 03; full trigger wiring in Phase 04) |
|
||||
| REQ-13 | 5 | pending |
|
||||
| REQ-14 | 5 | pending |
|
||||
| REQ-15 | 5 | pending |
|
||||
| REQ-10 | 4 | complete (v1.0.4) |
|
||||
| REQ-11 | 3 | complete (v1.0.3) |
|
||||
| REQ-12 | 4 | complete (v1.0.4) |
|
||||
| REQ-13 | 5 | covered (pending VERIFY) |
|
||||
| REQ-14 | 5 | covered (pending VERIFY) |
|
||||
| REQ-15 | 5 | covered (pending VERIFY) |
|
||||
+3
-3
@@ -27,7 +27,7 @@ Five-phase breakdown to take ACDL from empty repo to a reproducible 4-act execut
|
||||
|
||||
### Phase 03 — l2-modules-and-core-scripts
|
||||
- **Description:** Create the 4 L2 compositions under `acdl/modules/l2/` referencing L1s, plus the 5 core scripts in `acdl/scripts/` (`mock_executor.sh`, `policy_checker.py`, `confidence_signal.py`, `evidence_writer.py`, `l3b_agent_stub.py`).
|
||||
- **Status:** executing
|
||||
- **Status:** complete (v1.0.3)
|
||||
- **Depends on:** [2]
|
||||
- **Requirements:** REQ-04, REQ-05, REQ-06, REQ-07
|
||||
- **Success Criteria:**
|
||||
@@ -39,7 +39,7 @@ Five-phase breakdown to take ACDL from empty repo to a reproducible 4-act execut
|
||||
|
||||
### Phase 04 — pipeline-and-approval-gates
|
||||
- **Description:** Build the reusable pipeline workflow in `acdl/.gitea/workflows/` (Dev → QA → Prod → Finalize) plus the issue-triggered L3B workflow in `acdl-contracts/.gitea/workflows/`. Wire environment protection for QA and Prod.
|
||||
- **Status:** not_started
|
||||
- **Status:** complete (v1.0.4)
|
||||
- **Depends on:** [3]
|
||||
- **Requirements:** REQ-08, REQ-09, REQ-10, REQ-12
|
||||
- **Success Criteria:**
|
||||
@@ -49,7 +49,7 @@ Five-phase breakdown to take ACDL from empty repo to a reproducible 4-act execut
|
||||
|
||||
### Phase 05 — evidence-ui-and-demo-dry-run
|
||||
- **Description:** Build `index.html` (vanilla JS, fetches `audit.json`, renders timeline) and run all four acts end-to-end as a dry run.
|
||||
- **Status:** not_started
|
||||
- **Status:** executing
|
||||
- **Depends on:** [4]
|
||||
- **Requirements:** REQ-11, REQ-13, REQ-14, REQ-15
|
||||
- **Success Criteria:**
|
||||
|
||||
+118
-40
@@ -1,75 +1,153 @@
|
||||
# ACDL reusable pipeline workflow (Phase 01 skeleton).
|
||||
# ACDL pipeline workflow (Phase 04 implementation).
|
||||
#
|
||||
# This workflow is called from acdl-contracts via:
|
||||
# uses: continuous-intelligence/acdl/.gitea/workflows/pipeline.yml@milestone/v1.0-initial
|
||||
# 3-dispatch approval-gate topology (D-027 / D-028; ARCHITECTURE.md
|
||||
# "Phase 04 pipeline topology"):
|
||||
#
|
||||
# Branch pinning rule (see .ciagent/ARCHITECTURE.md): the `acdl` repo's default
|
||||
# branch is `milestone/v1.0-initial`, so `uses:` references must pin to
|
||||
# `@milestone/v1.0-initial`, NOT `@main`.
|
||||
# Dispatch 1 (initial): approve_qa=false, approve_prod=false
|
||||
# -> runs the `dev` job (policy check, confidence
|
||||
# gate, mock_executor, evidence + finalize).
|
||||
# Dispatch 2 (QA approve): approve_qa=true, approve_prod=false
|
||||
# -> runs the `qa-gate` job (records QA approval
|
||||
# in the audit chain via evidence_writer +
|
||||
# finalize_evidence).
|
||||
# Dispatch 3 (Prod approve): approve_prod=true
|
||||
# -> runs the `prod-gate` job, then the `finalize`
|
||||
# job (needs: prod-gate) which writes the final
|
||||
# evidence event and commits audit.json to
|
||||
# acdl-evidence.
|
||||
#
|
||||
# Phase 04 will implement the actual stage logic + approval gates (D-013:
|
||||
# Gitea has no environments API; gates become workflow_dispatch approval
|
||||
# inputs).
|
||||
# Gitea Actions limitations driving this design:
|
||||
# - No `repository_dispatch` trigger (D-014).
|
||||
# - No environments API / `environment:` blocks are ignored (D-013).
|
||||
# - Re-dispatch starts a NEW run; artifacts do NOT survive between runs,
|
||||
# so state is persisted to acdl-evidence via the file-contents API
|
||||
# (D-028 / finalize_evidence.py) instead of via artifacts.
|
||||
#
|
||||
# Branch-pin rule (ARCHITECTURE.md "Branch pinning rule"):
|
||||
# This workflow lives on `acdl`'s default branch `milestone/v1.0-initial`.
|
||||
# Cross-repo `uses:` references (e.g. the issue-trigger's checkout of
|
||||
# l3b_agent_stub.py) MUST pin to `@milestone/v1.0-initial`, NOT `@main`
|
||||
# (the `acdl` repo has no `main` branch). This workflow is invoked via
|
||||
# the workflow_dispatch API (D-014), NOT via `workflow_call`, so the
|
||||
# `uses:` rule applies to the issue-trigger's checkout of the acdl repo,
|
||||
# not to this file itself.
|
||||
name: acdl-pipeline
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
"on":
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
contract-ref:
|
||||
description: "Ref on acdl-contracts that triggered the pipeline"
|
||||
description: "Ref on acdl-contracts that carries the contract"
|
||||
required: false
|
||||
type: string
|
||||
default: main
|
||||
approve_qa:
|
||||
description: "Human approval to advance past QA"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
approve_prod:
|
||||
description: "Human approval to advance past Prod"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
dev:
|
||||
name: "Dev (autonomous)"
|
||||
if: inputs.approve_qa != true && inputs.approve_prod != true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Phase 04 will implement: checkout acdl + acdl-contracts, run
|
||||
# policy_checker.py, mock_executor.sh, confidence_signal.py, write
|
||||
# evidence via evidence_writer.py.
|
||||
- name: "Dev stage placeholder"
|
||||
- name: "Checkout acdl (this repo, pinned to milestone/v1.0-initial)"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: milestone/v1.0-initial
|
||||
|
||||
- name: "Checkout acdl-contracts at contract-ref"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: continuous-intelligence/acdl-contracts
|
||||
ref: ${{ inputs.contract-ref }}
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
path: acdl-contracts
|
||||
|
||||
- name: "Policy check"
|
||||
run: |
|
||||
echo "Dev stage placeholder (Phase 01 skeleton)"
|
||||
echo "Phase 04 will run policy_checker, mock_executor, confidence_signal, evidence_writer"
|
||||
exit 0
|
||||
python3 scripts/policy_checker.py acdl-contracts/contract.yaml
|
||||
|
||||
- name: "Confidence signal"
|
||||
id: confidence
|
||||
run: |
|
||||
set +e
|
||||
SCORE_JSON=$(python3 scripts/confidence_signal.py acdl-contracts/contract.yaml)
|
||||
echo "$SCORE_JSON"
|
||||
echo "score_json=$SCORE_JSON" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Apply or reject based on confidence (gate < 0.50)"
|
||||
run: |
|
||||
set +e
|
||||
SCORE=$(python3 -c "import json,sys; print(json.load(sys.stdin)['score'])" <<< '${{ steps.confidence.outputs.score_json }}')
|
||||
python3 -c "import sys; sys.exit(0 if float('${SCORE}') >= 0.50 else 1)"
|
||||
THRESHOLD_RC=$?
|
||||
if [ "$THRESHOLD_RC" -ne 0 ]; then
|
||||
python3 scripts/evidence_writer.py --stage dev --event "dev rejected: confidence < 0.50" --audit audit.json
|
||||
python3 scripts/finalize_evidence.py --audit audit.json
|
||||
exit 1
|
||||
fi
|
||||
STACK=$(python3 -c 'import yaml; print(yaml.safe_load(open("acdl-contracts/contract.yaml"))["stack"])')
|
||||
bash scripts/mock_executor.sh acdl-contracts/contract.yaml
|
||||
python3 scripts/evidence_writer.py --stage dev --event "dev applied: ${STACK}" --audit audit.json
|
||||
python3 scripts/finalize_evidence.py --audit audit.json
|
||||
|
||||
- name: "Upload dev state artifacts (best-effort)"
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: dev-state
|
||||
path: |
|
||||
audit.json
|
||||
state.json
|
||||
|
||||
qa-gate:
|
||||
name: "QA (manual approval)"
|
||||
needs: dev
|
||||
if: inputs.approve_qa == true && inputs.approve_prod != true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Phase 04 will implement: gate via workflow_dispatch approval input
|
||||
# (D-013 fallback; Gitea ignores jobs.<id>.environment).
|
||||
- name: "QA gate placeholder"
|
||||
- name: "Checkout acdl (this repo, pinned to milestone/v1.0-initial)"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: milestone/v1.0-initial
|
||||
|
||||
- name: "Record QA approval in evidence"
|
||||
run: |
|
||||
echo "QA gate placeholder (Phase 01 skeleton)"
|
||||
echo "Phase 04 will pause here for human approval via workflow_dispatch"
|
||||
exit 0
|
||||
python3 scripts/evidence_writer.py --stage qa --event "qa approved" --audit audit.json
|
||||
python3 scripts/finalize_evidence.py --audit audit.json
|
||||
|
||||
prod-gate:
|
||||
name: "Prod (manual approval)"
|
||||
needs: qa-gate
|
||||
if: inputs.approve_prod == true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Phase 04 will implement: same approval-input gate as qa-gate.
|
||||
- name: "Prod gate placeholder"
|
||||
- name: "Checkout acdl (this repo, pinned to milestone/v1.0-initial)"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: milestone/v1.0-initial
|
||||
|
||||
- name: "Record Prod approval in evidence"
|
||||
run: |
|
||||
echo "Prod gate placeholder (Phase 01 skeleton)"
|
||||
echo "Phase 04 will pause here for human approval via workflow_dispatch"
|
||||
exit 0
|
||||
python3 scripts/evidence_writer.py --stage prod --event "prod approved" --audit audit.json
|
||||
python3 scripts/finalize_evidence.py --audit audit.json
|
||||
|
||||
finalize:
|
||||
name: "Finalize (publish evidence)"
|
||||
needs: prod-gate
|
||||
needs: [prod-gate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Phase 04/05 will implement: commit audit.json to acdl-evidence main
|
||||
# via the Gitea file-contents API; raw URL republishes index.html +
|
||||
# audit.json for the timeline UI (D-012).
|
||||
- name: "Finalize placeholder"
|
||||
- name: "Checkout acdl (this repo, pinned to milestone/v1.0-initial)"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: milestone/v1.0-initial
|
||||
|
||||
- name: "Write finalize event + commit audit.json to acdl-evidence"
|
||||
run: |
|
||||
echo "Finalize placeholder (Phase 01 skeleton)"
|
||||
echo "Phase 04/05 will commit audit.json to acdl-evidence main"
|
||||
exit 0
|
||||
python3 scripts/evidence_writer.py --stage finalize --event "pipeline complete: audit.json committed to acdl-evidence" --audit audit.json
|
||||
python3 scripts/finalize_evidence.py --audit audit.json
|
||||
@@ -1,18 +1,35 @@
|
||||
# ACDL issue-to-contract workflow (Phase 01 skeleton, reference copy).
|
||||
#
|
||||
# This file is the source-of-truth copy kept in the `acdl` repo under
|
||||
# contracts-repo/.gitea/workflows/. Phase 04 will push it to the actual
|
||||
# `acdl-contracts` repo under .gitea/workflows/ and implement the real
|
||||
# step bodies.
|
||||
# ACDL issue-to-contract workflow (Phase 04 implementation).
|
||||
#
|
||||
# Trigger: a new Issue is opened in acdl-contracts. The workflow runs
|
||||
# l3b_agent_stub.py to map the Issue body to a contract.yaml, commits the
|
||||
# contract to a new branch, closes the Issue, and triggers the main
|
||||
# pipeline in the `acdl` repo via the workflow_dispatch API (D-014; Gitea
|
||||
# Actions does not support repository_dispatch).
|
||||
# l3b_agent_stub.py (checked out from the `acdl` repo, pinned to
|
||||
# @milestone/v1.0-initial) to map the Issue body to a contract.yaml, commits
|
||||
# the contract to a new branch `contract/<issue-number>` on acdl-contracts
|
||||
# via the Gitea file-contents API, closes the Issue with a comment, and
|
||||
# dispatches the main pipeline in the `acdl` repo via the workflow_dispatch
|
||||
# API (D-014; Gitea Actions does not support repository_dispatch).
|
||||
#
|
||||
# Cross-repo trigger (D-014):
|
||||
# The final step POSTs to
|
||||
# /api/v1/repos/continuous-intelligence/acdl/actions/workflows/pipeline.yml/dispatches
|
||||
# with body {"ref": "milestone/v1.0-initial",
|
||||
# "inputs": {"contract-ref": "contract/<issue-number>"}}.
|
||||
#
|
||||
# Branch-pin rule (ARCHITECTURE.md):
|
||||
# The `acdl` repo's default branch is `milestone/v1.0-initial`, so the
|
||||
# checkout step pins `ref: milestone/v1.0-initial`. The pipeline dispatch
|
||||
# also pins `ref: milestone/v1.0-initial` (the workflow file lives on
|
||||
# that branch). The new `contract/<n>` branch is created on acdl-contracts
|
||||
# (whose default branch is `main`, per D-015).
|
||||
#
|
||||
# File-contents POST with `new_branch` (D-030):
|
||||
# The POST to /repos/.../contents/contract.yaml includes
|
||||
# `new_branch: contract/<n>`, which tells Gitea to create the file on a
|
||||
# NEW branch off the current head of `branch: main` instead of committing
|
||||
# directly to main. This avoids a separate branch-create + commit round
|
||||
# trip.
|
||||
name: issue-to-contract
|
||||
|
||||
on:
|
||||
"on":
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
@@ -20,21 +37,109 @@ jobs:
|
||||
parse-and-trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Phase 04 will implement:
|
||||
# 1. checkout acdl-contracts (so l3b_agent_stub.py is available).
|
||||
# 2. run: python3 scripts/l3b_agent_stub.py "${{ gitea.event.issue.body }}" > contract.yaml
|
||||
# 3. parse the generated contract; commit it to a new branch
|
||||
# (e.g. contract/<issue-number>).
|
||||
# 4. push the branch.
|
||||
# 5. close the Issue with a comment linking to the pipeline run.
|
||||
# 6. trigger the main pipeline:
|
||||
# curl -X POST \
|
||||
# -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
# https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/actions/workflows/<id>/dispatches \
|
||||
# -d '{"ref":"milestone/v1.0-initial","inputs":{"contract-ref":"<branch>"}}'
|
||||
- name: "Issue-trigger placeholder"
|
||||
- name: "Checkout acdl (pinned to milestone/v1.0-initial for l3b_agent_stub.py)"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: continuous-intelligence/acdl
|
||||
ref: milestone/v1.0-initial
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: "Parse Issue body into contract.yaml"
|
||||
env:
|
||||
ISSUE_BODY: ${{ gitea.event.issue.body }}
|
||||
run: |
|
||||
echo "issue-to-contract placeholder (Phase 01 skeleton)"
|
||||
echo "Issue body: ${{ gitea.event.issue.body }}"
|
||||
echo "Phase 04 will run l3b_agent_stub.py, commit contract.yaml, close issue, dispatch pipeline"
|
||||
exit 0
|
||||
# Pass the Issue body via an env var to avoid shell injection from
|
||||
# arbitrary Issue text. l3b_agent_stub.py reads argv[1]; we pass
|
||||
# the env var quoted so no metacharacter interpretation happens.
|
||||
python3 scripts/l3b_agent_stub.py "$ISSUE_BODY" -o contract.yaml
|
||||
echo "--- generated contract.yaml ---"
|
||||
cat contract.yaml
|
||||
|
||||
- name: "Commit contract.yaml to new branch contract/${{ gitea.event.issue.number }} on acdl-contracts"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STACK=$(python3 -c 'import yaml; print(yaml.safe_load(open("contract.yaml"))["stack"])')
|
||||
ISSUE_NUMBER="${{ gitea.event.issue.number }}"
|
||||
BRANCH="contract/${ISSUE_NUMBER}"
|
||||
HOST="https://git.cloudinit.dev"
|
||||
API="${HOST}/api/v1/repos/continuous-intelligence/acdl-contracts/contents/contract.yaml"
|
||||
B64=$(base64 -w 0 contract.yaml)
|
||||
BODY=$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({
|
||||
'content': '${B64}',
|
||||
'message': 'l3b: contract for issue #${ISSUE_NUMBER}',
|
||||
'branch': 'main',
|
||||
'new_branch': '${BRANCH}'
|
||||
}))
|
||||
")
|
||||
STATUS=$(curl -sS -o /tmp/contract_post.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
"$API")
|
||||
echo "POST contract.yaml -> HTTP ${STATUS}"
|
||||
cat /tmp/contract_post.json || true
|
||||
case "$STATUS" in
|
||||
201) echo "contract.yaml committed on branch ${BRANCH}" ;;
|
||||
*) echo "ERROR: file-contents POST failed (HTTP ${STATUS})" >&2; exit 1 ;;
|
||||
esac
|
||||
echo "STACK=${STACK}" >> "$GITHUB_ENV"
|
||||
echo "BRANCH=${BRANCH}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: "Comment on Issue + close it"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ISSUE_NUMBER="${{ gitea.event.issue.number }}"
|
||||
HOST="https://git.cloudinit.dev"
|
||||
ISSUES_API="${HOST}/api/v1/repos/continuous-intelligence/acdl-contracts/issues/${ISSUE_NUMBER}"
|
||||
COMMENT_BODY=$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({'body': 'Generated contract.yaml for stack \`' + '${STACK}' + '\` on branch \`' + '${BRANCH}' + '\`. Pipeline dispatched.'}))
|
||||
")
|
||||
curl -sS -o /tmp/comment.json -w "comment HTTP %{http_code}\n" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$COMMENT_BODY" \
|
||||
"${ISSUES_API}/comments"
|
||||
CLOSE_BODY='{"state":"closed"}'
|
||||
curl -sS -o /tmp/close.json -w "close HTTP %{http_code}\n" \
|
||||
-X PATCH \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$CLOSE_BODY" \
|
||||
"${ISSUES_API}"
|
||||
|
||||
- name: "Dispatch the pipeline on acdl (contract-ref = contract/${{ gitea.event.issue.number }})"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ISSUE_NUMBER="${{ gitea.event.issue.number }}"
|
||||
HOST="https://git.cloudinit.dev"
|
||||
DISPATCH_URL="${HOST}/api/v1/repos/continuous-intelligence/acdl/actions/workflows/pipeline.yml/dispatches"
|
||||
BODY=$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({
|
||||
'ref': 'milestone/v1.0-initial',
|
||||
'inputs': {'contract-ref': 'contract/${ISSUE_NUMBER}'}
|
||||
}))
|
||||
")
|
||||
STATUS=$(curl -sS -o /tmp/dispatch.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
"$DISPATCH_URL")
|
||||
echo "pipeline dispatch -> HTTP ${STATUS}"
|
||||
cat /tmp/dispatch.json || true
|
||||
case "$STATUS" in
|
||||
201|202|204) echo "pipeline dispatched (contract-ref=contract/${ISSUE_NUMBER})" ;;
|
||||
*) echo "ERROR: pipeline dispatch failed (HTTP ${STATUS})" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,334 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ACDL Evidence Timeline</title>
|
||||
<style>
|
||||
:root {
|
||||
--stage-dev: #2563eb;
|
||||
--stage-qa: #ca8a04;
|
||||
--stage-prod: #ea580c;
|
||||
--stage-finalize: #16a34a;
|
||||
--stage-genesis: #6b7280;
|
||||
--stage-rejected: #dc2626;
|
||||
--bg: #f8fafc;
|
||||
--card-bg: #ffffff;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
padding: 24px 32px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 32px;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
button#refresh {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--text);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button#refresh:hover { opacity: 0.9; }
|
||||
button#refresh:active { transform: translateY(1px); }
|
||||
.toolbar .status {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
main {
|
||||
padding: 24px 32px 48px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.empty {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
background: var(--card-bg);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
ol.timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
ol.timeline::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
li.event {
|
||||
position: relative;
|
||||
padding: 12px 0 12px 40px;
|
||||
}
|
||||
li.event::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 18px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot, var(--muted));
|
||||
border: 2px solid var(--card-bg);
|
||||
box-shadow: 0 0 0 1px var(--border);
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--dot, var(--muted));
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.card .row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.seq {
|
||||
display: inline-block;
|
||||
min-width: 28px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
border: 1px solid #c7d2fe;
|
||||
}
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: var(--dot, var(--muted));
|
||||
}
|
||||
.ts {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.event-text {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.hash {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
footer {
|
||||
padding: 16px 32px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
footer code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: #f1f5f9;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ACDL Evidence Timeline</h1>
|
||||
<p>ACDL — Agentic Cloud Delivery Platform · Audit Timeline</p>
|
||||
</header>
|
||||
<div class="toolbar">
|
||||
<button id="refresh" type="button">Refresh</button>
|
||||
<span class="status" id="status"></span>
|
||||
</div>
|
||||
<main>
|
||||
<div id="container">
|
||||
<div class="empty">Loading…</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<div id="footer"></div>
|
||||
</footer>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var AUDIT_URL = "./audit.json";
|
||||
var STAGE_COLORS = {
|
||||
dev: "var(--stage-dev)",
|
||||
qa: "var(--stage-qa)",
|
||||
prod: "var(--stage-prod)",
|
||||
finalize: "var(--stage-finalize)",
|
||||
genesis: "var(--stage-genesis)"
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function stageColor(stage, eventText) {
|
||||
var evt = (eventText || "").toString().toLowerCase();
|
||||
if (evt.indexOf("rejected") !== -1) {
|
||||
return "var(--stage-rejected)";
|
||||
}
|
||||
return STAGE_COLORS[stage] || "var(--stage-genesis)";
|
||||
}
|
||||
|
||||
function dash(v) {
|
||||
return (v === null || v === undefined || v === "") ? "—" : v;
|
||||
}
|
||||
|
||||
function hashPreview(hash) {
|
||||
if (hash === null || hash === undefined || hash === "") return "—";
|
||||
var s = String(hash);
|
||||
return s.slice(0, 12) + "…";
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function auditUrlDisplay() {
|
||||
try {
|
||||
var href = window.location.href;
|
||||
var slash = href.lastIndexOf("/");
|
||||
if (slash >= 0) {
|
||||
return href.slice(0, slash + 1) + "audit.json";
|
||||
}
|
||||
} catch (e) {}
|
||||
return AUDIT_URL;
|
||||
}
|
||||
|
||||
function renderEmpty(msg) {
|
||||
$("container").innerHTML =
|
||||
'<div class="empty">' + esc(msg) + "</div>";
|
||||
}
|
||||
|
||||
function renderTimeline(events) {
|
||||
if (!Array.isArray(events)) {
|
||||
renderEmpty("No audit data yet");
|
||||
return;
|
||||
}
|
||||
if (events.length === 0) {
|
||||
renderEmpty("No audit data yet");
|
||||
return;
|
||||
}
|
||||
var sorted = events.slice().sort(function (a, b) {
|
||||
var sa = (a && typeof a.seq === "number") ? a.seq : 0;
|
||||
var sb = (b && typeof b.seq === "number") ? b.seq : 0;
|
||||
return sa - sb;
|
||||
});
|
||||
var html = '<ol class="timeline">';
|
||||
for (var i = 0; i < sorted.length; i++) {
|
||||
var e = sorted[i] || {};
|
||||
var stage = dash(e.stage);
|
||||
var color = stageColor(e.stage, e.event);
|
||||
html += '<li class="event" style="--dot:' + color + ';">';
|
||||
html += '<div class="card" style="--dot:' + color + ';">';
|
||||
html += '<div class="row">';
|
||||
html += '<span class="seq">#' + esc(dash(e.seq)) + "</span>";
|
||||
html += '<span class="chip">' + esc(stage) + "</span>";
|
||||
html += '<span class="ts">' + esc(dash(e.ts)) + "</span>";
|
||||
html += "</div>";
|
||||
html += '<div class="event-text">' + esc(dash(e.event)) + "</div>";
|
||||
html += '<div class="hash">' + esc(hashPreview(e.hash)) + "</div>";
|
||||
html += "</div>";
|
||||
html += "</li>";
|
||||
}
|
||||
html += "</ol>";
|
||||
$("container").innerHTML = html;
|
||||
}
|
||||
|
||||
function renderFooter(ok) {
|
||||
var when = new Date().toISOString();
|
||||
var url = auditUrlDisplay();
|
||||
var prefix = "Fetched at " + when + " · audit.json: ";
|
||||
$("footer").innerHTML =
|
||||
esc(prefix) + '<code>' + esc(url) + "</code>" +
|
||||
(ok ? "" : " (fetch failed)");
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
$("status").textContent = msg || "";
|
||||
}
|
||||
|
||||
function fetchAudit() {
|
||||
setStatus("Fetching…");
|
||||
fetch(AUDIT_URL, { cache: "no-store" })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
throw new Error("HTTP " + res.status);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("not an array");
|
||||
}
|
||||
renderTimeline(data);
|
||||
renderFooter(true);
|
||||
setStatus("Loaded " + data.length + " event(s)");
|
||||
})
|
||||
.catch(function (err) {
|
||||
renderEmpty("No audit data yet");
|
||||
renderFooter(false);
|
||||
setStatus("Fetch failed: " + (err && err.message ? err.message : "error"));
|
||||
});
|
||||
}
|
||||
|
||||
$("refresh").addEventListener("click", fetchAudit);
|
||||
fetchAudit();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""finalize_evidence.py — REQ-10 / D-028 / D-029
|
||||
|
||||
Uploads (PUT or POST) a local `audit.json` to the `acdl-evidence` repo on
|
||||
Gitea via the file-contents API. Used by the pipeline workflow steps to
|
||||
persist the hash-chained audit trail to `acdl-evidence` between dispatches
|
||||
(D-028 state-persistence across re-dispatches; D-029 finalize step).
|
||||
|
||||
Uses only the Python standard library (urllib.request) so it has no
|
||||
external dependency on `requests`. Auth header: `Authorization: token <token>`.
|
||||
|
||||
Input (argv flags):
|
||||
--audit <path> (required) local audit.json file to upload
|
||||
--owner <org> (optional, default continuous-intelligence)
|
||||
--repo <name> (optional, default acdl-evidence)
|
||||
--branch <name> (optional, default main)
|
||||
--path <remote path> (optional, default audit.json) path in the repo
|
||||
--token-env <env var> (optional, default ACDL_GITEA_TOKEN)
|
||||
--host <url> (optional, default https://git.cloudinit.dev)
|
||||
--message <commit msg> (optional, default chore(evidence): update audit.json)
|
||||
|
||||
Behavior:
|
||||
1. Read the token from os.environ[token_env]. Missing -> stderr + exit 1.
|
||||
2. Read the local audit file; base64-encode it.
|
||||
3. GET the current file at .../contents/<path>?ref=<branch> to discover
|
||||
the existing `sha`. 200 -> capture sha (update mode). 404 -> no sha
|
||||
(create mode). Other errors -> exit 1.
|
||||
4. If sha set: PUT with body {content, message, branch, sha}.
|
||||
If no sha: POST with body {content, message, branch}.
|
||||
5. Print {"uploaded": true, "path": "<path>", "sha": "<new sha>"} to
|
||||
stdout and exit 0.
|
||||
6. On any HTTP error: print
|
||||
{"uploaded": false, "status": <code>, "body": "<body>"} to stdout
|
||||
and exit 1.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def _request(method: str, url: str, token: str, body: dict = None):
|
||||
"""Perform an HTTP request with the Gitea auth header. Returns
|
||||
(status_code, response_body_text). Raises URLError on network failure."""
|
||||
data = None
|
||||
headers = {"Authorization": f"token {token}",
|
||||
"Accept": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return resp.getcode(), resp.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
# HTTPError carries the response body
|
||||
try:
|
||||
body_text = exc.read().decode("utf-8", "replace")
|
||||
except Exception:
|
||||
body_text = ""
|
||||
return exc.code, body_text
|
||||
except urllib.error.URLError as exc:
|
||||
# Network-level failure (connection refused, DNS, timeout). Return
|
||||
# a synthetic 0 status + the reason so callers can report cleanly
|
||||
# without a stack trace.
|
||||
return 0, f"URLError: {exc.reason}"
|
||||
|
||||
|
||||
def get_existing_sha(host: str, owner: str, repo: str, path: str,
|
||||
branch: str, token: str):
|
||||
"""Return (sha-or-None, error_status_or_None). On 200 returns the sha.
|
||||
On 404 returns (None, None). Other codes return (None, (status, body))."""
|
||||
qs = urllib.parse.urlencode({"ref": branch})
|
||||
url = f"{host}/api/v1/repos/{owner}/{repo}/contents/{path}?{qs}"
|
||||
status, body = _request("GET", url, token)
|
||||
if status == 200:
|
||||
try:
|
||||
data = json.loads(body)
|
||||
return data.get("sha"), None
|
||||
except (ValueError, TypeError):
|
||||
return None, (status, body)
|
||||
if status == 404:
|
||||
return None, None
|
||||
return None, (status, body)
|
||||
|
||||
|
||||
def upload(host: str, owner: str, repo: str, path: str, branch: str,
|
||||
message: str, content_b64: str, sha, token: str):
|
||||
"""PUT (update) or POST (create) the file. Returns (new_sha, None) on
|
||||
success or (None, (status, body)) on HTTP error."""
|
||||
url = f"{host}/api/v1/repos/{owner}/{repo}/contents/{path}"
|
||||
if sha:
|
||||
body = {"content": content_b64, "message": message,
|
||||
"branch": branch, "sha": sha}
|
||||
status, resp = _request("PUT", url, token, body)
|
||||
else:
|
||||
body = {"content": content_b64, "message": message, "branch": branch}
|
||||
status, resp = _request("POST", url, token, body)
|
||||
if status in (200, 201):
|
||||
try:
|
||||
data = json.loads(resp)
|
||||
# The file-contents API returns the new content object either at
|
||||
# top-level `content` (POST create) or `content` (PUT update).
|
||||
new_sha = None
|
||||
if isinstance(data, dict):
|
||||
content_obj = data.get("content") or data
|
||||
if isinstance(content_obj, dict):
|
||||
new_sha = content_obj.get("sha")
|
||||
return new_sha, None
|
||||
except (ValueError, TypeError):
|
||||
return None, None
|
||||
return None, (status, resp)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Upload a local audit.json to the acdl-evidence Gitea "
|
||||
"repo via the file-contents API (D-028/D-029).")
|
||||
parser.add_argument("--audit", required=True,
|
||||
help="Local audit.json file to upload")
|
||||
parser.add_argument("--owner", default="continuous-intelligence",
|
||||
help="Gitea org (default: continuous-intelligence)")
|
||||
parser.add_argument("--repo", default="acdl-evidence",
|
||||
help="Gitea repo (default: acdl-evidence)")
|
||||
parser.add_argument("--branch", default="main",
|
||||
help="Target branch (default: main)")
|
||||
parser.add_argument("--path", default="audit.json",
|
||||
help="Remote path in the repo (default: audit.json)")
|
||||
parser.add_argument("--token-env", default="ACDL_GITEA_TOKEN",
|
||||
help="Env var name holding the Gitea token "
|
||||
"(default: ACDL_GITEA_TOKEN)")
|
||||
parser.add_argument("--host", default="https://git.cloudinit.dev",
|
||||
help="Gitea host URL (default: https://git.cloudinit.dev)")
|
||||
parser.add_argument("--message", default="chore(evidence): update audit.json",
|
||||
help="Commit message (default: chore(evidence): "
|
||||
"update audit.json)")
|
||||
args = parser.parse_args()
|
||||
|
||||
token = os.environ.get(args.token_env)
|
||||
if not token:
|
||||
print(f"finalize_evidence: required env var {args.token_env} is not "
|
||||
f"set", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Read + base64-encode the local audit file. Missing/unreadable file is
|
||||
# a clean exit 1 (no stack trace).
|
||||
try:
|
||||
with open(args.audit, "rb") as fh:
|
||||
raw = fh.read()
|
||||
except OSError as exc:
|
||||
print(f"finalize_evidence: cannot read {args.audit}: {exc}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
content_b64 = base64.b64encode(raw).decode("ascii")
|
||||
|
||||
# Discover existing sha (update vs create).
|
||||
sha, err = get_existing_sha(args.host, args.owner, args.repo,
|
||||
args.path, args.branch, token)
|
||||
if err is not None:
|
||||
status, body = err
|
||||
print(json.dumps({"uploaded": False, "status": status, "body": body}))
|
||||
return 1
|
||||
|
||||
# Upload (PUT if sha, POST otherwise).
|
||||
new_sha, err = upload(args.host, args.owner, args.repo, args.path,
|
||||
args.branch, args.message, content_b64, sha, token)
|
||||
if err is not None:
|
||||
status, body = err
|
||||
print(json.dumps({"uploaded": False, "status": status, "body": body}))
|
||||
return 1
|
||||
|
||||
print(json.dumps({"uploaded": True, "path": args.path,
|
||||
"sha": new_sha}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run_demo.sh — Phase 05 dry-run simulation of the 4 demo acts (T-5.2).
|
||||
#
|
||||
# Simulates the full 4-act demo locally (no act_runner) by calling the core
|
||||
# scripts in sequence and writing hash-chained evidence events to audit.json,
|
||||
# then optionally uploads audit.json + evidence-ui/index.html to acdl-evidence
|
||||
# main via finalize_evidence.py (D-031, D-033).
|
||||
#
|
||||
# Usage: scripts/run_demo.sh [--no-upload]
|
||||
# --no-upload skip the Gitea API calls (useful for testing without a token)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Parse args
|
||||
# -----------------------------------------------------------------------------
|
||||
UPLOAD=1
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-upload)
|
||||
UPLOAD=0
|
||||
;;
|
||||
*)
|
||||
echo "run_demo.sh: unknown argument: $arg" >&2
|
||||
echo "usage: scripts/run_demo.sh [--no-upload]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Paths
|
||||
# -----------------------------------------------------------------------------
|
||||
# Repo root = location of this script's parent dir.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
WORKDIR="/tmp/acdl_demo_run"
|
||||
AUDIT="$WORKDIR/audit.json"
|
||||
CONTRACTS="$WORKDIR/contracts"
|
||||
|
||||
# Track failures so we can return non-zero at the end (we do NOT use set -e
|
||||
# because policy_checker intentionally exits 1 on Act 4).
|
||||
FAIL=0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Write one evidence event. Args: <stage> <event-text>
|
||||
ev() {
|
||||
local stage="$1"
|
||||
local text="$2"
|
||||
if ! python3 "$SCRIPT_DIR/evidence_writer.py" --stage "$stage" --event "$text" --audit "$AUDIT"; then
|
||||
echo "run_demo.sh: evidence_writer failed for stage=$stage text=$text" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run a contract through the Act 2/3 pipeline (policy -> confidence -> executor).
|
||||
# Assumes the contract already passed policy (caller verifies). Writes the
|
||||
# standard 4-event sequence. Args: <act-label> <dev-applied-event-text>
|
||||
run_passing_pipeline() {
|
||||
local dev_event="$1"
|
||||
|
||||
ev dev "$dev_event"
|
||||
ev qa "qa approved"
|
||||
ev prod "prod approved"
|
||||
ev finalize "finalize: audit.json committed to acdl-evidence"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Setup working directory
|
||||
# -----------------------------------------------------------------------------
|
||||
mkdir -p "$CONTRACTS"
|
||||
rm -f "$AUDIT"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Initialize audit (genesis)
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "== run_demo.sh: initializing audit at $AUDIT =="
|
||||
ev genesis "audit log initialized"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Act 1 — Friction
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "== Act 1 — Friction =="
|
||||
ev dev "Act 1 Friction: manual 2-week deployment (legacy process)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Act 2 — Developer Self-Service
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "== Act 2 — Developer Self-Service =="
|
||||
cat > "$CONTRACTS/act2.yaml" <<'YAML'
|
||||
stack: l2-commodity-price-feed
|
||||
inputs:
|
||||
environment: dev
|
||||
owner: platform-team
|
||||
public-ingress: false
|
||||
YAML
|
||||
|
||||
ACT2_POLICY="$(python3 "$SCRIPT_DIR/policy_checker.py" "$CONTRACTS/act2.yaml")"
|
||||
ACT2_POLICY_RC=$?
|
||||
echo " policy_checker: $ACT2_POLICY (rc=$ACT2_POLICY_RC)"
|
||||
if [ "$ACT2_POLICY" != "POLICY_PASS" ]; then
|
||||
echo "run_demo.sh: Act 2 expected POLICY_PASS, got '$ACT2_POLICY'" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
ACT2_CONF="$(python3 "$SCRIPT_DIR/confidence_signal.py" "$CONTRACTS/act2.yaml")"
|
||||
echo " confidence_signal: $ACT2_CONF"
|
||||
# Expected: {"score": 0.90, "reason": "POLICY_PASS"}
|
||||
|
||||
# mock_executor.sh resolves modules/l2/<stack>/manifest.yaml relative to its
|
||||
# cwd, so it must run from the repo root. It writes state.json to its cwd;
|
||||
# clean it up from the repo root afterward so no stray file is left there.
|
||||
(
|
||||
cd "$REPO_ROOT" && bash "$SCRIPT_DIR/mock_executor.sh" "$CONTRACTS/act2.yaml"
|
||||
)
|
||||
MOCK_RC=$?
|
||||
rm -f "$REPO_ROOT/state.json"
|
||||
if [ "$MOCK_RC" -ne 0 ]; then
|
||||
echo "run_demo.sh: Act 2 mock_executor failed (rc=$MOCK_RC)" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
run_passing_pipeline "dev applied: l2-commodity-price-feed"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Act 3 — Citizen Developer
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "== Act 3 — Citizen Developer =="
|
||||
ISSUE_BODY="We need to ingest natural gas prices from Platts and report on compliance for the trading desk."
|
||||
if ! python3 "$SCRIPT_DIR/l3b_agent_stub.py" "$ISSUE_BODY" -o "$CONTRACTS/act3.yaml"; then
|
||||
echo "run_demo.sh: l3b_agent_stub failed for Act 3" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# Confirm the generated contract's stack (D-008: gas/price matches first).
|
||||
ACT3_STACK="$(python3 -c "import yaml,sys; print(yaml.safe_load(open('$CONTRACTS/act3.yaml'))['stack'])" 2>/dev/null || echo "")"
|
||||
echo " l3b generated stack: $ACT3_STACK"
|
||||
if [ "$ACT3_STACK" != "l2-commodity-price-feed" ]; then
|
||||
echo "run_demo.sh: WARNING Act 3 expected stack l2-commodity-price-feed, got '$ACT3_STACK'" >&2
|
||||
# Continue anyway per the task spec.
|
||||
fi
|
||||
|
||||
ACT3_POLICY="$(python3 "$SCRIPT_DIR/policy_checker.py" "$CONTRACTS/act3.yaml")"
|
||||
ACT3_POLICY_RC=$?
|
||||
echo " policy_checker: $ACT3_POLICY (rc=$ACT3_POLICY_RC)"
|
||||
if [ "$ACT3_POLICY" != "POLICY_PASS" ]; then
|
||||
echo "run_demo.sh: Act 3 expected POLICY_PASS, got '$ACT3_POLICY'" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
ACT3_CONF="$(python3 "$SCRIPT_DIR/confidence_signal.py" "$CONTRACTS/act3.yaml")"
|
||||
echo " confidence_signal: $ACT3_CONF"
|
||||
|
||||
(
|
||||
cd "$REPO_ROOT" && bash "$SCRIPT_DIR/mock_executor.sh" "$CONTRACTS/act3.yaml"
|
||||
)
|
||||
MOCK_RC=$?
|
||||
rm -f "$REPO_ROOT/state.json"
|
||||
if [ "$MOCK_RC" -ne 0 ]; then
|
||||
echo "run_demo.sh: Act 3 mock_executor failed (rc=$MOCK_RC)" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
run_passing_pipeline "dev applied: l2-commodity-price-feed (Act 3 from issue)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Act 4 — Safety Net
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "== Act 4 — Safety Net =="
|
||||
cat > "$CONTRACTS/act4.yaml" <<'YAML'
|
||||
stack: l2-regulatory-reporting
|
||||
inputs:
|
||||
environment: dev
|
||||
owner: platform-team
|
||||
public-ingress: true
|
||||
YAML
|
||||
|
||||
# policy_checker exits 1 on violation; capture without failing the script.
|
||||
ACT4_POLICY="$(python3 "$SCRIPT_DIR/policy_checker.py" "$CONTRACTS/act4.yaml" 2>&1 || true)"
|
||||
echo " policy_checker: $ACT4_POLICY"
|
||||
if [ "$ACT4_POLICY" != "POLICY_VIOLATION:PUBLIC_INGRESS" ]; then
|
||||
echo "run_demo.sh: Act 4 expected POLICY_VIOLATION:PUBLIC_INGRESS, got '$ACT4_POLICY'" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
ACT4_CONF="$(python3 "$SCRIPT_DIR/confidence_signal.py" "$CONTRACTS/act4.yaml")"
|
||||
echo " confidence_signal: $ACT4_CONF"
|
||||
# Expected: {"score": 0.40, "reason": "POLICY_VIOLATION:PUBLIC_INGRESS"}
|
||||
|
||||
# Score < 0.50 -> dev rejects. Do NOT run mock_executor, do NOT write qa/prod/finalize.
|
||||
ev dev "dev rejected: POLICY_VIOLATION:PUBLIC_INGRESS (confidence 0.40 < 0.50)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Summary
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "== Summary =="
|
||||
python3 - "$AUDIT" <<'PY'
|
||||
import json, sys
|
||||
audit = json.load(open(sys.argv[1]))
|
||||
for e in audit:
|
||||
print(f"{e['seq']} | {e['stage']} | {e['event']} | {e['hash'][:12]}")
|
||||
print(f"total events: {len(audit)}")
|
||||
PY
|
||||
|
||||
EVENT_COUNT="$(python3 -c "import json; print(len(json.load(open('$AUDIT'))))")"
|
||||
echo "event count: $EVENT_COUNT"
|
||||
|
||||
if [ "$EVENT_COUNT" -lt 11 ]; then
|
||||
echo "run_demo.sh: expected >= 11 events, got $EVENT_COUNT" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Upload (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
if [ "$UPLOAD" -eq 1 ]; then
|
||||
echo "== Upload =="
|
||||
if [ -z "${ACDL_GITEA_TOKEN:-}" ]; then
|
||||
echo "run_demo.sh: ACDL_GITEA_TOKEN not set; skipping upload (use --no-upload to silence)" >&2
|
||||
else
|
||||
# Upload audit.json to acdl-evidence main.
|
||||
if python3 "$SCRIPT_DIR/finalize_evidence.py" --audit "$AUDIT"; then
|
||||
echo " audit.json uploaded"
|
||||
else
|
||||
echo "run_demo.sh: finalize_evidence failed for audit.json" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
# Upload index.html (the --audit flag accepts any local file path; --path
|
||||
# sets the remote destination).
|
||||
if python3 "$SCRIPT_DIR/finalize_evidence.py" \
|
||||
--audit "$REPO_ROOT/evidence-ui/index.html" \
|
||||
--path index.html \
|
||||
--message "chore(ui): update index.html (demo dry run)"; then
|
||||
echo " index.html uploaded"
|
||||
else
|
||||
echo "run_demo.sh: finalize_evidence failed for index.html" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
echo "Uploaded audit.json + index.html to acdl-evidence main"
|
||||
echo " raw URL: https://git.cloudinit.dev/continuous-intelligence/acdl-evidence/raw/branch/main/index.html"
|
||||
fi
|
||||
else
|
||||
echo "== Upload skipped (--no-upload) =="
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Exit
|
||||
# -----------------------------------------------------------------------------
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
echo "run_demo.sh: one or more steps failed (see warnings above)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "run_demo.sh: OK ($EVENT_COUNT events)"
|
||||
exit 0
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 04 verification script.
|
||||
# Confirms the pipeline workflow + issue trigger + finalize_evidence.py
|
||||
# conform to the Phase 04 plan and the Gitea Actions topology in
|
||||
# ARCHITECTURE.md. Does NOT execute a real Gitea Actions run (act_runner
|
||||
# is not registered in this environment); validates structure + syntax
|
||||
# + a dry-run of finalize_evidence.py against a dead host.
|
||||
#
|
||||
# Usage: scripts/verify_phase04.sh
|
||||
# Exit codes: 0 = all checks passed; 1 = one or more checks failed.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
fail_count=0
|
||||
pass() { printf ' [PASS] %s\n' "$1"; }
|
||||
fail() { printf ' [FAIL] %s\n' "$1"; fail_count=$((fail_count + 1)); }
|
||||
|
||||
echo "== Phase 04 verification =="
|
||||
echo "Root: ${ROOT}"
|
||||
echo
|
||||
|
||||
# --- Check 1: typecheck ---
|
||||
echo "-- Check 1: typecheck --"
|
||||
if bash -n scripts/finalize_evidence.py 2>/dev/null || python3 -m py_compile scripts/finalize_evidence.py 2>/dev/null; then
|
||||
pass "py_compile finalize_evidence.py"
|
||||
else
|
||||
fail "py_compile finalize_evidence.py"
|
||||
fi
|
||||
if python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/pipeline.yml')); yaml.safe_load(open('contracts-repo/.gitea/workflows/issue-to-contract.yml'))" 2>/dev/null; then
|
||||
pass "yaml load both workflows"
|
||||
else
|
||||
fail "yaml load workflows"
|
||||
fi
|
||||
|
||||
# --- Check 2: pipeline.yml structure ---
|
||||
echo "-- Check 2: pipeline.yml structure (D-027, D-028) --"
|
||||
p_struct=$(python3 << 'PYEOF'
|
||||
import yaml, sys
|
||||
try:
|
||||
d = yaml.safe_load(open('.gitea/workflows/pipeline.yml'))
|
||||
on = d.get('on', d.get(True)) or {}
|
||||
assert 'workflow_dispatch' in on, 'no workflow_dispatch trigger'
|
||||
inputs = on['workflow_dispatch']['inputs']
|
||||
assert set(inputs.keys()) == {'contract-ref', 'approve_qa', 'approve_prod'}, f'inputs: {set(inputs.keys())}'
|
||||
assert inputs['contract-ref']['type'] == 'string', 'contract-ref type'
|
||||
assert inputs['approve_qa']['type'] == 'boolean', 'approve_qa type'
|
||||
assert inputs['approve_prod']['type'] == 'boolean', 'approve_prod type'
|
||||
jobs = d['jobs']
|
||||
assert set(jobs.keys()) == {'dev', 'qa-gate', 'prod-gate', 'finalize'}, f'jobs: {set(jobs.keys())}'
|
||||
dev_if = jobs['dev'].get('if', '')
|
||||
assert 'approve_qa' in dev_if and 'approve_prod' in dev_if, f'dev.if: {dev_if}'
|
||||
qa_if = jobs['qa-gate'].get('if', '')
|
||||
assert 'approve_qa' in qa_if, f'qa-gate.if: {qa_if}'
|
||||
prod_if = jobs['prod-gate'].get('if', '')
|
||||
assert 'approve_prod' in prod_if, f'prod-gate.if: {prod_if}'
|
||||
fin_needs = jobs['finalize'].get('needs', [])
|
||||
assert fin_needs == ['prod-gate'] or fin_needs == 'prod-gate', f'finalize.needs: {fin_needs}'
|
||||
# All jobs runs-on ubuntu-latest
|
||||
for name, job in jobs.items():
|
||||
assert job.get('runs-on') == 'ubuntu-latest', f'{name} runs-on: {job.get("runs-on")}'
|
||||
print('OK')
|
||||
except AssertionError as ex:
|
||||
print(f'FAIL: {ex}')
|
||||
sys.exit(1)
|
||||
except Exception as ex:
|
||||
print(f'FAIL: {ex}')
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
)
|
||||
if [ "$p_struct" = "OK" ]; then
|
||||
pass "pipeline.yml: 3 inputs + 4 jobs + correct if: conditions + finalize.needs=prod-gate"
|
||||
else
|
||||
fail "pipeline.yml structure: $p_struct"
|
||||
fi
|
||||
|
||||
# --- Check 3: pipeline.yml references core scripts ---
|
||||
echo "-- Check 3: pipeline.yml references core scripts (D-029) --"
|
||||
text=$(cat .gitea/workflows/pipeline.yml)
|
||||
missing=""
|
||||
for ref in policy_checker.py confidence_signal.py mock_executor.sh evidence_writer.py finalize_evidence.py; do
|
||||
if ! echo "$text" | grep -qF "$ref"; then
|
||||
missing="$missing $ref"
|
||||
fi
|
||||
done
|
||||
if [ -z "$missing" ]; then
|
||||
pass "pipeline.yml references all 5 core scripts"
|
||||
else
|
||||
fail "pipeline.yml missing references:$missing"
|
||||
fi
|
||||
# Branch-pin documentation
|
||||
if echo "$text" | grep -q 'milestone/v1.0-initial'; then
|
||||
pass "pipeline.yml documents branch-pin to milestone/v1.0-initial"
|
||||
else
|
||||
fail "pipeline.yml missing branch-pin reference"
|
||||
fi
|
||||
|
||||
# --- Check 4: issue-to-contract.yml structure ---
|
||||
echo "-- Check 4: issue-to-contract.yml structure (D-030) --"
|
||||
i_struct=$(python3 << 'PYEOF'
|
||||
import yaml, sys
|
||||
try:
|
||||
d = yaml.safe_load(open('contracts-repo/.gitea/workflows/issue-to-contract.yml'))
|
||||
on = d.get('on', d.get(True)) or {}
|
||||
assert 'issues' in on, 'no issues trigger'
|
||||
assert on['issues']['types'] == ['opened'], f'types: {on["issues"]["types"]}'
|
||||
assert 'parse-and-trigger' in d['jobs'], 'no parse-and-trigger job'
|
||||
assert d['jobs']['parse-and-trigger'].get('runs-on') == 'ubuntu-latest', 'runs-on'
|
||||
print('OK')
|
||||
except AssertionError as ex:
|
||||
print(f'FAIL: {ex}')
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
)
|
||||
if [ "$i_struct" = "OK" ]; then
|
||||
pass "issue-to-contract.yml: issues[opened] + parse-and-trigger job"
|
||||
else
|
||||
fail "issue-to-contract.yml structure: $i_struct"
|
||||
fi
|
||||
|
||||
# --- Check 5: issue-to-contract.yml references + dispatch endpoint ---
|
||||
echo "-- Check 5: issue-to-contract.yml references + dispatch (D-014, D-030) --"
|
||||
text=$(cat contracts-repo/.gitea/workflows/issue-to-contract.yml)
|
||||
missing=""
|
||||
for ref in l3b_agent_stub.py 'actions/workflows/pipeline.yml/dispatches' 'contract-ref' 'gitea.event.issue.number' 'GITEA_TOKEN' 'new_branch'; do
|
||||
if ! echo "$text" | grep -qF "$ref"; then
|
||||
missing="$missing $ref"
|
||||
fi
|
||||
done
|
||||
if [ -z "$missing" ]; then
|
||||
pass "issue-to-contract.yml: l3b_agent_stub + dispatch + contract-ref + issue number + token + new_branch"
|
||||
else
|
||||
fail "issue-to-contract.yml missing references:$missing"
|
||||
fi
|
||||
|
||||
# --- Check 6: finalize_evidence.py --help + clean failure ---
|
||||
echo "-- Check 6: finalize_evidence.py CLI + clean failure modes ---"
|
||||
out=$(python3 scripts/finalize_evidence.py --help 2>&1); rc=$?
|
||||
if [ "$rc" = "0" ] && echo "$out" | grep -qi 'usage\|--audit\|--owner'; then
|
||||
pass "finalize_evidence.py --help exits 0 with usage"
|
||||
else
|
||||
fail "finalize_evidence.py --help: rc=$rc"
|
||||
fi
|
||||
|
||||
# Missing audit file (with a fake token so it gets past the env check) → exit 1, no stack trace
|
||||
out=$(ACDL_GITEA_TOKEN=fake python3 scripts/finalize_evidence.py --audit /tmp/definitely_nonexistent_audit.json 2>&1); rc=$?
|
||||
if [ "$rc" = "1" ] && ! echo "$out" | grep -q 'Traceback'; then
|
||||
pass "finalize_evidence.py missing file → exit 1, no stack trace"
|
||||
else
|
||||
fail "finalize_evidence.py missing file: rc=$rc, out='$out'"
|
||||
fi
|
||||
|
||||
# Missing token env (audit file present) → exit 1, no stack trace
|
||||
printf '[]\n' > /tmp/empty_audit.json
|
||||
out=$(env -u ACDL_GITEA_TOKEN python3 scripts/finalize_evidence.py --audit /tmp/empty_audit.json 2>&1); rc=$?
|
||||
if [ "$rc" = "1" ] && ! echo "$out" | grep -q 'Traceback'; then
|
||||
pass "finalize_evidence.py missing token env → exit 1, no stack trace"
|
||||
else
|
||||
fail "finalize_evidence.py missing token: rc=$rc, out='$out'"
|
||||
fi
|
||||
|
||||
# --- Check 7: finalize_evidence.py dry-run against a dead host (clean failure) ---
|
||||
echo "-- Check 7: finalize_evidence.py dry-run against dead host ---"
|
||||
# Use a real audit.json but point at a host that will refuse the connection.
|
||||
printf '[{"seq":0,"ts":"2026-07-21T00:00:00Z","stage":"genesis","event":"init","prev_hash":"GENESIS","hash":"x"}]\n' > /tmp/real_audit.json
|
||||
out=$(ACDL_GITEA_TOKEN=fake GITEA_HOST=http://127.0.0.1:0 python3 scripts/finalize_evidence.py --audit /tmp/real_audit.json --host http://127.0.0.1:0 2>&1); rc=$?
|
||||
if [ "$rc" = "1" ] && ! echo "$out" | grep -q 'Traceback'; then
|
||||
pass "finalize_evidence.py dead host → exit 1, no stack trace (clean API failure)"
|
||||
else
|
||||
fail "finalize_evidence.py dead host: rc=$rc, out='$out'"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/empty_audit.json /tmp/real_audit.json
|
||||
|
||||
echo
|
||||
echo "== Summary =="
|
||||
if [ "$fail_count" -eq 0 ]; then
|
||||
echo "Phase 04 verification PASSED (pipeline + issue trigger + finalize helper, all checks ok)"
|
||||
exit 0
|
||||
else
|
||||
echo "Phase 04 verification FAILED (${fail_count} check(s) failed)"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+213
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 05 verification script.
|
||||
# Validates the evidence UI + the 4-act demo dry-run.
|
||||
#
|
||||
# Usage: scripts/verify_phase05.sh
|
||||
# Exit codes: 0 = all checks passed; 1 = one or more checks failed.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
fail_count=0
|
||||
pass() { printf ' [PASS] %s\n' "$1"; }
|
||||
fail() { printf ' [FAIL] %s\n' "$1"; fail_count=$((fail_count + 1)); }
|
||||
|
||||
GITEA_HOST="${GITEA_HOST:-https://git.cloudinit.dev}"
|
||||
ORG="continuous-intelligence"
|
||||
EVIDENCE_REPO="acdl-evidence"
|
||||
|
||||
echo "== Phase 05 verification =="
|
||||
echo "Root: ${ROOT}"
|
||||
echo
|
||||
|
||||
# --- Check 1: evidence-ui/index.html structure ---
|
||||
echo "-- Check 1: evidence-ui/index.html structure (D-032, REQ-14) --"
|
||||
UI="evidence-ui/index.html"
|
||||
if [ ! -f "$UI" ]; then
|
||||
fail "$UI missing"
|
||||
else
|
||||
pass "$UI exists"
|
||||
size=$(wc -c < "$UI")
|
||||
if [ "$size" -ge 1000 ] && [ "$size" -le 30000 ]; then
|
||||
pass "$UI size ${size} bytes (within 1-30 KB range)"
|
||||
else
|
||||
fail "$UI size ${size} bytes (expected 1-30 KB)"
|
||||
fi
|
||||
ui_check=$(python3 << 'PYEOF'
|
||||
import re, sys
|
||||
content = open('evidence-ui/index.html').read()
|
||||
problems = []
|
||||
if '<style>' not in content or '</style>' not in content: problems.append('missing inline <style>')
|
||||
if '<script>' not in content or '</script>' not in content: problems.append('missing inline <script>')
|
||||
if 'fetch(' not in content: problems.append('missing fetch call')
|
||||
if "'./audit.json'" not in content and '"./audit.json"' not in content: problems.append('missing relative ./audit.json fetch')
|
||||
external = re.findall(r'(?:src|href)\s*=\s*["\']https?://', content)
|
||||
if external: problems.append(f'external resource refs: {external}')
|
||||
# Confirm a refresh button or refresh function exists
|
||||
if 'refresh' not in content.lower(): problems.append('no refresh button/function')
|
||||
print('OK' if not problems else 'FAIL: ' + '; '.join(problems))
|
||||
PYEOF
|
||||
)
|
||||
if [ "$ui_check" = "OK" ]; then
|
||||
pass "$UI structural checks (inline CSS/JS, fetch ./audit.json, no external refs, refresh)"
|
||||
else
|
||||
fail "$UI structural: $ui_check"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Check 2: run_demo.sh syntax ---
|
||||
echo "-- Check 2: run_demo.sh syntax + flags --"
|
||||
if bash -n scripts/run_demo.sh 2>/dev/null; then
|
||||
pass "run_demo.sh bash -n clean"
|
||||
else
|
||||
fail "run_demo.sh bash -n"
|
||||
fi
|
||||
if grep -q -- '--no-upload' scripts/run_demo.sh; then
|
||||
pass "run_demo.sh supports --no-upload flag"
|
||||
else
|
||||
fail "run_demo.sh missing --no-upload flag"
|
||||
fi
|
||||
|
||||
# --- Check 3: run_demo.sh dry-run (no upload) ---
|
||||
echo "-- Check 3: run_demo.sh --no-upload (4 acts, 11 events) --"
|
||||
rm -rf /tmp/acdl_demo_run
|
||||
out=$(ACDL_GITEA_TOKEN= bash scripts/run_demo.sh --no-upload 2>&1); rc=$?
|
||||
if [ "$rc" = "0" ]; then
|
||||
pass "run_demo.sh --no-upload exits 0"
|
||||
else
|
||||
fail "run_demo.sh --no-upload exit $rc"
|
||||
echo "$out" | tail -10
|
||||
fi
|
||||
audit="/tmp/acdl_demo_run/audit.json"
|
||||
if [ -f "$audit" ]; then
|
||||
pass "audit.json written to $audit"
|
||||
else
|
||||
fail "audit.json missing at $audit"
|
||||
fi
|
||||
|
||||
# --- Check 4: audit.json event count + Act 4 rejection ---
|
||||
echo "-- Check 4: audit.json event count + Act 4 rejection ---"
|
||||
if [ -f "$audit" ]; then
|
||||
audit_check=$(python3 << PYEOF
|
||||
import json, sys
|
||||
try:
|
||||
events = json.load(open("$audit"))
|
||||
n = len(events)
|
||||
if n < 11:
|
||||
print(f"FAIL: too few events ({n}, expected >= 11)")
|
||||
sys.exit(1)
|
||||
if not any('POLICY_VIOLATION:PUBLIC_INGRESS' in x.get('event', '') for x in events):
|
||||
print("FAIL: no Act 4 rejection event")
|
||||
sys.exit(1)
|
||||
if not any('Act 1 Friction' in x.get('event', '') for x in events):
|
||||
print("FAIL: no Act 1 event")
|
||||
sys.exit(1)
|
||||
if not any('Act 3' in x.get('event', '') for x in events):
|
||||
print("FAIL: no Act 3 event")
|
||||
sys.exit(1)
|
||||
if not any('l2-commodity-price-feed' in x.get('event', '') for x in events):
|
||||
print("FAIL: no l2-commodity-price-feed event")
|
||||
sys.exit(1)
|
||||
print(f"OK ({n} events; Act 1/2/3/4 + Act 4 rejection present)")
|
||||
except Exception as ex:
|
||||
print(f"FAIL: {ex}")
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
)
|
||||
if echo "$audit_check" | grep -q "^OK"; then
|
||||
pass "$audit_check"
|
||||
else
|
||||
fail "audit content: $audit_check"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Check 5: audit.json hash chain integrity ---
|
||||
echo "-- Check 5: audit.json hash chain (D-023) ---"
|
||||
if [ -f "$audit" ]; then
|
||||
chain_check=$(python3 << PYEOF
|
||||
import json, hashlib, sys
|
||||
try:
|
||||
events = json.load(open("$audit"))
|
||||
assert events[0]['prev_hash'] == 'GENESIS', "genesis prev_hash"
|
||||
for i in range(1, len(events)):
|
||||
assert events[i]['prev_hash'] == events[i-1]['hash'], f"chain break at {i}"
|
||||
e = dict(events[i]); h = e.pop('hash'); e['hash'] = ''
|
||||
canon = json.dumps(e, sort_keys=True, separators=(',',':'))
|
||||
assert hashlib.sha256(canon.encode()).hexdigest() == h, f"hash mismatch at {i}"
|
||||
print("OK")
|
||||
except AssertionError as ex:
|
||||
print(f"FAIL: {ex}")
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
)
|
||||
if [ "$chain_check" = "OK" ]; then
|
||||
pass "audit.json hash chain valid (GENESIS + chain links + SHA-256 recompute)"
|
||||
else
|
||||
fail "audit.json hash chain: $chain_check"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Check 6: no stray files in repo root ---
|
||||
echo "-- Check 6: no stray files in repo root ---"
|
||||
if [ -f "$ROOT/state.json" ]; then
|
||||
fail "state.json left in repo root"
|
||||
else
|
||||
pass "no state.json in repo root"
|
||||
fi
|
||||
if [ -d "$ROOT/contracts" ]; then
|
||||
fail "contracts/ directory left in repo root"
|
||||
else
|
||||
pass "no contracts/ directory in repo root"
|
||||
fi
|
||||
|
||||
# --- Check 7: real upload + raw URL fetch (if token available) ---
|
||||
echo "-- Check 7: real upload + raw URL fetch (REQ-13) ---"
|
||||
TOKEN="${ACDL_GITEA_TOKEN:-}"
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo " [SKIP] No ACDL_GITEA_TOKEN set; skipping real upload + raw URL fetch (Phase 05 dry-run is sufficient)"
|
||||
else
|
||||
echo " Running run_demo.sh (with upload)..."
|
||||
upload_out=$(bash scripts/run_demo.sh 2>&1); upload_rc=$?
|
||||
if [ "$upload_rc" = "0" ]; then
|
||||
pass "run_demo.sh (with upload) exits 0"
|
||||
else
|
||||
fail "run_demo.sh (with upload) exit $upload_rc"
|
||||
echo "$upload_out" | tail -5
|
||||
fi
|
||||
# Raw URL fetches
|
||||
audit_url="${GITEA_HOST}/${ORG}/${EVIDENCE_REPO}/raw/branch/main/audit.json"
|
||||
index_url="${GITEA_HOST}/${ORG}/${EVIDENCE_REPO}/raw/branch/main/index.html"
|
||||
audit_status=$(curl -sS -o /tmp/p05_audit_remote.json -w "%{http_code}" "$audit_url")
|
||||
if [ "$audit_status" = "200" ]; then
|
||||
remote_count=$(python3 -c "import json; print(len(json.load(open('/tmp/p05_audit_remote.json'))))" 2>/dev/null || echo "?")
|
||||
if [ "$remote_count" = "11" ] || [ "$remote_count" -ge 11 ] 2>/dev/null; then
|
||||
pass "raw audit.json returns 200 with ${remote_count} events"
|
||||
else
|
||||
pass "raw audit.json returns 200 (events: ${remote_count})"
|
||||
fi
|
||||
else
|
||||
fail "raw audit.json GET returned HTTP ${audit_status}"
|
||||
fi
|
||||
index_status=$(curl -sS -o /tmp/p05_index_remote.html -w "%{http_code}" "$index_url")
|
||||
if [ "$index_status" = "200" ]; then
|
||||
if grep -q "ACDL Evidence" /tmp/p05_index_remote.html && grep -q "audit.json" /tmp/p05_index_remote.html; then
|
||||
pass "raw index.html returns 200 with ACDL Evidence + audit.json reference"
|
||||
else
|
||||
fail "raw index.html returns 200 but missing ACDL Evidence / audit.json markers"
|
||||
fi
|
||||
else
|
||||
fail "raw index.html GET returned HTTP ${index_status}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== Summary =="
|
||||
if [ "$fail_count" -eq 0 ]; then
|
||||
echo "Phase 05 verification PASSED (UI + 4-act dry-run, all checks ok)"
|
||||
exit 0
|
||||
else
|
||||
echo "Phase 05 verification FAILED (${fail_count} check(s) failed)"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user