From 181330b78fee663e8271bd0010d67e12e1fb3015 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 23:19:45 +0200 Subject: [PATCH 01/10] feat(deploy): add signed AppOps-approved workflow --- .../forgeflow-approved-deploy.yml | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 examples/gitea-actions/forgeflow-approved-deploy.yml diff --git a/examples/gitea-actions/forgeflow-approved-deploy.yml b/examples/gitea-actions/forgeflow-approved-deploy.yml new file mode 100644 index 0000000..b35ca7b --- /dev/null +++ b/examples/gitea-actions/forgeflow-approved-deploy.yml @@ -0,0 +1,86 @@ +name: ForgeFlow approved deploy + +on: + workflow_dispatch: + inputs: + environment: + description: Allowlisted ForgeFlow environment + required: true + type: string + commit_sha: + description: Exact approved commit SHA + required: true + type: string + request_id: + description: Immutable AppOps request identifier + required: true + type: string + approval_id: + description: AppOps approval identifier + required: true + type: string + approval_fingerprint: + description: Immutable AppOps approval fingerprint + required: true + type: string + evidence_issued_at: + description: Signed evidence UNIX timestamp + required: true + type: string + evidence_signature: + description: Base64 Ed25519 signature over the exact deployment evidence + required: true + type: string + +concurrency: + group: forgeflow-approved-${{ gitea.repository }}-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: forgeflow + steps: + - name: Validate signed deployment inputs + shell: bash + env: + FF_REPOSITORY: ${{ gitea.repository }} + FF_ENVIRONMENT: ${{ inputs.environment }} + FF_COMMIT_SHA: ${{ inputs.commit_sha }} + FF_REQUEST_ID: ${{ inputs.request_id }} + FF_APPROVAL_ID: ${{ inputs.approval_id }} + FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }} + FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }} + FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }} + run: | + set -Eeuo pipefail + [[ "$FF_REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] + [[ "$FF_ENVIRONMENT" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] + [[ "$FF_COMMIT_SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] + [[ "$FF_REQUEST_ID" =~ ^appr-[A-Za-z0-9._-]{1,75}$ ]] + [[ "$FF_APPROVAL_ID" == "$FF_REQUEST_ID" ]] + [[ "$FF_APPROVAL_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]] + [[ "$FF_EVIDENCE_ISSUED_AT" =~ ^[0-9]{10,11}$ ]] + [[ "$FF_EVIDENCE_SIGNATURE" =~ ^[A-Za-z0-9+/]{86}==$ ]] + + - name: Execute root-owned verified deployment + shell: bash + env: + FF_REPOSITORY: ${{ gitea.repository }} + FF_ENVIRONMENT: ${{ inputs.environment }} + FF_COMMIT_SHA: ${{ inputs.commit_sha }} + FF_REQUEST_ID: ${{ inputs.request_id }} + FF_APPROVAL_ID: ${{ inputs.approval_id }} + FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }} + FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }} + FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }} + run: | + set -Eeuo pipefail + sudo /usr/local/bin/forgeflow-deploy \ + "$FF_REPOSITORY" \ + "$FF_ENVIRONMENT" \ + "$FF_COMMIT_SHA" \ + "$FF_REQUEST_ID" \ + "$FF_APPROVAL_ID" \ + "$FF_APPROVAL_FINGERPRINT" \ + "$FF_EVIDENCE_ISSUED_AT" \ + "$FF_EVIDENCE_SIGNATURE" From d1f4cb6ba8dd163016dc7ec3cc223b865c3c8839 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 23:20:26 +0200 Subject: [PATCH 02/10] feat(deploy): verify AppOps Ed25519 evidence before machine deploy --- examples/server/forgeflow-deploy | 67 +++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/examples/server/forgeflow-deploy b/examples/server/forgeflow-deploy index 707fa5d..7da8af7 100644 --- a/examples/server/forgeflow-deploy +++ b/examples/server/forgeflow-deploy @@ -4,18 +4,27 @@ umask 027 # Install as /usr/local/bin/forgeflow-deploy, owned by root and not writable by # the Gitea runner. Targets are read from the root-owned data file below. +# Approved machine deployments additionally verify an AppOps Ed25519 signature +# using the root-controlled public key; the Actions runner never receives that +# trust anchor's private key. readonly CONFIG_FILE="/etc/forgeflow/targets.conf" +readonly EVIDENCE_PUBLIC_KEY_FILE="/etc/forgeflow/evidence.pub" readonly REPOSITORY="${1:-}" readonly ENVIRONMENT="${2:-}" readonly SHA="${3:-}" readonly REQUEST_ID="${4:-manual-$(date +%s)}" +readonly APPROVAL_ID="${5:-}" +readonly APPROVAL_FINGERPRINT="${6:-}" +readonly EVIDENCE_ISSUED_AT="${7:-}" +readonly EVIDENCE_SIGNATURE="${8:-}" fail_usage() { - echo "Usage: forgeflow-deploy [request-id]" >&2 + echo "Usage: forgeflow-deploy [request-id] [approval-id approval-fingerprint evidence-issued-at evidence-signature]" >&2 exit 64 } +(( $# == 3 || $# == 4 || $# == 8 )) || fail_usage [[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage [[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage [[ "$SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || fail_usage @@ -29,6 +38,52 @@ config_mode="$(stat -c '%a' "$CONFIG_FILE")" # Reject group/other write bits. GNU stat returns an octal string such as 640. (( (8#$config_mode & 8#022) == 0 )) || { echo "Target configuration may not be group/other writable" >&2; exit 78; } +EVIDENCE_VERIFIED=false +if (( $# == 8 )); then + [[ "$REQUEST_ID" =~ ^appr-[A-Za-z0-9._-]{1,75}$ ]] || { echo "Approved deployment request ID is invalid" >&2; exit 64; } + [[ "$APPROVAL_ID" == "$REQUEST_ID" ]] || { echo "Approval ID must equal the immutable request ID" >&2; exit 65; } + [[ "$APPROVAL_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]] || { echo "Approval fingerprint is invalid" >&2; exit 64; } + [[ "$EVIDENCE_ISSUED_AT" =~ ^[0-9]{10,11}$ ]] || { echo "Evidence timestamp is invalid" >&2; exit 64; } + [[ "$EVIDENCE_SIGNATURE" =~ ^[A-Za-z0-9+/]{86}==$ ]] || { echo "Evidence signature encoding is invalid" >&2; exit 64; } + [[ -f "$EVIDENCE_PUBLIC_KEY_FILE" ]] || { echo "Missing AppOps evidence public key: $EVIDENCE_PUBLIC_KEY_FILE" >&2; exit 78; } + + evidence_owner="$(stat -c '%U' "$EVIDENCE_PUBLIC_KEY_FILE")" + evidence_mode="$(stat -c '%a' "$EVIDENCE_PUBLIC_KEY_FILE")" + [[ "$evidence_owner" == "root" ]] || { echo "Evidence public key must be owned by root" >&2; exit 78; } + (( (8#$evidence_mode & 8#022) == 0 )) || { echo "Evidence public key may not be group/other writable" >&2; exit 78; } + command -v openssl >/dev/null 2>&1 || { echo "OpenSSL is required for approved deployment evidence verification" >&2; exit 69; } + + now_epoch="$(date +%s)" + (( EVIDENCE_ISSUED_AT <= now_epoch + 60 )) || { echo "Deployment evidence is issued too far in the future" >&2; exit 65; } + (( EVIDENCE_ISSUED_AT >= now_epoch - 1800 )) || { echo "Deployment evidence expired before execution" >&2; exit 65; } + + evidence_tmp="$(mktemp -d /run/forgeflow-evidence.XXXXXX)" + cleanup_evidence() { rm -rf "$evidence_tmp"; } + trap cleanup_evidence EXIT + printf 'forgeflow-evidence-v1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \ + "$APPROVAL_ID" \ + "$APPROVAL_FINGERPRINT" \ + "$REPOSITORY" \ + "$ENVIRONMENT" \ + "${SHA,,}" \ + "$REQUEST_ID" \ + "$EVIDENCE_ISSUED_AT" > "$evidence_tmp/message" + printf '%s' "$EVIDENCE_SIGNATURE" | base64 --decode > "$evidence_tmp/signature" 2>/dev/null || { + echo "Deployment evidence signature could not be decoded" >&2 + exit 65 + } + openssl pkeyutl -verify \ + -pubin \ + -inkey "$EVIDENCE_PUBLIC_KEY_FILE" \ + -rawin \ + -in "$evidence_tmp/message" \ + -sigfile "$evidence_tmp/signature" >/dev/null 2>&1 || { + echo "Deployment evidence signature verification failed" >&2 + exit 65 + } + EVIDENCE_VERIFIED=true +fi + APP_DIR="" BRANCH="" COMPOSE_FILE="" @@ -75,6 +130,9 @@ write_status() { temporary="${STATUS_FILE}.${$}.tmp" json_string "$health" >/dev/null json_string "$REQUEST_ID" >/dev/null + json_string "$APPROVAL_ID" >/dev/null + json_string "$APPROVAL_FINGERPRINT" >/dev/null + json_string "$EVIDENCE_ISSUED_AT" >/dev/null [[ "$live_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid live SHA for status output" >&2; return 1; } [[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid previous SHA for status output" >&2; return 1; } cat > "$temporary" < Date: Wed, 26 Aug 2026 23:21:44 +0200 Subject: [PATCH 03/10] test(deploy): lock signed AppOps evidence contract --- tests/approved-deployment-evidence.test.mjs | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/approved-deployment-evidence.test.mjs diff --git a/tests/approved-deployment-evidence.test.mjs b/tests/approved-deployment-evidence.test.mjs new file mode 100644 index 0000000..0b1650c --- /dev/null +++ b/tests/approved-deployment-evidence.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const workflowUrl = new URL('../examples/gitea-actions/forgeflow-approved-deploy.yml', import.meta.url); +const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url); + + +test('approved workflow transports immutable evidence only to the root-owned deploy wrapper', async () => { + const workflow = await readFile(workflowUrl, 'utf8'); + + for (const input of [ + 'environment', + 'commit_sha', + 'request_id', + 'approval_id', + 'approval_fingerprint', + 'evidence_issued_at', + 'evidence_signature', + ]) { + assert.match(workflow, new RegExp(`\\b${input}:`)); + } + assert.match(workflow, /\$\{\{ gitea\.repository \}\}/); + assert.match(workflow, /FF_APPROVAL_ID.*FF_REQUEST_ID/s); + assert.match(workflow, /sudo \/usr\/local\/bin\/forgeflow-deploy/); + assert.doesNotMatch(workflow, /actions\/checkout/); + assert.doesNotMatch(workflow, /docker compose/); + assert.doesNotMatch(workflow, /git\s+-C/); +}); + + +test('server wrapper verifies Ed25519 evidence before any live git or compose mutation', async () => { + const script = await readFile(deployUrl, 'utf8'); + const verifyIndex = script.indexOf('openssl pkeyutl -verify'); + const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"'); + const composeIndex = script.indexOf('docker compose -f "$COMPOSE_FILE" up -d --build'); + + assert.ok(verifyIndex > 0, 'expected cryptographic verification'); + assert.ok(resetIndex > verifyIndex, 'git reset must happen after evidence verification'); + assert.ok(composeIndex > verifyIndex, 'compose mutation must happen after evidence verification'); + assert.match(script, /EVIDENCE_PUBLIC_KEY_FILE="\/etc\/forgeflow\/evidence\.pub"/); + assert.match(script, /evidence_owner.*root/s); + assert.match(script, /8#022/); + assert.match(script, /EVIDENCE_ISSUED_AT >= now_epoch - 1800/); + assert.match(script, /"evidence_verified": \$EVIDENCE_VERIFIED/); + assert.match(script, /\(\( \$# == 3 \|\| \$# == 4 \|\| \$# == 8 \)\)/); +}); + + +test('signed message fields match the AppOps evidence v1 contract and exclude runner-chosen workflow/ref', async () => { + const script = await readFile(deployUrl, 'utf8'); + const marker = "printf 'forgeflow-evidence-v1\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n'"; + assert.ok(script.includes(marker)); + assert.match( + script, + /"\$APPROVAL_ID"[\s\\]+"\$APPROVAL_FINGERPRINT"[\s\\]+"\$REPOSITORY"[\s\\]+"\$ENVIRONMENT"[\s\\]+"\$\{SHA,,\}"[\s\\]+"\$REQUEST_ID"[\s\\]+"\$EVIDENCE_ISSUED_AT"/s, + ); + assert.doesNotMatch(script.slice(0, script.indexOf('APP_DIR=""')), /WORKFLOW|workflow|ref=/); +}); From 858b09afeb1b9fb9df5e0b615993aeced03cd8af Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 23:23:40 +0200 Subject: [PATCH 04/10] docs(deploy): include signed approval evidence in status example --- examples/server/status-example.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/server/status-example.json b/examples/server/status-example.json index 5fad942..4723ace 100644 --- a/examples/server/status-example.json +++ b/examples/server/status-example.json @@ -1,11 +1,15 @@ { "repository": "jens/example-app", "environment": "production", - "request_id": "3a6ed71c-d52d-4d8d-9678-96e0c9456a81", + "request_id": "appr-3a6ed71cd52d", "commit_sha": "0123456789abcdef0123456789abcdef01234567", "previous_sha": "89abcdef0123456789abcdef0123456789abcdef", "requested_sha": "0123456789abcdef0123456789abcdef01234567", - "deployed_at": "2026-07-24T13:00:00Z", + "approval_id": "appr-3a6ed71cd52d", + "approval_fingerprint": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "evidence_verified": true, + "evidence_issued_at": "1787778000", + "deployed_at": "2026-08-26T21:00:00Z", "health": "healthy", "last_exit_code": 0 } From 1dc3bea8dd148036555abf9b7003975c1b4ae4bc Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 23:25:51 +0200 Subject: [PATCH 05/10] ci: add managed exact-head validation gate --- .gitea/workflows/chatgpt-validation.yml | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .gitea/workflows/chatgpt-validation.yml diff --git a/.gitea/workflows/chatgpt-validation.yml b/.gitea/workflows/chatgpt-validation.yml new file mode 100644 index 0000000..4acca6c --- /dev/null +++ b/.gitea/workflows/chatgpt-validation.yml @@ -0,0 +1,26 @@ +name: ChatGPT validation + +on: + workflow_dispatch: + +jobs: + quality: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run quality + - run: npx playwright install --with-deps chromium + - run: npm run test:browser:ci + - name: Preserve browser failure evidence + if: failure() + uses: actions/upload-artifact@v4 + with: + name: forgeflow-browser-failure-evidence + path: artifacts/ + if-no-files-found: ignore + - run: npm audit --omit=dev --audit-level=high From 2174b79544ef0c3f72aa468cee9296b934687fc8 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 23:26:16 +0200 Subject: [PATCH 06/10] ci: run managed validation on codex change branches --- .gitea/workflows/chatgpt-validation.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/chatgpt-validation.yml b/.gitea/workflows/chatgpt-validation.yml index 4acca6c..dc45b60 100644 --- a/.gitea/workflows/chatgpt-validation.yml +++ b/.gitea/workflows/chatgpt-validation.yml @@ -1,6 +1,9 @@ name: ChatGPT validation on: + push: + branches: + - 'codex/**' workflow_dispatch: jobs: From 84ed89bccf65fa10fb0a26cdefb7073abd0dda00 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 23:46:49 +0200 Subject: [PATCH 07/10] fix(deploy): centralize approved workflow dispatch --- examples/gitea-actions/forgeflow-approved-deploy.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/gitea-actions/forgeflow-approved-deploy.yml b/examples/gitea-actions/forgeflow-approved-deploy.yml index b35ca7b..857c953 100644 --- a/examples/gitea-actions/forgeflow-approved-deploy.yml +++ b/examples/gitea-actions/forgeflow-approved-deploy.yml @@ -3,6 +3,10 @@ name: ForgeFlow approved deploy on: workflow_dispatch: inputs: + repository: + description: Signed allowlisted deployment target (owner/repository) + required: true + type: string environment: description: Allowlisted ForgeFlow environment required: true @@ -33,7 +37,7 @@ on: type: string concurrency: - group: forgeflow-approved-${{ gitea.repository }}-${{ inputs.environment }} + group: forgeflow-approved-${{ inputs.repository }}-${{ inputs.environment }} cancel-in-progress: false jobs: @@ -43,7 +47,7 @@ jobs: - name: Validate signed deployment inputs shell: bash env: - FF_REPOSITORY: ${{ gitea.repository }} + FF_REPOSITORY: ${{ inputs.repository }} FF_ENVIRONMENT: ${{ inputs.environment }} FF_COMMIT_SHA: ${{ inputs.commit_sha }} FF_REQUEST_ID: ${{ inputs.request_id }} @@ -65,7 +69,7 @@ jobs: - name: Execute root-owned verified deployment shell: bash env: - FF_REPOSITORY: ${{ gitea.repository }} + FF_REPOSITORY: ${{ inputs.repository }} FF_ENVIRONMENT: ${{ inputs.environment }} FF_COMMIT_SHA: ${{ inputs.commit_sha }} FF_REQUEST_ID: ${{ inputs.request_id }} From c5cf384f9a79fc99126aeb45067d1c50538e5b87 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 23:47:20 +0200 Subject: [PATCH 08/10] test(deploy): prove central workflow carries signed target --- tests/approved-deployment-evidence.test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/approved-deployment-evidence.test.mjs b/tests/approved-deployment-evidence.test.mjs index 0b1650c..67ebc46 100644 --- a/tests/approved-deployment-evidence.test.mjs +++ b/tests/approved-deployment-evidence.test.mjs @@ -6,10 +6,11 @@ const workflowUrl = new URL('../examples/gitea-actions/forgeflow-approved-deploy const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url); -test('approved workflow transports immutable evidence only to the root-owned deploy wrapper', async () => { +test('central approved workflow transports signed target evidence only to the root-owned deploy wrapper', async () => { const workflow = await readFile(workflowUrl, 'utf8'); for (const input of [ + 'repository', 'environment', 'commit_sha', 'request_id', @@ -20,7 +21,8 @@ test('approved workflow transports immutable evidence only to the root-owned dep ]) { assert.match(workflow, new RegExp(`\\b${input}:`)); } - assert.match(workflow, /\$\{\{ gitea\.repository \}\}/); + assert.match(workflow, /\$\{\{ inputs\.repository \}\}/); + assert.doesNotMatch(workflow, /\$\{\{ gitea\.repository \}\}/); assert.match(workflow, /FF_APPROVAL_ID.*FF_REQUEST_ID/s); assert.match(workflow, /sudo \/usr\/local\/bin\/forgeflow-deploy/); assert.doesNotMatch(workflow, /actions\/checkout/); From e3778892636457264749f310b4f64587c19e2f1f Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 23:55:49 +0200 Subject: [PATCH 09/10] fix(deploy): consume signed approval evidence exactly once --- examples/server/forgeflow-deploy | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/examples/server/forgeflow-deploy b/examples/server/forgeflow-deploy index 7da8af7..4218671 100644 --- a/examples/server/forgeflow-deploy +++ b/examples/server/forgeflow-deploy @@ -6,10 +6,12 @@ umask 027 # the Gitea runner. Targets are read from the root-owned data file below. # Approved machine deployments additionally verify an AppOps Ed25519 signature # using the root-controlled public key; the Actions runner never receives that -# trust anchor's private key. +# trust anchor's private key. Every verified approval id is consumed exactly +# once in a root-owned replay journal before target lookup or mutation. readonly CONFIG_FILE="/etc/forgeflow/targets.conf" readonly EVIDENCE_PUBLIC_KEY_FILE="/etc/forgeflow/evidence.pub" +readonly EVIDENCE_REPLAY_DIR="/var/lib/forgeflow-status/approved-requests" readonly REPOSITORY="${1:-}" readonly ENVIRONMENT="${2:-}" readonly SHA="${3:-}" @@ -81,6 +83,15 @@ if (( $# == 8 )); then echo "Deployment evidence signature verification failed" >&2 exit 65 } + + # Consume the verified approval before any target lookup. mkdir is atomic, + # making this a cross-process replay fence. A failed first deployment still + # requires a fresh human approval, matching AppOps' terminal execution model. + install -d -o root -g root -m 0700 "$EVIDENCE_REPLAY_DIR" + if ! mkdir -m 0700 "$EVIDENCE_REPLAY_DIR/$APPROVAL_ID" 2>/dev/null; then + echo "Approved deployment evidence was already consumed" >&2 + exit 65 + fi EVIDENCE_VERIFIED=true fi From ff1fcd33030b844ccab3694122a3cc8a65d86814 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 23:56:35 +0200 Subject: [PATCH 10/10] test(deploy): lock one-shot approved request semantics --- tests/approved-deployment-one-shot.test.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/approved-deployment-one-shot.test.mjs diff --git a/tests/approved-deployment-one-shot.test.mjs b/tests/approved-deployment-one-shot.test.mjs new file mode 100644 index 0000000..28bd943 --- /dev/null +++ b/tests/approved-deployment-one-shot.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url); + +test('a signed approved request is consumed once before target selection or mutation', async () => { + const script = await readFile(deployUrl, 'utf8'); + const verifyIndex = script.indexOf('openssl pkeyutl -verify'); + const consumeIndex = script.indexOf('mkdir -m 0700 "$EVIDENCE_REPLAY_DIR/$APPROVAL_ID"'); + const targetIndex = script.indexOf('APP_DIR=""'); + const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"'); + + assert.ok(verifyIndex > 0); + assert.ok(consumeIndex > verifyIndex); + assert.ok(targetIndex > consumeIndex); + assert.ok(resetIndex > consumeIndex); + assert.match(script, /EVIDENCE_REPLAY_DIR="\/var\/lib\/forgeflow-status\/approved-requests"/); + assert.match(script, /install -d -o root -g root -m 0700 "\$EVIDENCE_REPLAY_DIR"/); + assert.match(script, /Approved deployment evidence was already consumed/); +});