Skip to content

Commit a68ba47

Browse files
committed
Merge FEATURE-ci-freebsd-proof: Repair guest Git and evidence recovery
2 parents 663c0eb + 499dc1d commit a68ba47

4 files changed

Lines changed: 176 additions & 30 deletions

File tree

‎.github/workflows/v3-package-validation.yml‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,18 @@ jobs:
160160
cpu: 2
161161
prepare: pkg install -y bash autoconf automake libtool pkgconf python3 gmake curl binutils perl5 llvm19 git node24
162162
run: |
163+
set -eu
163164
export RUNNER_TEMP=/tmp/v3-package-${{ github.run_id }}-${{ github.run_attempt }}
164165
mkdir -p "$RUNNER_TEMP/package-evidence"
165166
trap 'cp -R "$RUNNER_TEMP/package-evidence" ./package-evidence' EXIT
167+
# Guest root must trust only the host checkout, including in Python's Git children.
168+
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0="$PWD"
169+
git_log="$RUNNER_TEMP/package-evidence/git-provenance.log"
170+
{
171+
printf 'checkout=%s\n' "$PWD" && command -v git && git --version &&
172+
git rev-parse --verify 'HEAD^{commit}'
173+
} > "$git_log" 2>&1 || { cat "$git_log" >&2; exit 1; }
174+
cat "$git_log"
166175
if [ "$(node --version)" != v24.15.0 ]; then
167176
mkdir -p "$RUNNER_TEMP/node-source"
168177
node_logs="$RUNNER_TEMP/package-evidence/node-source"
@@ -198,7 +207,7 @@ jobs:
198207
if: always()
199208
run: |
200209
mkdir -p package-evidence
201-
scp -O -r freebsd:/tmp/v3-package-${{ github.run_id }}-${{ github.run_attempt }}/package-evidence/. package-evidence/
210+
scp -O -r "freebsd:/tmp/v3-package-${{ github.run_id }}-${{ github.run_attempt }}/package-evidence" ./
202211
- if: always()
203212
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
204213
with:

‎.github/workflows/v3-release-acceptance.yml‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,14 @@ jobs:
262262
export RUNNER_TEMP=/tmp/v3-release-${{ github.run_id }}-${{ github.run_attempt }}
263263
mkdir -p "$RUNNER_TEMP/platform-evidence"
264264
trap 'cp -R "$RUNNER_TEMP/platform" "$RUNNER_TEMP/platform-evidence/" 2>/dev/null || true; cp -R "$RUNNER_TEMP/prerequisites" "$RUNNER_TEMP/platform-evidence/" 2>/dev/null || true; cp -R "$RUNNER_TEMP/platform-evidence" ./platform-evidence' EXIT
265+
# Guest root must trust only the host checkout, including in Python's Git children.
266+
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0="$PWD"
267+
git_log="$RUNNER_TEMP/platform-evidence/git-provenance.log"
268+
{
269+
printf 'checkout=%s\n' "$PWD" && command -v git && git --version &&
270+
git rev-parse --verify 'HEAD^{commit}'
271+
} > "$git_log" 2>&1 || { cat "$git_log" >&2; exit 1; }
272+
cat "$git_log"
265273
if [ "$(node --version)" != v24.15.0 ]; then
266274
mkdir -p "$RUNNER_TEMP/node-source"
267275
node_logs="$RUNNER_TEMP/platform-evidence/node-source"
@@ -294,7 +302,7 @@ jobs:
294302
if: always()
295303
run: |
296304
mkdir -p platform-evidence
297-
scp -O -r freebsd:/tmp/v3-release-${{ github.run_id }}-${{ github.run_attempt }}/platform-evidence/. platform-evidence/
305+
scp -O -r "freebsd:/tmp/v3-release-${{ github.run_id }}-${{ github.run_attempt }}/platform-evidence" ./
298306
- id: proof
299307
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
300308
with:

‎scripts/test-v3-ci-workflows.py‎

Lines changed: 129 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import json
66
import os
77
import re
8+
import shlex
9+
import shutil
810
import subprocess
911
import sys
1012
import tempfile
@@ -32,9 +34,60 @@ def workflow_shell(lane, job, step):
3234
class FreeBSDNodePrerequisites(unittest.TestCase):
3335
workflows = ('v3-package-validation.yml', 'v3-release-acceptance.yml')
3436

35-
def execute_prerequisite(self, root, name, fail_stage):
36-
steps = yaml.safe_load((ROOT / '.github/workflows' / name).read_text())['jobs']['freebsd']['steps']
37+
def checkout(self, root):
38+
root.mkdir(exist_ok=True)
39+
env = {key: value for key, value in os.environ.items() if not key.startswith('GIT_')}
40+
env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull)
41+
for args in (['init', '-q'], ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.invalid',
42+
'commit', '-q', '--allow-empty', '-m', 'fixture']):
43+
subprocess.run(['git'] + args, cwd=root, env=env, check=True, capture_output=True)
44+
return env
45+
46+
def guest_run(self, name, runner):
47+
steps = workflow(name)['jobs']['freebsd']['steps']
3748
vm = next(step for step in steps if 'freebsd-vm@' in step.get('uses', ''))
49+
run = '\n'.join('export RUNNER_TEMP=' + shlex.quote(str(runner))
50+
if line.startswith('export RUNNER_TEMP=') else line
51+
for line in vm['with']['run'].splitlines())
52+
return run, steps
53+
54+
def recover(self, root, steps, runner, env):
55+
recovery = next(step for step in steps if step.get('name') == 'Recover FreeBSD evidence')
56+
self.assertEqual(recovery['if'], 'always()')
57+
tools = root / 'transport'
58+
tools.mkdir()
59+
# Replace only SSH transport: real OpenSSH scp runs both protocol peers,
60+
# including the receiving client's strict filename validation.
61+
ssh = tools / 'ssh'
62+
ssh.write_text('''#!/usr/bin/env python3
63+
import os, pathlib, shlex, sys
64+
argv = shlex.split(sys.argv[-1])
65+
assert argv[:3] == ['scp', '-r', '-f'], argv
66+
remote = pathlib.PurePosixPath(argv[-1])
67+
assert str(remote).startswith(('/tmp/v3-package-123-1/', '/tmp/v3-release-123-1/')), remote
68+
relative = str(remote).split('/', 3)[3]
69+
source = os.environ['FIXTURE_RUNNER'] + '/' + relative
70+
if sys.argv[-1].endswith('/.'):
71+
source += '/.'
72+
os.execv(os.environ['REAL_SCP'], ['scp', '-r', '-f', source])
73+
''')
74+
ssh.chmod(0o755)
75+
scp = tools / 'scp'
76+
scp.write_text('#!/bin/sh\nfor arg do [ "$arg" != -T ] || exit 91; done\nexec "$REAL_SCP" -S "$FIXTURE_SSH" "$@"\n')
77+
scp.chmod(0o755)
78+
host = root / 'host'
79+
host.mkdir(exist_ok=True)
80+
run = recovery['run'].replace('${{ github.run_id }}', '123').replace('${{ github.run_attempt }}', '1')
81+
transport_env = dict(env, PATH=str(tools) + os.pathsep + env['PATH'],
82+
REAL_SCP=shutil.which('scp'), FIXTURE_SSH=str(ssh), FIXTURE_RUNNER=str(runner))
83+
result = subprocess.run(['bash', '-eu', '-c', run], cwd=host, env=transport_env,
84+
capture_output=True, text=True, timeout=10)
85+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
86+
upload = next(step for step in steps if step.get('if') == 'always()' and '/upload-artifact@' in step.get('uses', ''))
87+
return host / upload['with']['path']
88+
89+
def execute_prerequisite(self, root, name, fail_stage):
90+
env = self.checkout(root)
3891
runner = root / 'runner'
3992
tools = root / 'tools'
4093
tools.mkdir()
@@ -70,9 +123,8 @@ def execute_prerequisite(self, root, name, fail_stage):
70123
path = llvm / compiler
71124
path.write_text('#!/bin/sh\necho "selected clang version 19.1.7"\n')
72125
path.chmod(0o755)
73-
run = vm['with']['run']
74-
run = '\n'.join('export RUNNER_TEMP="' + str(runner) + '"' if line.startswith('export RUNNER_TEMP=') else line for line in run.splitlines())
75-
env = dict(os.environ, PATH=str(tools) + os.pathsep + os.environ['PATH'],
126+
run, steps = self.guest_run(name, runner)
127+
env = dict(env, PATH=str(tools) + os.pathsep + os.environ['PATH'],
76128
FIXTURE_RUNNER=str(runner), SELECTED_LLVM=str(llvm),
77129
OBSERVED=str(root / 'observed.json'), FAIL_STAGE=fail_stage)
78130
env.pop('CC', None)
@@ -97,29 +149,7 @@ def test_node_failure_diagnostics_survive_failed_vm_copyback(self):
97149
root = Path(directory)
98150
result, steps, runner, llvm, env = self.execute_prerequisite(root, name, stage)
99151
self.assertEqual(result.returncode, status, result.stdout + result.stderr)
100-
recovery = next((step for step in steps if step.get('name') == 'Recover FreeBSD evidence'), None)
101-
self.assertIsNotNone(recovery, 'Failed VM commands skip the action copyback; recover evidence on the host')
102-
self.assertEqual(recovery['if'], 'always()')
103-
# Emulate only the SSH transport boundary after failed action
104-
# copyback: the evidence must come from the VM temp directory.
105-
scp = root / 'tools/scp'
106-
scp.write_text('''#!/usr/bin/env python3
107-
import os, pathlib, shutil, sys
108-
remote, destination = sys.argv[-2:]
109-
assert remote.startswith('freebsd:/tmp/v3-'), remote
110-
folder = 'package-evidence' if '/package-evidence/' in remote else 'platform-evidence'
111-
source = pathlib.Path(os.environ['FIXTURE_RUNNER']) / folder
112-
shutil.copytree(source, destination, dirs_exist_ok=True)
113-
''')
114-
scp.chmod(0o755)
115-
host = root / 'host'
116-
host.mkdir()
117-
recovery_run = recovery['run'].replace('${{ github.run_id }}', '123').replace('${{ github.run_attempt }}', '1')
118-
recovered = subprocess.run(['bash', '-eu', '-c', recovery_run], cwd=host,
119-
env=env, capture_output=True, text=True, timeout=10)
120-
self.assertEqual(recovered.returncode, 0, recovered.stdout + recovered.stderr)
121-
upload = next(step for step in steps if step.get('if') == 'always()' and '/upload-artifact@' in step.get('uses', ''))
122-
logs = host / upload['with']['path'] / 'node-source'
152+
logs = self.recover(root, steps, runner, env) / 'node-source'
123153
provenance = (logs / 'compiler.log').read_text()
124154
self.assertIn(str(llvm / 'clang++'), provenance)
125155
self.assertIn('clang version 19', provenance)
@@ -128,6 +158,77 @@ def test_node_failure_diagnostics_survive_failed_vm_copyback(self):
128158
self.assertIn('node-build-diagnostic', (logs / 'build.log').read_text())
129159
self.assertFalse((logs / 'node-v24.15.0').exists(), 'Do not upload the Node source/build tree')
130160

161+
def test_guest_git_trust_is_exact_checkout_and_inherited_by_provenance_calls(self):
162+
for name in self.workflows:
163+
with self.subTest(workflow=name), tempfile.TemporaryDirectory() as directory:
164+
root = Path(directory)
165+
checkout = root / 'pinned checkout'
166+
env = self.checkout(checkout)
167+
other = root / 'other checkout'
168+
self.checkout(other)
169+
env['GIT_TEST_ASSUME_DIFFERENT_OWNER'] = '1'
170+
refused = subprocess.run(['git', 'rev-parse', '--verify', 'HEAD^{commit}'],
171+
cwd=checkout, env=env, capture_output=True, text=True)
172+
self.assertNotEqual(refused.returncode, 0)
173+
self.assertIn('dubious ownership', refused.stderr)
174+
runner = root / 'runner'
175+
run, _ = self.guest_run(name, runner)
176+
early = run.split('if [ "$(node --version)"', 1)[0]
177+
# The child process uses the same Git contract as the real Python
178+
# provenance validators; another checkout must remain untrusted.
179+
probe = 'import subprocess; subprocess.run(["git", "rev-parse", "--verify", "HEAD^{commit}"], check=True)'
180+
result = subprocess.run(['sh', '-eu', '-c', early + '\n' + shlex.join([sys.executable, '-c', probe]) +
181+
'\ngit -C ' + shlex.quote(str(other)) + ' rev-parse --verify HEAD'],
182+
cwd=checkout, env=env, capture_output=True, text=True, timeout=10)
183+
self.assertIn('dubious ownership', result.stderr)
184+
self.assertIn(str(other), result.stderr)
185+
self.assertNotIn(str(checkout), result.stderr)
186+
self.assertEqual(result.returncode, 128)
187+
folder = 'package-evidence' if name == self.workflows[0] else 'platform-evidence'
188+
provenance = (runner / folder / 'git-provenance.log').read_text()
189+
head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=checkout, text=True).strip()
190+
self.assertIn(str(checkout), provenance)
191+
self.assertIn('git version', provenance)
192+
self.assertIn(head, provenance)
193+
self.assertIn(head, result.stdout)
194+
195+
def test_failed_early_git_probe_is_retained_and_recovered_before_node(self):
196+
for name in self.workflows:
197+
with self.subTest(workflow=name), tempfile.TemporaryDirectory() as directory:
198+
root = Path(directory)
199+
env = self.checkout(root)
200+
(root / '.git/HEAD').write_text('ref: refs/heads/missing\n')
201+
runner = root / 'runner'
202+
run, steps = self.guest_run(name, runner)
203+
early = run.split('if [ "$(node --version)"', 1)[0]
204+
result = subprocess.run(['sh', '-eu', '-c', early], cwd=root, env=env,
205+
capture_output=True, text=True, timeout=10)
206+
self.assertNotEqual(result.returncode, 0)
207+
self.assertIn('Needed a single revision', result.stderr)
208+
evidence = self.recover(root, steps, runner, env)
209+
self.assertIn('Needed a single revision', (evidence / 'git-provenance.log').read_text())
210+
self.assertFalse((evidence / 'node-source').exists())
211+
212+
def test_successful_evidence_recovery_merges_named_directory_in_place(self):
213+
for name in self.workflows:
214+
with self.subTest(workflow=name), tempfile.TemporaryDirectory() as directory:
215+
root = Path(directory)
216+
runner = root / 'runner'
217+
_, steps = self.guest_run(name, runner)
218+
folder = 'package-evidence' if name == self.workflows[0] else 'platform-evidence'
219+
source = runner / folder
220+
(source / 'platform').mkdir(parents=True)
221+
(source / 'platform/FreeBSD.tar.gz').write_bytes(b'platform proof')
222+
(source / '.diagnostic').write_text('hidden diagnostic')
223+
existing = root / 'host' / folder
224+
existing.mkdir(parents=True)
225+
(existing / 'kept.log').write_text('action copyback diagnostic')
226+
evidence = self.recover(root, steps, runner, dict(os.environ))
227+
self.assertEqual((evidence / 'kept.log').read_text(), 'action copyback diagnostic')
228+
self.assertEqual((evidence / '.diagnostic').read_text(), 'hidden diagnostic')
229+
self.assertEqual((evidence / 'platform/FreeBSD.tar.gz').read_bytes(), b'platform proof')
230+
self.assertFalse((evidence / folder).exists())
231+
131232

132233
class WindowsGitPreflight(unittest.TestCase):
133234
def test_native_git_checkout_diagnostic_runs_before_provider_build(self):
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# FEATURE-ci-freebsd-proof: FreeBSD checkout provenance and evidence recovery
2+
3+
Status: Complete (local implementation, Groundwork validation and finalization). Native FreeBSD execution remains CI / v3 PR owned under AGENTS.md. Base: `v3` at `663c0eb390a5d0a9380b8c9002c67b0cf2af770e`.
4+
5+
Archive FreeBSD job `120331982719` in run `39928823544` completed its pinned Node source build and client installation, then failed native Git provenance with `dubious ownership`. Its always-run recovery also failed: legacy SCP rejected the source basename `.`. The affected workflow sections were unchanged from `919fa6aa` to this task's base, as established by the caller's bounded comparison. Full original output is retained in the main repository's `.debug/FEATURE-ci-freebsd-proof/original-freebsd-job.log`.
6+
7+
Both package and archive FreeBSD commands now export a process-scoped `safe.directory` for exactly the current pinned checkout. Before Node setup, native Git reports its executable, version and verified HEAD commit into `git-provenance.log` and the job output. Probe failure prints its retained diagnostic and stops the guest command. Python provenance subprocesses inherit the same exact checkout trust; no global Git settings or source/artifact binding checks change.
8+
9+
Both host recovery commands copy the fixed, quoted, named evidence directory into its parent. OpenSSH's legacy SCP filename checks remain enabled. Recovery merges into `package-evidence/` or `platform-evidence/`, preserves hidden files and existing action-copyback evidence, and retains the archive proof at `platform-evidence/platform/FreeBSD.tar.gz`.
10+
11+
Action items:
12+
13+
- [x] Create the isolated task branch/worktree using Groundwork's identity helper; preserve unrelated bauth work.
14+
- [x] Retain the original Git/SCP failure and confirm new focused regressions fail before implementation.
15+
- [x] Establish exact checkout Git trust before expensive prerequisites and retain early native provenance on success/failure.
16+
- [x] Correct both SCP commands without `-T`, skipped validation, masked recovery failures, or changed upload conditions.
17+
- [x] Exercise real Git ownership refusal, inherited checkout trust and refusal of another checkout.
18+
- [x] Exercise actual OpenSSH SCP sender/receiver protocol with only SSH transport replaced locally; verify strict-basename RED, recovered diagnostics, hidden files and exact package/archive layout.
19+
- [x] Run all three affected Python workflow test scripts, actionlint, shell/Python syntax and diff checks.
20+
- [x] Preserve raw outputs, command journal and verification limits in the main repository's `.debug/FEATURE-ci-freebsd-proof/`.
21+
- [x] Caller: Groundwork validation and local finalization into `v3`; five reviewers approved with zero findings.
22+
- [ ] CI / v3 PR: fresh native FreeBSD execution and hosted artifact recovery/upload.
23+
24+
Local verification: all 20 workflow tests, 4 package-workflow tests and 6 platform-CI tests pass. Actionlint passes for both changed workflows. The pre-fix regression run failed in 10 subcases across both workflows, including real native Git checkout refusal and real SCP `unexpected filename: .`; the repaired commands pass. Local Git uses `GIT_TEST_ASSUME_DIFFERENT_OWNER=1` to exercise its actual ownership policy, with an independently initialized checkout still refused. The SCP fixture runs the installed OpenSSH client and server protocol; it replaces SSH transport only and uses no network credentials or daemon. These checks provide local command/protocol evidence, not native FreeBSD or hosted action proof.
25+
26+
Pinned Node source setup took roughly 2h28 and completed in the failed native job. Tool pins, checksums, dependency setup, caches, timeouts, source bindings, artifact identities, permissions and publication are unchanged. No native baseline, broad suite, unrelated subsystem investigation, merge, push or publication was performed. Prior closed findings remain closed. The separate older Linux parity failure remains **UNCERTAIN** after its settled bounded diagnostic batch; this task does not reopen it or treat a passing replay as independence.
27+
28+
Project notes / task-executor memory: v3 is Autotools/C++20 (`./bootstrap`, out-of-tree `configure`, `make check`); workflow-only changes need no native build setup. Focused workflow contracts use Python `unittest` and PyYAML in `scripts/test-v3-ci-workflows.py`, `test-v3-package-workflow.py`, and `test-v3-platform-ci.py`. Extract actual YAML commands for execution; mock only unavailable transport/tool boundaries, retaining real Git/SCP policy checks. Task evidence lives in ignored main `.debug/<identifier>/`, task records in `specs/tasks/`. Worktree identity comes from Groundwork's shared helper; Git metadata creation/commits may need the existing sandbox escalation.

0 commit comments

Comments
 (0)