Skip to content

Commit 57d9813

Browse files
authored
Enable protected payout journey signing
Add a manual production-release workflow that captures SPEC-016-R002 payout onboarding evidence on reviewed main, verifies repository/environment posture before secret use, signs and validates the journey-result payload, and exports a short-lived artifact for a normal follow-up PR.\n\nConstraint: The real acceptance signing key is only available as a protected GitHub environment secret and must not be copied locally or used by an unguarded workflow.\n\nRejected: Let the workflow push directly or open a PR with GITHUB_TOKEN | that would bypass the requested Augustas11 PR path or risk CI not retriggering on generated commits.\n\nConfidence: high\n\nScope-risk: moderate\n\nDirective: Dispatch this workflow only from main with source_sha equal to current origin/main, then apply the exported artifact in a separate reviewed PR before ledger evidence promotion lands.\n\nTested: bash scripts/test-signed-payout-journey-workflow.sh; bash -n scripts/test-signed-payout-journey-workflow.sh; PyYAML parsed .github/workflows/promote-signed-payout-journey.yml; go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/promote-signed-payout-journey.yml; python3 scripts/check_spec_governance.py --base-ref origin/main; MACPROVIDER_CAPTURE_PAYOUT_JOURNEY=1 go test ./internal/payout -run TestPayoutAddressRegistrationJourneyEvidence -count=1 -v; python3 -m unittest scripts.tests.test_spec_pr_declaration scripts.tests.test_spec_governance scripts.tests.test_journey_result_tools; git diff --check; PR #907 CI including ci-required passed; antfleet-ops approved PR #907; focused sidecar review reported 0 C/H/M after posture fix.\n\nNot-tested: Live protected workflow dispatch and real acceptance-key signing; those are the next step after this workflow merges to main.
1 parent e8dba0e commit 57d9813

3 files changed

Lines changed: 312 additions & 0 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
name: Build signed payout journey promotion artifact
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
source_sha:
7+
description: "Exact origin/main commit SHA to capture and promote"
8+
required: true
9+
type: string
10+
promotion_confirmed:
11+
description: "Confirm SPEC-016-R002 signed evidence promotion artifact should be built"
12+
required: true
13+
default: false
14+
type: boolean
15+
16+
permissions:
17+
contents: read
18+
19+
concurrency:
20+
group: signed-payout-journey-${{ github.event.inputs.source_sha }}
21+
cancel-in-progress: false
22+
23+
jobs:
24+
promote:
25+
name: Capture, sign, validate, and export SPEC-016-R002 promotion
26+
runs-on: ubuntu-latest
27+
environment: production-release
28+
steps:
29+
- name: Checkout reviewed main controls
30+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
31+
with:
32+
ref: ${{ github.sha }}
33+
fetch-depth: 0
34+
persist-credentials: false
35+
36+
- name: Validate exact main source
37+
id: request
38+
shell: bash
39+
env:
40+
SOURCE_SHA_INPUT: ${{ github.event.inputs.source_sha }}
41+
PROMOTION_CONFIRMED_INPUT: ${{ github.event.inputs.promotion_confirmed || 'false' }}
42+
run: |
43+
set -euo pipefail
44+
[[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]] || { echo "::error::manual dispatch required" >&2; exit 1; }
45+
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo "::error::workflow must run from main" >&2; exit 1; }
46+
[[ "$SOURCE_SHA_INPUT" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::invalid source SHA" >&2; exit 1; }
47+
[[ "$SOURCE_SHA_INPUT" == "$GITHUB_SHA" ]] || { echo "::error::source SHA must equal the reviewed workflow commit" >&2; exit 1; }
48+
[[ "$PROMOTION_CONFIRMED_INPUT" == true ]] || { echo "::error::promotion confirmation is required" >&2; exit 1; }
49+
git fetch --quiet origin refs/heads/main:refs/remotes/origin/main
50+
main_sha="$(git rev-parse refs/remotes/origin/main)"
51+
[[ "$main_sha" == "$SOURCE_SHA_INPUT" ]] || { echo "::error::source SHA is no longer origin/main" >&2; exit 1; }
52+
short_sha="${SOURCE_SHA_INPUT:0:12}"
53+
printf 'source_sha=%s\n' "$SOURCE_SHA_INPUT" >> "$GITHUB_OUTPUT"
54+
printf 'short_sha=%s\n' "$short_sha" >> "$GITHUB_OUTPUT"
55+
56+
- name: Set up Go
57+
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
58+
with:
59+
go-version-file: phase4-coordinator/go.mod
60+
cache-dependency-path: phase4-coordinator/go.sum
61+
62+
- name: Capture payout journey payload
63+
shell: bash
64+
run: |
65+
set -euo pipefail
66+
(
67+
cd phase4-coordinator
68+
MACPROVIDER_CAPTURE_PAYOUT_JOURNEY=1 \
69+
go test ./internal/payout -run TestPayoutAddressRegistrationJourneyEvidence -count=1 -v
70+
)
71+
72+
- name: Bind generated artifact paths
73+
id: generated
74+
shell: bash
75+
run: |
76+
set -euo pipefail
77+
python3 - "$GITHUB_OUTPUT" <<'PY'
78+
import pathlib
79+
import sys
80+
81+
root = pathlib.Path("journeys/evidence")
82+
payloads = sorted(root.glob("spec016-r002-payout-address-*.journey-result.unsigned.json"))
83+
if len(payloads) != 1:
84+
raise SystemExit(f"expected exactly one unsigned payload, found {len(payloads)}")
85+
payload = payloads[0]
86+
prefix = payload.name.removesuffix(".journey-result.unsigned.json")
87+
redacted = root / f"{prefix}.redacted.json"
88+
candidate = root / f"{prefix}.candidate.json"
89+
envelope = root / f"{prefix}.journey-result.signed.json"
90+
for path in (redacted, candidate):
91+
if not path.is_file():
92+
raise SystemExit(f"missing generated artifact: {path}")
93+
with open(sys.argv[1], "a", encoding="ascii") as output:
94+
output.write(f"payload={payload.as_posix()}\n")
95+
output.write(f"redacted={redacted.as_posix()}\n")
96+
output.write(f"candidate={candidate.as_posix()}\n")
97+
output.write(f"envelope={envelope.as_posix()}\n")
98+
output.write(f"prefix={prefix}\n")
99+
PY
100+
101+
- name: Verify protected environment and repository posture
102+
shell: bash
103+
env:
104+
GH_TOKEN: ${{ secrets.RELEASE_POSTURE_TOKEN }}
105+
run: bash scripts/verify-github-release-posture.sh "$GITHUB_REPOSITORY" production-release 28995904
106+
107+
- name: Sign journey-result payload
108+
shell: bash
109+
env:
110+
MACPROVIDER_ACCEPTANCE_SIGNING_KEY_PEM: ${{ secrets.MACPROVIDER_ACCEPTANCE_SIGNING_KEY_PEM }}
111+
PAYLOAD: ${{ steps.generated.outputs.payload }}
112+
ENVELOPE: ${{ steps.generated.outputs.envelope }}
113+
run: |
114+
set -euo pipefail
115+
python3 scripts/sign-journey-result.py \
116+
--input "$PAYLOAD" \
117+
--output "$ENVELOPE" \
118+
--verifier ".github/workflows/promote-signed-payout-journey.yml:${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}"
119+
120+
- name: Promote only after signed validation
121+
shell: bash
122+
env:
123+
ENVELOPE: ${{ steps.generated.outputs.envelope }}
124+
run: |
125+
set -euo pipefail
126+
python3 scripts/promote-signed-journey-result.py \
127+
--base-ref origin/main \
128+
SPEC-016-R002 \
129+
"$ENVELOPE"
130+
131+
- name: Drop non-promotable intermediates
132+
shell: bash
133+
env:
134+
PAYLOAD: ${{ steps.generated.outputs.payload }}
135+
CANDIDATE: ${{ steps.generated.outputs.candidate }}
136+
run: |
137+
set -euo pipefail
138+
python3 - "$PAYLOAD" "$CANDIDATE" <<'PY'
139+
import pathlib
140+
import sys
141+
142+
for value in sys.argv[1:]:
143+
path = pathlib.Path(value)
144+
if path.suffix != ".json" or "journeys/evidence/" not in path.as_posix():
145+
raise SystemExit(f"refusing to remove unexpected path: {path}")
146+
path.unlink()
147+
PY
148+
149+
- name: Verify promoted ledger and committed artifact set
150+
shell: bash
151+
env:
152+
REDACTED: ${{ steps.generated.outputs.redacted }}
153+
ENVELOPE: ${{ steps.generated.outputs.envelope }}
154+
run: |
155+
set -euo pipefail
156+
python3 -m json.tool "$REDACTED" >/dev/null
157+
python3 -m json.tool "$ENVELOPE" >/dev/null
158+
python3 -m json.tool specs/CONFORMANCE.json >/dev/null
159+
python3 scripts/check_spec_governance.py --base-ref origin/main
160+
git diff --check
161+
python3 - "$ENVELOPE" <<'PY'
162+
import json
163+
import pathlib
164+
import sys
165+
166+
envelope = pathlib.Path(sys.argv[1]).as_posix()
167+
conformance = json.loads(pathlib.Path("specs/CONFORMANCE.json").read_text(encoding="utf-8"))
168+
matches = [
169+
item for item in conformance.get("requirements", [])
170+
if isinstance(item, dict) and item.get("requirement_id") == "SPEC-016-R002"
171+
]
172+
if len(matches) != 1:
173+
raise SystemExit("SPEC-016-R002 must exist exactly once")
174+
requirement = matches[0]
175+
if requirement.get("state") != "conformant" or requirement.get("gap") is not None:
176+
raise SystemExit("SPEC-016-R002 was not promoted to conformant")
177+
evidence = requirement.get("evidence")
178+
if not isinstance(evidence, list) or not any(item.get("source") == envelope for item in evidence if isinstance(item, dict)):
179+
raise SystemExit("SPEC-016-R002 evidence does not reference the signed envelope")
180+
for path in pathlib.Path("journeys/evidence").glob("spec016-r002-payout-address-*"):
181+
name = path.name
182+
if name.endswith(".candidate.json") or name.endswith(".journey-result.unsigned.json"):
183+
raise SystemExit(f"non-promotable intermediate remains: {path}")
184+
PY
185+
186+
- name: Export signed promotion artifact
187+
shell: bash
188+
env:
189+
SOURCE_SHA: ${{ steps.request.outputs.source_sha }}
190+
REDACTED: ${{ steps.generated.outputs.redacted }}
191+
ENVELOPE: ${{ steps.generated.outputs.envelope }}
192+
run: |
193+
set -euo pipefail
194+
export_dir="$RUNNER_TEMP/signed-payout-journey-promotion"
195+
mkdir -p "$export_dir/journeys/evidence" "$export_dir/specs"
196+
cp "$REDACTED" "$export_dir/$REDACTED"
197+
cp "$ENVELOPE" "$export_dir/$ENVELOPE"
198+
cp specs/CONFORMANCE.json "$export_dir/specs/CONFORMANCE.json"
199+
python3 - "$export_dir/promotion-manifest.json" "$SOURCE_SHA" "$REDACTED" "$ENVELOPE" <<'PY'
200+
import hashlib
201+
import json
202+
import pathlib
203+
import sys
204+
205+
output, source_sha, redacted, envelope = sys.argv[1:]
206+
root = pathlib.Path(".")
207+
manifest = {
208+
"schema_version": "macprovider.signed-payout-journey-promotion.v1",
209+
"source_sha": source_sha,
210+
"requirement_id": "SPEC-016-R002",
211+
"journey_id": "JOURNEY-SPEC-016-PAYOUT-ADDRESS-REGISTRATION",
212+
"redacted_artifact": redacted,
213+
"redacted_sha256": hashlib.sha256((root / redacted).read_bytes()).hexdigest(),
214+
"signed_envelope": envelope,
215+
"signed_envelope_sha256": hashlib.sha256((root / envelope).read_bytes()).hexdigest(),
216+
"conformance_sha256": hashlib.sha256((root / "specs/CONFORMANCE.json").read_bytes()).hexdigest(),
217+
"workflow_run_id": __import__("os").environ["GITHUB_RUN_ID"],
218+
"workflow_run_attempt": __import__("os").environ["GITHUB_RUN_ATTEMPT"],
219+
}
220+
pathlib.Path(output).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
221+
PY
222+
223+
- name: Upload signed promotion artifact
224+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
225+
with:
226+
name: signed-payout-journey-promotion-${{ steps.request.outputs.source_sha }}
227+
path: ${{ runner.temp }}/signed-payout-journey-promotion/
228+
if-no-files-found: error
229+
include-hidden-files: false
230+
retention-days: 1

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ test-dist:
6363
bash scripts/test-malibu-bootstrap-bridge.sh
6464
bash scripts/test-recover-malibu-publication.sh
6565
bash scripts/test-acceptance-candidate-security.sh
66+
bash scripts/test-signed-payout-journey-workflow.sh
6667
bash scripts/test-acceptance-candidate-metadata.sh
6768
bash scripts/test-acceptance-promotion.sh
6869
bash scripts/test-release-toolchain.sh
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
5+
workflow="$root/.github/workflows/promote-signed-payout-journey.yml"
6+
7+
fail() {
8+
printf '[test-signed-payout-journey-workflow] ERROR: %s\n' "$*" >&2
9+
exit 1
10+
}
11+
12+
[[ -f "$workflow" && ! -L "$workflow" ]] || fail "workflow is absent or unsafe"
13+
14+
python3 - "$workflow" <<'PY'
15+
import pathlib
16+
import re
17+
import sys
18+
19+
workflow = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
20+
21+
required = [
22+
"\n workflow_dispatch:\n",
23+
"environment: production-release",
24+
"contents: read",
25+
"GH_TOKEN: ${{ secrets.RELEASE_POSTURE_TOKEN }}",
26+
'scripts/verify-github-release-posture.sh "$GITHUB_REPOSITORY" production-release 28995904',
27+
"MACPROVIDER_ACCEPTANCE_SIGNING_KEY_PEM: ${{ secrets.MACPROVIDER_ACCEPTANCE_SIGNING_KEY_PEM }}",
28+
'[[ "$GITHUB_REF" == refs/heads/main ]]',
29+
'[[ "$SOURCE_SHA_INPUT" == "$GITHUB_SHA" ]]',
30+
'[[ "$PROMOTION_CONFIRMED_INPUT" == true ]]',
31+
'[[ "$main_sha" == "$SOURCE_SHA_INPUT" ]]',
32+
"MACPROVIDER_CAPTURE_PAYOUT_JOURNEY=1",
33+
"scripts/sign-journey-result.py",
34+
"scripts/promote-signed-journey-result.py",
35+
"scripts/check_spec_governance.py --base-ref origin/main",
36+
"actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a",
37+
"retention-days: 1",
38+
"macprovider.signed-payout-journey-promotion.v1",
39+
]
40+
for value in required:
41+
if value not in workflow:
42+
raise SystemExit(f"workflow contract is missing: {value}")
43+
44+
if "\n push:" in workflow or "\n pull_request:" in workflow:
45+
raise SystemExit("workflow must be manual dispatch only")
46+
if "git push origin main" in workflow or "HEAD:refs/heads/main" in workflow:
47+
raise SystemExit("workflow must not push directly to main")
48+
for forbidden in ("contents: write", "pull-requests: write", "git push", "gh pr create", "gh pr merge", "gh release"):
49+
if forbidden in workflow:
50+
raise SystemExit(f"workflow contains an unnecessary write/publication capability: {forbidden}")
51+
if "cat \"$MACPROVIDER_ACCEPTANCE_SIGNING_KEY_PEM\"" in workflow:
52+
raise SystemExit("workflow must not print private key material")
53+
if re.search(r'echo .*MACPROVIDER_ACCEPTANCE_SIGNING_KEY_PEM', workflow):
54+
raise SystemExit("workflow echoes the private key environment variable")
55+
if "cp \"$REDACTED\"" not in workflow or "cp \"$ENVELOPE\"" not in workflow or "cp specs/CONFORMANCE.json" not in workflow:
56+
raise SystemExit("workflow must export only the signed evidence, redacted artifact, and ledger")
57+
if "candidate.json" not in workflow or "journey-result.unsigned.json" not in workflow:
58+
raise SystemExit("workflow must explicitly detect non-promotable intermediates")
59+
if "path.unlink()" not in workflow:
60+
raise SystemExit("workflow must remove non-promotable intermediates before artifact export")
61+
posture_index = workflow.find("scripts/verify-github-release-posture.sh")
62+
signing_key_index = workflow.find("MACPROVIDER_ACCEPTANCE_SIGNING_KEY_PEM")
63+
if posture_index == -1 or signing_key_index == -1 or posture_index > signing_key_index:
64+
raise SystemExit("workflow must verify release posture before importing the acceptance signing key")
65+
66+
lines = workflow.splitlines()
67+
for index, line in enumerate(lines):
68+
match = re.match(r"^(\s*)run:\s*\|", line)
69+
if not match:
70+
continue
71+
indent = len(match.group(1))
72+
block = []
73+
for candidate in lines[index + 1 :]:
74+
if candidate.strip() and len(candidate) - len(candidate.lstrip()) <= indent:
75+
break
76+
block.append(candidate)
77+
if any("${{" in row for row in block):
78+
raise SystemExit("GitHub expression is interpolated directly into a shell block")
79+
80+
print("[test-signed-payout-journey-workflow] ok: protected manual signer exports a short-lived artifact")
81+
PY

0 commit comments

Comments
 (0)