Merge remote-tracking branch 'origin/codex/portfolio-integration-fabric-20260826'
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
name: ChatGPT validation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'codex/**'
|
||||
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
|
||||
@@ -0,0 +1,90 @@
|
||||
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
|
||||
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-${{ inputs.repository }}-${{ inputs.environment }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: forgeflow
|
||||
steps:
|
||||
- name: Validate signed deployment inputs
|
||||
shell: bash
|
||||
env:
|
||||
FF_REPOSITORY: ${{ inputs.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: ${{ inputs.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"
|
||||
@@ -4,18 +4,29 @@ 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. 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:-}"
|
||||
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 <owner/repository> <environment> <full-sha> [request-id]" >&2
|
||||
echo "Usage: forgeflow-deploy <owner/repository> <environment> <full-sha> [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 +40,61 @@ 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
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
APP_DIR=""
|
||||
BRANCH=""
|
||||
COMPOSE_FILE=""
|
||||
@@ -75,6 +141,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" <<JSON
|
||||
@@ -85,6 +154,10 @@ write_status() {
|
||||
"commit_sha": "$live_sha",
|
||||
"previous_sha": "$previous_sha",
|
||||
"requested_sha": "$SHA",
|
||||
"approval_id": "$APPROVAL_ID",
|
||||
"approval_fingerprint": "$APPROVAL_FINGERPRINT",
|
||||
"evidence_verified": $EVIDENCE_VERIFIED,
|
||||
"evidence_issued_at": "$EVIDENCE_ISSUED_AT",
|
||||
"deployed_at": "$deployed_at",
|
||||
"health": "$health",
|
||||
"last_exit_code": $exit_code
|
||||
@@ -105,6 +178,9 @@ echo "ForgeFlow request: $REQUEST_ID"
|
||||
echo "Target: $REPOSITORY / $ENVIRONMENT"
|
||||
echo "Current SHA: $current_sha"
|
||||
echo "Requested SHA: $SHA"
|
||||
if [[ "$EVIDENCE_VERIFIED" == "true" ]]; then
|
||||
echo "Approval: $APPROVAL_ID (signed evidence verified)"
|
||||
fi
|
||||
|
||||
on_error() {
|
||||
local exit_code=$?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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('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',
|
||||
'approval_id',
|
||||
'approval_fingerprint',
|
||||
'evidence_issued_at',
|
||||
'evidence_signature',
|
||||
]) {
|
||||
assert.match(workflow, new RegExp(`\\b${input}:`));
|
||||
}
|
||||
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/);
|
||||
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=/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user