Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion automation/reddit_output_transaction.psm1
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
Set-StrictMode -Version Latest

function Assert-RedditGitEnvironment {
$pattern = '^GIT_(DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|' +
'NAMESPACE|.*PREFIX|SHALLOW_FILE|GRAFT_FILE|CONFIG.*|CEILING_DIRECTORIES|' +
'DISCOVERY_ACROSS_FILESYSTEM|IMPLICIT_WORK_TREE|REPLACE_REF_BASE)$'
$rejected = @(Get-ChildItem Env: | Where-Object { $_.Name -match $pattern } | Select-Object -ExpandProperty Name)
if ($rejected.Count -gt 0) { throw "Rejected Git environment: $($rejected -join ', ')" }
}

function Resolve-RedditOutputPath {
param(
[Parameter(Mandatory = $true)][string]$ProjectRoot,
Expand Down Expand Up @@ -136,4 +144,4 @@ function Restore-RedditOutputSnapshot {
Remove-RedditOutputSnapshot -Snapshot $Snapshot
}

Export-ModuleMember -Function New-RedditOutputSnapshot, Restore-RedditOutputSnapshot, Restore-RedditOutputSnapshotPaths, Remove-RedditOutputSnapshot
Export-ModuleMember -Function New-RedditOutputSnapshot, Restore-RedditOutputSnapshot, Restore-RedditOutputSnapshotPaths, Remove-RedditOutputSnapshot, Assert-RedditGitEnvironment
14 changes: 14 additions & 0 deletions automation/run_reddit_baseline.ps1
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
param([string]$PreparedMainSha = "")
$ErrorActionPreference = "Stop"
$repo = Split-Path -Parent $PSScriptRoot
$py = Join-Path $repo ".venv311\Scripts\python.exe"
Expand All @@ -20,12 +21,14 @@ $ignoredOutputs = @(

function Run-Step {
param([string]$Label, [string[]]$Command)
if ($Command[0] -eq "git") { Assert-RedditGitEnvironment }
Write-Host "==> $Label"
& $Command[0] $Command[1..($Command.Length - 1)]
if ($LASTEXITCODE -ne 0) { throw "$Label failed with exit code $LASTEXITCODE" }
}

function Assert-CleanWorktree {
Assert-RedditGitEnvironment
$status = @(git status --porcelain)
if ($LASTEXITCODE -ne 0) { throw "git status failed" }
$ignored = @(git status --porcelain --ignored --untracked-files=all -- datos/latest datos/history datos/metadata)
Expand All @@ -36,6 +39,15 @@ function Assert-CleanWorktree {
if ($LASTEXITCODE -ne 0 -or $currentBranch -ne "main") { throw "The automation requires clean main: $currentBranch" }
}

function Assert-PreparedMainSha {
Assert-RedditGitEnvironment
if ($PreparedMainSha -notmatch '^[0-9a-f]{40}$') { throw "Missing or invalid prepared main SHA" }
$head = (git rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or $head -ne $PreparedMainSha) { throw "HEAD differs from prepared main SHA" }
$currentBranch = (git branch --show-current).Trim()
if ($LASTEXITCODE -ne 0 -or $currentBranch -ne "main") { throw "Prepared producer requires main" }
}

function Get-TopicMentions {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing Reddit topics CSV: $Path" }
Expand Down Expand Up @@ -89,6 +101,7 @@ function Assert-RedditSourcePackage {
function Invoke-SourcePublication {
param([Parameter(Mandatory = $true)]$Snapshot)
try {
Assert-PreparedMainSha
Run-Step "git checkout branch" @("git", "checkout", "-b", $branch)
Run-Step "git add target files" (@("git", "add", "--") + $filesToStage)
Run-Step "git commit" @("git", "commit", "-m", "chore(data): refresh reddit source package $utcDate")
Expand Down Expand Up @@ -128,6 +141,7 @@ function Invoke-SourcePublication {

Set-Location $repo
Assert-CleanWorktree
Assert-PreparedMainSha
$baselineMentions = Get-TopicMentions -Path (Join-Path $repo $filesToStage[1])
$outputSnapshot = New-RedditOutputSnapshot -ProjectRoot $repo -RelativePaths ($filesToStage + $ignoredOutputs)
$env:ETL_REFERENCE_DATE_UTC = $utcDate
Expand Down
23 changes: 22 additions & 1 deletion automation/run_reddit_baseline_guarded.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,26 @@ if (Test-Path $stateFile) {
}
}

$py = Join-Path $repo ".venv311\Scripts\python.exe"
Import-Module (Join-Path $PSScriptRoot "reddit_output_transaction.psm1") -Force
try {
Assert-RedditGitEnvironment
$preparation = @(& $py -I -B (Join-Path $repo "scripts\prepare_reddit_producer.py") --root $repo 2>&1)
$preparationExit = $LASTEXITCODE
}
catch {
$preparation = @("Preparation invocation failed; HEAD and fetched refs require inspection.")
if ($_.Exception.Message.StartsWith("Rejected Git environment:")) {
$preparation = @("Preparation preflight failed: " + $_.Exception.Message)
}
$preparationExit = 1
}
if ($preparationExit -ne 0 -or $preparation.Count -ne 1 -or [string]$preparation[0] -notmatch '^[0-9a-f]{40}$') {
Write-Status -Outcome "preparation_failed" -Message "Producer preparation failed; preserve Git state for inspection." -WindowKey $windowKey
foreach ($line in $preparation) { Write-Log ([string]$line); Write-Output ([string]$line) }
exit 1
}
$preparedSha = [string]$preparation[0]
$currentUtcDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd")
$topicsLatest = Get-BridgeLatestDate -Path $topicsBridgePath
$intersectionLatest = Get-BridgeLatestDate -Path $intersectionBridgePath
Expand All @@ -195,7 +215,8 @@ if ($topicsLatest -eq $currentUtcDate -and $intersectionLatest -eq $currentUtcDa
Write-Host "==> guarded run reason=$Reason window=$windowKey"
Write-Log "guard executing main script for $windowKey"

$commandLine = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$mainScript`" 2>&1"
Assert-RedditGitEnvironment
$commandLine = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$mainScript`" -PreparedMainSha $preparedSha 2>&1"
$runOutput = New-Object System.Collections.Generic.List[string]
& cmd.exe /d /c $commandLine | ForEach-Object {
$line = [string]$_
Expand Down
127 changes: 127 additions & 0 deletions scripts/prepare_reddit_producer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Prepare clean producer main without installing packages or replacing bootstrap code."""

import argparse
from importlib import metadata
import os
from pathlib import Path
import re
import subprocess
import sys
import uuid

ORIGIN = "https://github.com/Sam-24-dev/Technology-trend-analysis-platform"
CONTROLS = ("automation/run_reddit_baseline_guarded.ps1", "automation/run_reddit_baseline.ps1",
"automation/reddit_output_transaction.psm1", "scripts/prepare_reddit_producer.py")
GIT_SELECTORS = (r"^GIT_(DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|"
r"NAMESPACE|.*PREFIX|SHALLOW_FILE|GRAFT_FILE|CONFIG.*|CEILING_DIRECTORIES|"
r"DISCOVERY_ACROSS_FILESYSTEM|IMPLICIT_WORK_TREE|REPLACE_REF_BASE)$")


def git(root, *args):
rejected = sorted(name for name in os.environ if re.match(GIT_SELECTORS, name, re.IGNORECASE))
if rejected:
raise RuntimeError("Rejected Git environment: " + ", ".join(rejected))
env = dict(os.environ, GIT_TERMINAL_PROMPT="0", GCM_INTERACTIVE="Never",
GIT_SSH_COMMAND="ssh -oBatchMode=yes")
result = subprocess.run(["git", "-C", str(root), *args],
env=env, capture_output=True, text=True, timeout=60, check=False)
if result.returncode:
raise RuntimeError(f"Git {args[0]} failed; Git state was not rolled back")
return result.stdout.strip()


def clean_main(root):
if git(root, "branch", "--show-current") != "main":
raise RuntimeError("Producer requires main")
for operation in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-apply",
"rebase-merge", "sequencer", "index.lock"):
path = Path(git(root, "rev-parse", "--git-path", operation))
if (path if path.is_absolute() else Path(root) / path).exists():
raise RuntimeError("Git operation or index lock exists; preserve it for inspection")
if git(root, "status", "--porcelain", "--untracked-files=all") or any(line.startswith("!! ") for line in
git(root, "status", "--porcelain", "--ignored", "--untracked-files=all", "--",
"datos/latest", "datos/history", "datos/metadata").splitlines()):
raise RuntimeError("Existing changes or ignored outputs; refusing preparation")
return git(root, "rev-parse", "HEAD")


def dependencies(lock):
if sys.version_info[:2] != (3, 11) or sys.prefix == sys.base_prefix:
raise RuntimeError("An explicitly invoked isolated Python 3.11 is required")
pins = {}
for line in lock.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
match = re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)==([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)", line)
if not match:
raise RuntimeError("Unsupported lock syntax")
name, version = match.groups()
name = re.sub(r"[-_.]+", "-", name).lower()
if name in pins:
raise RuntimeError("Duplicate lock pin")
pins[name] = version
try:
installed = metadata.version(name)
except metadata.PackageNotFoundError as exc:
raise RuntimeError(f"Missing locked dependency: {name}") from exc
if installed != version:
raise RuntimeError(f"Installed dependency differs from frozen lock: {name}")
if not pins:
raise RuntimeError("Empty dependency lock")


def prepare(root, expected_origin=ORIGIN):
"""The origin argument is for direct local fixtures; CLI never overrides production origin."""
stage = "preflight"
head = target = "unknown"
try:
head = clean_main(root)
origin = git(root, "remote", "get-url", "--all", "origin").removesuffix(".git")
if origin != expected_origin.removesuffix(".git"):
raise RuntimeError("Unexpected origin")
stage = "fetch"
capture = f"refs/ttap/preparation/{uuid.uuid4().hex}"
git(root, "fetch", "--atomic", "--no-tags", "--no-recurse-submodules", "origin",
"refs/heads/main:refs/remotes/origin/main", f"refs/heads/main:{capture}")
target = git(root, "rev-parse", f"{capture}^{{commit}}")
git(root, "update-ref", "-d", capture, target)
stage = "admission"
git(root, "merge-base", "--is-ancestor", head, target)
original = git(root, "ls-tree", head, "--", *CONTROLS)
if len(original.splitlines()) != len(CONTROLS) or original != git(root, "ls-tree", target, "--", *CONTROLS):
raise RuntimeError("Bootstrap blobs or modes changed; activate reviewed controls explicitly")
stage = "dependencies"
dependencies(git(root, "show", f"{target}:backend/requirements.lock"))
stage = "advance"
if clean_main(root) != head:
raise RuntimeError("HEAD changed during preparation")
if head != target:
git(root, "merge", "--ff-only", target)
if clean_main(root) != target:
raise RuntimeError("Prepared HEAD differs from frozen main")
return target
except (RuntimeError, subprocess.TimeoutExpired, OSError, KeyboardInterrupt) as exc:
try:
actual = git(root, "rev-parse", "HEAD")
except (RuntimeError, subprocess.TimeoutExpired, OSError, KeyboardInterrupt):
actual = "unknown"
reason = str(exc) if isinstance(exc, RuntimeError) else type(exc).__name__
raise RuntimeError(f"Preparation failed at {stage}; H={head}; T={target}; actualHEAD={actual}; "
f"fetched refs may have changed; no Git rollback: {reason}") from exc


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, required=True)
args = parser.parse_args()
try:
print(prepare(args.root))
except RuntimeError as exc:
print(str(exc))
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
3 changes: 3 additions & 0 deletions tests/test_local_reddit_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,18 +418,21 @@ def test_runner_dirty_preflight_preserves_existing_bytes_and_stops_before_mutati
$preamble = $runner.Substring(0, $runner.IndexOf('Set-Location $repo'))
$preamble = $preamble -replace 'Import-Module.*\r?\n', ''
$preamble = $preamble.Replace('$repo = Split-Path -Parent $PSScriptRoot', '$repo = $env:TEST_ROOT')
Import-Module $env:TRANSACTION_MODULE -Force
Invoke-Expression $preamble
$repo = $env:TEST_ROOT
Set-Location $repo
Assert-CleanWorktree
'''
env = os.environ.copy()
env["RUNNER_PATH"] = str(RUNNER_PATH)
env["TRANSACTION_MODULE"] = str(TRANSACTION_MODULE)
env["TEST_ROOT"] = str(tmp_path)
result = _run_powershell(script, env)

if kind == "empty_ignored_root":
assert result.returncode == 0, result.stderr
else:
assert result.returncode != 0
assert "Pre-existing changes or ignored outputs" in result.stderr
assert target.read_bytes() == b"preexisting-bytes"
Loading
Loading