diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9780487..1535498a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,17 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - name: Checkout + - name: Checkout exact source revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify exact source revision + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" - name: Set up Java uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 @@ -46,12 +55,100 @@ jobs: if: runner.os == 'Windows' run: .\\mvnw.cmd -B test + - name: Report uncovered JaCoCo branches + if: failure() + shell: bash + run: | + python_command="python3" + if ! command -v "${python_command}" >/dev/null 2>&1; then + python_command="python" + fi + "${python_command}" - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ElementTree + + coverage_targets = { + "com/xtrmetl/etl/job/EtlJobService": "EtlJobService.java", + "com/xtrmetl/etl/controller/EtlJobController": "EtlJobController.java", + "com/xtrmetl/etl/service/Sha256Digest": "Sha256Digest.java", + } + reports = sorted(Path(".").glob("**/target/site/jacoco/jacoco.xml")) + if not reports: + raise SystemExit("No JaCoCo XML report was produced") + + found_classes = {class_name: False for class_name in coverage_targets} + for report_path in reports: + report_root = ElementTree.parse(report_path).getroot() + for class_name, source_name in coverage_targets.items(): + for class_element in report_root.findall( + f".//class[@name='{class_name}']" + ): + found_classes[class_name] = True + print(f"JaCoCo branch diagnostics for {class_name} from {report_path}:") + for method_element in class_element.findall("method"): + for counter in method_element.findall("counter"): + if counter.get("type") != "BRANCH": + continue + missed = int(counter.get("missed", "0")) + if missed > 0: + print( + " method=" + f"{method_element.get('name')}{method_element.get('desc')} " + f"first_line={method_element.get('line')} " + f"missed_branches={missed} " + f"covered_branches={counter.get('covered', '0')}" + ) + + for source_element in report_root.findall( + f".//sourcefile[@name='{source_name}']" + ): + for line_element in source_element.findall("line"): + missed = int(line_element.get("mb", "0")) + if missed > 0: + print( + f" source={source_name} " + f"source_line={line_element.get('nr')} " + f"missed_branches={missed} " + f"covered_branches={line_element.get('cb', '0')}" + ) + + missing_classes = [ + class_name + for class_name, was_found in found_classes.items() + if not was_found + ] + if missing_classes: + raise SystemExit( + "Strict coverage targets absent from JaCoCo XML: " + + ", ".join(missing_classes) + ) + PY + test_self_hosted: if: ${{ github.event_name == 'workflow_dispatch' && inputs.use_self_hosted == true }} runs-on: self-hosted steps: - - name: Checkout + - name: Checkout exact source revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify exact source revision (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + + - name: Verify exact source revision (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $actualHead = git rev-parse HEAD + if ($actualHead -ne "${{ github.event.pull_request.head.sha || github.sha }}") { + throw "Checked-out revision does not match the expected source SHA." + } - name: Set up Java uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 diff --git a/.github/workflows/hourly-opencode-maintenance.yml b/.github/workflows/hourly-opencode-maintenance.yml new file mode 100644 index 00000000..1fe99098 --- /dev/null +++ b/.github/workflows/hourly-opencode-maintenance.yml @@ -0,0 +1,950 @@ +name: Hourly OpenCode maintenance + +on: + schedule: + - cron: "43 * * * *" + +concurrency: + group: hourly-opencode-maintenance + cancel-in-progress: false + +permissions: + contents: read + +jobs: + maintain-repository: + permissions: + actions: read + checks: read + contents: read + issues: read + pull-requests: read + security-events: read + statuses: read + outputs: + open_pr_heads_before: ${{ steps.snapshot_heads.outputs.open_pr_heads }} + automation_branch_heads_before: ${{ steps.snapshot_heads.outputs.automation_branch_heads }} + develop_head_before: ${{ steps.snapshot_heads.outputs.develop_head }} + agent_candidate: ${{ steps.detect_candidate.outputs.agent_candidate }} + has_candidate: ${{ steps.detect_candidate.outputs.has_candidate }} + runs-on: ubuntu-latest + timeout-minutes: 50 + steps: + - name: Checkout protected default-branch source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 1 + persist-credentials: false + + - name: Install checksum-pinned OpenCode CLI + shell: bash + env: + OPENCODE_VERSION: "1.18.13" + OPENCODE_SHA256: "8d500b20fed2d26e537e221895b1a575476571b4f0089bb29fb13eeb8eb9e937" + run: | + set -euo pipefail + archive="${RUNNER_TEMP}/opencode-linux-x64.tar.gz" + install_dir="${RUNNER_TEMP}/opencode-bin" + + command -v curl >/dev/null + command -v install >/dev/null + command -v jq >/dev/null + command -v sha256sum >/dev/null + command -v tar >/dev/null + + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "${archive}" \ + "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" + printf '%s %s\n' "${OPENCODE_SHA256}" "${archive}" \ + | sha256sum --check --strict + + mapfile -t archive_members < <(tar --list --gzip --file "${archive}") + if [[ "${#archive_members[@]}" -ne 1 || "${archive_members[0]}" != "opencode" ]]; then + echo "OpenCode archive contains unexpected members" >&2 + exit 1 + fi + + archive_entry_metadata="$(LC_ALL=C tar --list --verbose --numeric-owner --gzip --file "${archive}")" + if [[ "${archive_entry_metadata:0:1}" != "-" ]]; then + echo "OpenCode archive member is not a regular file" >&2 + exit 1 + fi + + rm -rf "${install_dir}" + install -d -m 0700 "${install_dir}" + tar --extract --gzip --no-same-owner --no-same-permissions \ + --no-overwrite-dir --keep-old-files \ + --file "${archive}" --directory "${install_dir}" + test -f "${install_dir}/opencode" + test ! -L "${install_dir}/opencode" + chmod 0755 "${install_dir}/opencode" + test "$("${install_dir}/opencode" --version)" = "${OPENCODE_VERSION}" + printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}" + + - name: Snapshot protected branch and publication candidates + id: snapshot_heads + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + repository="${GITHUB_REPOSITORY}" + open_pr_file="${RUNNER_TEMP}/open-pr-heads-before.json" + automation_branch_file="${RUNNER_TEMP}/automation-branch-heads-before.json" + + develop_head="$(gh api "/repos/${repository}/git/ref/heads/develop" --jq '.object.sha')" + gh api --paginate \ + "/repos/${repository}/pulls?state=open&base=develop&per_page=100" \ + | jq -s --arg repo "${repository}" ' + (add // []) + | map(select(.head.repo.full_name == $repo)) + | map({key: (.number | tostring), value: .head.sha}) + | from_entries + ' > "${open_pr_file}" + gh api --paginate "/repos/${repository}/branches?per_page=100" \ + | jq -s ' + (add // []) + | map(select(.name | startswith("automation/opencode-"))) + | map({key: .name, value: .commit.sha}) + | from_entries + ' > "${automation_branch_file}" + + { + printf 'develop_head=%s\n' "${develop_head}" + printf 'open_pr_heads=%s\n' "$(jq -c . "${open_pr_file}")" + printf 'automation_branch_heads=%s\n' "$(jq -c . "${automation_branch_file}")" + } >> "${GITHUB_OUTPUT}" + + - name: Run bounded NVIDIA OpenCode maintenance + id: run_opencode + continue-on-error: true + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} + MODEL: nvidia/deepseek-ai/deepseek-v4-pro + OPENCODE_DISABLE_SHARE: "true" + PROMPT: | + Maintain ContextualWisdomLab/mightyETL toward defensible commercial and acquisition readiness. + + Start every run by inspecting every open pull request and its exact current head. Review unresolved human, CodeRabbit, GitHub Advanced Security, Dependabot, and automated feedback. Inspect required checks, statuses, and workflow outcomes. Treat queued, pending, skipped-required, stale-head, cancelled, absent, or unsuccessful gates as not passing. Distinguish valid current findings from stale, duplicate, incorrect, or superseded feedback. Resolve only source defects; deterministic non-model jobs and independent reviewers own remote branch publication and pull-request lifecycle operations. + + For every failing or blocked outcome, perform root-cause analysis before choosing a remediation. Trace the observed evidence to the responsible source, configuration, permission, quota, runner, provider, dependency, or policy boundary; do not label a symptom, a queued state, or a repeated retry as the root cause. Generate bounded remediation options that address the identified cause. Before acting, test each option's feasibility against current permissions, branch protection, tool capability, runtime and compute budgets, dependency state, and path ownership. Classify each option as executable now, requires an external actor, or unsafe or infeasible. + + Execute the highest-impact safe option that is executable in this run, then rerun the exact failing test or gate and verify whether the condition changed. If the preferred option requires an external actor or is infeasible, keep that gate fail-closed and immediately choose the next safe feasible non-overlapping remediation or independent bounded product slice instead of stopping. Never claim resolution from a proposal, retry, aggregate-green result, or predecessor evidence; require exact-head or literal-source evidence that the failing condition is gone. A pull request with only external blockers is not source-actionable. + + Completing an action is intermediate state, not an invocation endpoint. After every remediation, commit, documentation update, test result, deferred blocker, or completed slice, return to the highest-value safe executable queue. The one remote publication candidate limit constrains mutation output, not further read-only diagnosis, testing, or documentation analysis after a candidate is prepared. Queued checks, reviews, and provider waits are local deferred items, not reasons to idle. Same-branch writer movement freezes only that branch; continue safe work on other non-overlapping branches or read-only lanes. + + Before terminating, perform a fresh whole-repository sweep of pull requests, issues, checks, reviews, security, stack ancestry, documentation, release readiness, and product gaps. If that sweep finds any safe executable item, execute the highest-value item and restart the exit sweep count. Terminate only on genuine finite run-budget exhaustion or after a second consecutive fresh sweep proves no safe executable action remains. Routine status narration is not work. + + When one source-actionable dependency-eligible development pull request exists, fetch and check out that same-repository head branch, implement valid fixes test-first, rerun relevant tests, update authoritative documentation and CHANGELOG.md, and commit only to that local branch. Do not publish the branch remotely; the isolated deterministic branch publisher owns that authority. Do not create another branch for the same work. + + When no open pull request is source-actionable, whether or not blocked pull requests remain open, inspect open issues, roadmap, architecture, security, privacy, reliability, observability, accessibility, packaging, interoperability, deployment, data governance, operational workflows, release evidence, and buyer-visible gaps. Select exactly one highest-impact bounded vertical slice that is independent of every blocked or invalid stack. Start from the unchanged protected develop head, use exactly one automation/opencode-YYYYMMDDTHHMMSSZ-short-slug local branch, and commit the bounded slice there. Do not publish the branch remotely or create a second branch or pull request. Prefer durable ETL execution, idempotency, replay, dead-letter handling, schema contracts, connector reliability, target warehouse support, tenancy, auditability, SLO evidence, and operator controls. + + Preserve standalone operation and modular MSA compatibility with ContextualWisdomLab/.github, naruon, and other CWL services. Database objects must contain at least two descriptive words and use snake_case by default. Public production APIs require complete beginner-readable documentation. Added production statements and branches require deterministic 100% statement and branch coverage. Use current authoritative standards, primary technical documentation, and peer-reviewed evidence where material, with APA 7th references in repository documentation. + + Do not create, update, approve, close, or merge a pull request directly. Never push directly to develop or main. Do not bypass branch protection, independent approval, security gates, repository policy, required checks, or test coverage. Do not publish a release unless a separate release-authorized workflow and every acceptance gate explicitly permit it. + + Do not alter the existing review agent, its workflow, its provider configuration, or its credential flow. Do not change any review-agent secret name. Do not inspect secret values. Do not print, echo, summarize, or expose secret values. Do not modify .github/workflows/ or CODEOWNERS unless an open issue explicitly labeled automation-maintenance authorizes that exact bounded automation change; even then, leave the policy change for explicit human workflow authorization. + + Use the repository-scoped token for read operations only. Keep the working tree truthful about tests, skipped coverage, remaining risks, and release readiness. Commit every intended candidate change before returning. The deterministic non-model jobs validate and publish at most one exact local candidate. + run: | + set -euo pipefail + if [[ -z "${NVIDIA_API_KEY}" ]]; then + echo "NVIDIA_NIM_API_KEY is required for scheduled OpenCode maintenance" >&2 + exit 1 + fi + if [[ -z "${GITHUB_TOKEN}" || -z "${GH_TOKEN}" ]]; then + echo "The repository-scoped GitHub token is required for scheduled maintenance" >&2 + exit 1 + fi + + askpass_script="${RUNNER_TEMP}/opencode-git-read-askpass.sh" + cleanup_read_credentials() { + rm -f "${askpass_script}" + } + trap cleanup_read_credentials EXIT + cat > "${askpass_script}" <<'ASKPASS' + #!/usr/bin/env bash + case "${1:-}" in + *Username*) printf '%s\n' 'x-access-token' ;; + *) printf '%s\n' "${GITHUB_TOKEN}" ;; + esac + ASKPASS + chmod 0700 "${askpass_script}" + export GIT_ASKPASS="${askpass_script}" + export GIT_TERMINAL_PROMPT=0 + git config --local user.name "opencode-agent[bot]" + git config --local user.email "opencode-agent[bot]@users.noreply.github.com" + + printf '%s\n' "${PROMPT}" \ + | timeout --signal=TERM --kill-after=30s 45m opencode run --model "${MODEL}" --auto + + - name: Detect exactly one agent publication candidate + id: detect_candidate + if: ${{ always() && !cancelled() }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + BEFORE_PR_HEADS: ${{ steps.snapshot_heads.outputs.open_pr_heads }} + BEFORE_AUTOMATION_BRANCH_HEADS: ${{ steps.snapshot_heads.outputs.automation_branch_heads }} + DEVELOP_HEAD_BEFORE: ${{ steps.snapshot_heads.outputs.develop_head }} + run: | + set -euo pipefail + repository="${GITHUB_REPOSITORY}" + after_pr_file="${RUNNER_TEMP}/open-pr-heads-after.json" + after_branch_file="${RUNNER_TEMP}/automation-branch-heads-after.json" + candidate_dir="${RUNNER_TEMP}/opencode-candidate" + + jq -e 'select(type == "object")' >/dev/null <<<"${BEFORE_PR_HEADS}" + jq -e 'select(type == "object")' >/dev/null <<<"${BEFORE_AUTOMATION_BRANCH_HEADS}" + current_develop="$(gh api "/repos/${repository}/git/ref/heads/develop" --jq '.object.sha')" + if [[ "${current_develop}" != "${DEVELOP_HEAD_BEFORE}" ]]; then + echo "Protected develop moved during the model job; publication evidence is indeterminate" >&2 + exit 1 + fi + + gh api --paginate "/repos/${repository}/pulls?state=open&base=develop&per_page=100" \ + | jq -s '(add // [])' > "${after_pr_file}" + gh api --paginate "/repos/${repository}/branches?per_page=100" \ + | jq -s '(add // [])' > "${after_branch_file}" + + changed_existing="$( + jq -c \ + --arg repo "${repository}" \ + --argjson before "${BEFORE_PR_HEADS}" ' + [ + .[] + | select(.head.repo.full_name == $repo and .base.ref == "develop") + | (.number | tostring) as $number_key + | select($before[$number_key] != null and $before[$number_key] != .head.sha) + | { + kind: "existing_pr", + number: .number, + head_ref: .head.ref, + before_head_sha: $before[$number_key], + head_sha: .head.sha + } + ] + ' "${after_pr_file}" + )" + changed_existing_refs="$(jq -c '[.[].head_ref]' <<<"${changed_existing}")" + changed_automation="$( + jq -c \ + --argjson before "${BEFORE_AUTOMATION_BRANCH_HEADS}" \ + --argjson existing_refs "${changed_existing_refs}" ' + [ + .[] + | select(.name | startswith("automation/opencode-")) + | select(($before[.name] // null) != .commit.sha) + | .name as $branch_name + | select(($existing_refs | index($branch_name)) == null) + | { + kind: "new_branch", + head_ref: .name, + head_sha: .commit.sha + } + ] + ' "${after_branch_file}" + )" + candidates="$(jq -cn --argjson a "${changed_existing}" --argjson b "${changed_automation}" '$a + $b')" + candidate_count="$(jq 'length' <<<"${candidates}")" + if [[ "${candidate_count}" -gt 0 ]]; then + echo "Multiple agent publication candidates were detected remotely; refusing to race another writer" >&2 + jq . <<<"${candidates}" >&2 + exit 1 + fi + + after_pr_heads="$( + jq -c --arg repo "${repository}" ' + map(select(.head.repo.full_name == $repo and .base.ref == "develop")) + | map({key: (.number | tostring), value: .head.sha}) + | from_entries + ' "${after_pr_file}" + )" + if ! jq -e --argjson before "${BEFORE_PR_HEADS}" --argjson after "${after_pr_heads}" \ + '$before == $after' >/dev/null; then + echo "Open pull-request state moved during the model job; refusing stale publication" >&2 + exit 1 + fi + after_automation_heads="$( + jq -c ' + map(select(.name | startswith("automation/opencode-"))) + | map({key: .name, value: .commit.sha}) + | from_entries + ' "${after_branch_file}" + )" + if ! jq -e \ + --argjson before "${BEFORE_AUTOMATION_BRANCH_HEADS}" \ + --argjson after "${after_automation_heads}" \ + '$before == $after' >/dev/null; then + echo "Automation branch state moved during the model job; refusing stale publication" >&2 + exit 1 + fi + + if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then + echo "OpenCode left uncommitted changes; candidate publication requires an exact commit" >&2 + exit 1 + fi + branch_name="$(git symbolic-ref --quiet --short HEAD || true)" + candidate_head="$(git rev-parse HEAD)" + if [[ -z "${branch_name}" ]]; then + echo "OpenCode left a detached HEAD; refusing ambiguous publication" >&2 + exit 1 + fi + + if [[ "${branch_name}" == "develop" ]]; then + if [[ "${candidate_head}" == "${DEVELOP_HEAD_BEFORE}" ]]; then + printf 'agent_candidate=%s\n' '{"kind":"none"}' >> "${GITHUB_OUTPUT}" + printf 'has_candidate=false\n' >> "${GITHUB_OUTPUT}" + exit 0 + fi + echo "OpenCode committed on protected develop instead of a bounded candidate branch" >&2 + exit 1 + fi + + matching_prs="$( + jq -c \ + --arg repo "${repository}" \ + --arg branch "${branch_name}" ' + [ + .[] + | select( + .head.repo.full_name == $repo + and .base.ref == "develop" + and .head.ref == $branch + ) + ] + ' "${after_pr_file}" + )" + matching_pr_count="$(jq 'length' <<<"${matching_prs}")" + if [[ "${matching_pr_count}" -gt 1 ]]; then + echo "Multiple same-repository pull requests share the local candidate branch" >&2 + exit 1 + fi + + if [[ "${matching_pr_count}" -eq 1 ]]; then + number="$(jq -r '.[0].number' <<<"${matching_prs}")" + number_key="${number}" + before_head="$(jq -r --arg key "${number_key}" '.[$key] // empty' <<<"${BEFORE_PR_HEADS}")" + live_head="$(jq -r '.[0].head.sha' <<<"${matching_prs}")" + if [[ -z "${before_head}" || "${live_head}" != "${before_head}" ]]; then + echo "Existing pull-request head moved before local candidate validation" >&2 + exit 1 + fi + if [[ "${candidate_head}" == "${before_head}" ]]; then + printf 'agent_candidate=%s\n' '{"kind":"none"}' >> "${GITHUB_OUTPUT}" + printf 'has_candidate=false\n' >> "${GITHUB_OUTPUT}" + exit 0 + fi + predecessor_sha="${before_head}" + predecessor_ref="${branch_name}" + candidate="$( + jq -cn \ + --argjson number "${number}" \ + --arg head_ref "${branch_name}" \ + --arg before_head_sha "${before_head}" \ + --arg head_sha "${candidate_head}" \ + --arg predecessor_sha "${predecessor_sha}" \ + --arg predecessor_ref "${predecessor_ref}" \ + --arg develop_head_before "${DEVELOP_HEAD_BEFORE}" \ + '{ + kind: "existing_pr", + number: $number, + head_ref: $head_ref, + before_head_sha: $before_head_sha, + head_sha: $head_sha, + predecessor_sha: $predecessor_sha, + predecessor_ref: $predecessor_ref, + develop_head_before: $develop_head_before + }' + )" + else + if ! [[ "${branch_name}" =~ ^automation/opencode-[0-9]{8}T[0-9]{6}Z-[a-z0-9][a-z0-9-]{0,48}$ ]]; then + echo "Local candidate branch does not match the strict automation namespace" >&2 + exit 1 + fi + if jq -e --arg branch "${branch_name}" 'has($branch)' \ + >/dev/null <<<"${BEFORE_AUTOMATION_BRANCH_HEADS}"; then + echo "OpenCode reused an existing automation branch without an associated pull request" >&2 + exit 1 + fi + if jq -e --arg branch "${branch_name}" \ + 'any(.[]; .name == $branch)' >/dev/null "${after_branch_file}"; then + echo "Agent branch already exists remotely; refusing ambiguous ownership" >&2 + exit 1 + fi + predecessor_sha="${DEVELOP_HEAD_BEFORE}" + predecessor_ref="develop" + candidate="$( + jq -cn \ + --arg head_ref "${branch_name}" \ + --arg head_sha "${candidate_head}" \ + --arg predecessor_sha "${predecessor_sha}" \ + --arg predecessor_ref "${predecessor_ref}" \ + --arg develop_head_before "${DEVELOP_HEAD_BEFORE}" \ + '{ + kind: "new_branch", + head_ref: $head_ref, + head_sha: $head_sha, + predecessor_sha: $predecessor_sha, + predecessor_ref: $predecessor_ref, + develop_head_before: $develop_head_before + }' + )" + fi + + if ! git merge-base --is-ancestor "${predecessor_sha}" "${candidate_head}"; then + echo "Local candidate is not a non-destructive descendant of its exact predecessor" >&2 + exit 1 + fi + ahead_count="$(git rev-list --count "${predecessor_sha}..${candidate_head}")" + if [[ "${ahead_count}" -lt 1 ]]; then + echo "Agent branch is not ahead of develop or its exact predecessor" >&2 + exit 1 + fi + if [[ "${ahead_count}" -gt 50 ]]; then + echo "Agent candidate exceeds the bounded 50-commit publication limit" >&2 + exit 1 + fi + if git rev-list --merges "${predecessor_sha}..${candidate_head}" | grep -q .; then + echo "Agent candidate contains a merge commit; refusing non-linear publication" >&2 + exit 1 + fi + + changed_paths_file="${RUNNER_TEMP}/opencode-candidate-paths.txt" + git log --format= --name-only "${predecessor_sha}..${candidate_head}" \ + | sed '/^$/d' | sort -u > "${changed_paths_file}" + file_count="$(wc -l < "${changed_paths_file}")" + if [[ "${file_count}" -gt 50 ]]; then + echo "Agent branch exceeds the bounded 50-file publication limit" >&2 + exit 1 + fi + if grep -Eq '(^\.github/|(^|/)CODEOWNERS$)' "${changed_paths_file}"; then + echo "Agent branch changes .github or CODEOWNERS policy and requires manual handling" >&2 + exit 1 + fi + git diff --check "${predecessor_sha}" "${candidate_head}" + + rm -rf "${candidate_dir}" + install -d -m 0700 "${candidate_dir}" + printf '%s\n' "${candidate}" > "${candidate_dir}/metadata.json" + bundle_ref="refs/heads/__opencode_candidate" + git update-ref "${bundle_ref}" "${candidate_head}" + git bundle create "${candidate_dir}/candidate.bundle" \ + "${bundle_ref}" "^${predecessor_sha}" + git update-ref -d "${bundle_ref}" + bundle_size="$(stat -c '%s' "${candidate_dir}/candidate.bundle")" + if [[ "${bundle_size}" -gt 26214400 ]]; then + echo "Agent candidate bundle exceeds the 25 MiB publication limit" >&2 + exit 1 + fi + sha256sum "${candidate_dir}/candidate.bundle" \ + | awk '{print $1}' > "${candidate_dir}/candidate.bundle.sha256" + + printf 'agent_candidate=%s\n' "${candidate}" >> "${GITHUB_OUTPUT}" + printf 'has_candidate=true\n' >> "${GITHUB_OUTPUT}" + + - name: Upload exact local candidate for isolated branch publication + if: ${{ always() && !cancelled() && steps.detect_candidate.outputs.has_candidate == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: opencode-candidate-${{ github.run_id }} + path: ${{ runner.temp }}/opencode-candidate + if-no-files-found: error + retention-days: 1 + + - name: Preserve an OpenCode execution failure after candidate capture + if: ${{ steps.run_opencode.outcome == 'failure' }} + shell: bash + run: | + echo "OpenCode failed after any reviewable branch evidence was captured" >&2 + exit 1 + + publish-agent-branch: + needs: maintain-repository + if: ${{ always() && !cancelled() && needs.maintain-repository.outputs.has_candidate == 'true' }} + permissions: + actions: read + contents: write + outputs: + agent_candidate: ${{ steps.publish_branch.outputs.agent_candidate }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Download exact candidate bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: opencode-candidate-${{ github.run_id }} + path: ${{ runner.temp }}/opencode-candidate + + - name: Publish exact candidate by non-forced branch update + id: publish_branch + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_CANDIDATE: ${{ needs.maintain-repository.outputs.agent_candidate }} + run: | + set -euo pipefail + repository="${GITHUB_REPOSITORY}" + candidate_dir="${RUNNER_TEMP}/opencode-candidate" + metadata_file="${candidate_dir}/metadata.json" + bundle_file="${candidate_dir}/candidate.bundle" + digest_file="${candidate_dir}/candidate.bundle.sha256" + + test -f "${metadata_file}" + test -f "${bundle_file}" + test -f "${digest_file}" + metadata="$(jq -c . "${metadata_file}")" + expected_metadata="$(jq -c . <<<"${EXPECTED_CANDIDATE}")" + if [[ "${metadata}" != "${expected_metadata}" ]]; then + echo "Downloaded candidate metadata does not match the exact model-job output" >&2 + exit 1 + fi + expected_digest="$(tr -d '[:space:]' < "${digest_file}")" + if ! [[ "${expected_digest}" =~ ^[0-9a-f]{64}$ ]]; then + echo "Candidate bundle digest is malformed" >&2 + exit 1 + fi + actual_digest="$(sha256sum "${bundle_file}" | awk '{print $1}')" + if [[ "${actual_digest}" != "${expected_digest}" ]]; then + echo "Candidate bundle digest mismatch" >&2 + exit 1 + fi + + kind="$(jq -r '.kind' <<<"${metadata}")" + head_ref="$(jq -r '.head_ref' <<<"${metadata}")" + candidate_head="$(jq -r '.head_sha' <<<"${metadata}")" + predecessor_sha="$(jq -r '.predecessor_sha' <<<"${metadata}")" + predecessor_ref="$(jq -r '.predecessor_ref' <<<"${metadata}")" + develop_head_before="$(jq -r '.develop_head_before' <<<"${metadata}")" + if ! [[ "${candidate_head}" =~ ^[0-9a-f]{40}$ \ + && "${predecessor_sha}" =~ ^[0-9a-f]{40}$ \ + && "${develop_head_before}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Candidate metadata contains an invalid exact SHA" >&2 + exit 1 + fi + git check-ref-format --branch "${head_ref}" >/dev/null + git check-ref-format --branch "${predecessor_ref}" >/dev/null + + current_develop="$(gh api "/repos/${repository}/git/ref/heads/develop" --jq '.object.sha')" + if [[ "${current_develop}" != "${develop_head_before}" ]]; then + echo "Protected develop moved before isolated branch publication" >&2 + exit 1 + fi + + if [[ "${kind}" == "existing_pr" ]]; then + number="$(jq -r '.number' <<<"${metadata}")" + before_head="$(jq -r '.before_head_sha' <<<"${metadata}")" + if ! [[ "${number}" =~ ^[0-9]+$ && "${before_head}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Existing pull-request candidate metadata is malformed" >&2 + exit 1 + fi + live="$(gh api "/repos/${repository}/pulls/${number}")" + jq -e \ + --arg repo "${repository}" \ + --arg head_ref "${head_ref}" \ + --arg before_head "${before_head}" ' + select( + .state == "open" + and .base.repo.full_name == $repo + and .head.repo.full_name == $repo + and .base.ref == "develop" + and .head.ref == $head_ref + and .head.sha == $before_head + ) + ' >/dev/null <<<"${live}" + if [[ "${predecessor_sha}" != "${before_head}" || "${predecessor_ref}" != "${head_ref}" ]]; then + echo "Existing pull-request predecessor metadata does not bind the live branch" >&2 + exit 1 + fi + elif [[ "${kind}" == "new_branch" ]]; then + if ! [[ "${head_ref}" =~ ^automation/opencode-[0-9]{8}T[0-9]{6}Z-[a-z0-9][a-z0-9-]{0,48}$ ]]; then + echo "New candidate branch violates the strict automation namespace" >&2 + exit 1 + fi + encoded_ref="$(jq -rn --arg value "${head_ref}" '$value | @uri')" + if gh api "/repos/${repository}/branches/${encoded_ref}" >/dev/null 2>&1; then + echo "New candidate branch appeared before isolated publication" >&2 + exit 1 + fi + if [[ "${predecessor_sha}" != "${develop_head_before}" || "${predecessor_ref}" != "develop" ]]; then + echo "New branch predecessor does not bind the exact protected develop head" >&2 + exit 1 + fi + else + echo "Unknown agent candidate kind: ${kind}" >&2 + exit 1 + fi + + work_dir="${RUNNER_TEMP}/opencode-branch-publisher" + rm -rf "${work_dir}" + install -d -m 0700 "${work_dir}" + cd "${work_dir}" + git init --quiet + git remote add origin "https://github.com/${repository}.git" + + git_credential_key="credential.https://github.com.helper" + cleanup_git_credentials() { + git config --local --unset-all "${git_credential_key}" >/dev/null 2>&1 || true + } + trap cleanup_git_credentials EXIT + cleanup_git_credentials + git config --local --add "${git_credential_key}" "" + git config --local --add "${git_credential_key}" "!gh auth git-credential" + git config --local user.name "opencode-agent[bot]" + git config --local user.email "opencode-agent[bot]@users.noreply.github.com" + + git fetch --no-tags origin \ + "refs/heads/${predecessor_ref}:refs/remotes/origin/predecessor" + fetched_predecessor="$(git rev-parse refs/remotes/origin/predecessor)" + if [[ "${fetched_predecessor}" != "${predecessor_sha}" ]]; then + echo "Exact predecessor moved while the isolated publisher was preparing" >&2 + exit 1 + fi + + git bundle verify "${bundle_file}" + git fetch --no-tags "${bundle_file}" \ + "refs/heads/__opencode_candidate:refs/heads/__opencode_candidate" + imported_head="$(git rev-parse refs/heads/__opencode_candidate)" + if [[ "${imported_head}" != "${candidate_head}" ]]; then + echo "Candidate bundle does not contain the expected exact head" >&2 + exit 1 + fi + git fsck --strict --no-dangling + if ! git merge-base --is-ancestor "${predecessor_sha}" "${candidate_head}"; then + echo "Candidate bundle is not a non-destructive descendant of the exact predecessor" >&2 + exit 1 + fi + ahead_count="$(git rev-list --count "${predecessor_sha}..${candidate_head}")" + if [[ "${ahead_count}" -lt 1 || "${ahead_count}" -gt 50 ]]; then + echo "Candidate commit count is outside the bounded publication range" >&2 + exit 1 + fi + if git rev-list --merges "${predecessor_sha}..${candidate_head}" | grep -q .; then + echo "Candidate bundle contains a merge commit; refusing non-linear publication" >&2 + exit 1 + fi + + changed_paths_file="${RUNNER_TEMP}/opencode-publisher-paths.txt" + git log --format= --name-only "${predecessor_sha}..${candidate_head}" \ + | sed '/^$/d' | sort -u > "${changed_paths_file}" + file_count="$(wc -l < "${changed_paths_file}")" + if [[ "${file_count}" -gt 50 ]]; then + echo "Candidate exceeds the bounded 50-file publication limit" >&2 + exit 1 + fi + if grep -Eq '(^\.github/|(^|/)CODEOWNERS$)' "${changed_paths_file}"; then + echo "Candidate changes .github or CODEOWNERS policy and requires manual handling" >&2 + exit 1 + fi + git diff --check "${predecessor_sha}" "${candidate_head}" + + git push origin "${candidate_head}:refs/heads/${head_ref}" + encoded_ref="$(jq -rn --arg value "${head_ref}" '$value | @uri')" + live_head="$(gh api "/repos/${repository}/branches/${encoded_ref}" --jq '.commit.sha')" + if [[ "${live_head}" != "${candidate_head}" ]]; then + echo "Published branch does not retain the expected exact candidate head" >&2 + exit 1 + fi + + printf 'agent_candidate=%s\n' "${metadata}" >> "${GITHUB_OUTPUT}" + + publish-agent-pull-request: + needs: publish-agent-branch + if: ${{ always() && !cancelled() && needs.publish-agent-branch.result == 'success' && needs.publish-agent-branch.outputs.agent_candidate != '' }} + permissions: + contents: read + pull-requests: write + outputs: + pr_number: ${{ steps.publish.outputs.pr_number }} + head_ref: ${{ steps.publish.outputs.head_ref }} + head_sha: ${{ steps.publish.outputs.head_sha }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Publish one validated draft pull request or identify the updated pull request + id: publish + shell: bash + env: + GH_TOKEN: ${{ github.token }} + AGENT_CANDIDATE: ${{ needs.publish-agent-branch.outputs.agent_candidate }} + run: | + set -euo pipefail + repository="${GITHUB_REPOSITORY}" + owner="${repository%%/*}" + base_branch="develop" + kind="$(jq -r '.kind' <<<"${AGENT_CANDIDATE}")" + + head_ref="$(jq -r '.head_ref' <<<"${AGENT_CANDIDATE}")" + expected_head="$(jq -r '.head_sha' <<<"${AGENT_CANDIDATE}")" + if ! [[ "${expected_head}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Agent candidate has an invalid head SHA" >&2 + exit 1 + fi + + if [[ "${kind}" == "existing_pr" ]]; then + number="$(jq -r '.number' <<<"${AGENT_CANDIDATE}")" + before_head="$(jq -r '.before_head_sha' <<<"${AGENT_CANDIDATE}")" + if ! [[ "${before_head}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Updated pull request candidate has an invalid pre-agent head SHA" >&2 + exit 1 + fi + live="$(gh api "/repos/${repository}/pulls/${number}")" + jq -e \ + --arg repo "${repository}" \ + --arg base "${base_branch}" \ + --arg head_ref "${head_ref}" \ + --arg head_sha "${expected_head}" ' + select( + .state == "open" + and .base.repo.full_name == $repo + and .head.repo.full_name == $repo + and .base.ref == $base + and .head.ref == $head_ref + and .head.sha == $head_sha + ) + ' >/dev/null <<<"${live}" + + comparison="$(gh api "/repos/${repository}/compare/${before_head}...${expected_head}")" + if ! jq -e ' + .status == "ahead" + and .ahead_by >= 1 + and .behind_by == 0 + ' >/dev/null <<<"${comparison}"; then + echo "Updated pull request head is not a non-destructive descendant of its pre-agent head" >&2 + exit 1 + fi + file_count="$(jq '.files | length' <<<"${comparison}")" + if [[ "${file_count}" -gt 50 ]]; then + echo "Updated pull request exceeds the bounded 50-file publication limit" >&2 + exit 1 + fi + if jq -e ' + any( + .files[].filename; + startswith(".github/") + or . == "CODEOWNERS" + or endswith("/CODEOWNERS") + ) + ' >/dev/null <<<"${comparison}"; then + echo "Updated pull request changes .github or CODEOWNERS policy and requires manual handling" >&2 + exit 1 + fi + elif [[ "${kind}" == "new_branch" ]]; then + if ! [[ "${head_ref}" =~ ^automation/opencode-[0-9]{8}T[0-9]{6}Z-[a-z0-9][a-z0-9-]{0,48}$ ]]; then + echo "Agent branch does not match the strict publication namespace" >&2 + exit 1 + fi + encoded_ref="$(jq -rn --arg value "${head_ref}" '$value | @uri')" + live_head="$(gh api "/repos/${repository}/branches/${encoded_ref}" --jq '.commit.sha')" + if [[ "${live_head}" != "${expected_head}" ]]; then + echo "Agent branch moved before deterministic publication" >&2 + exit 1 + fi + + comparison="$(gh api "/repos/${repository}/compare/${base_branch}...${expected_head}")" + ahead_by="$(jq -r '.ahead_by' <<<"${comparison}")" + if [[ "${ahead_by}" -lt 1 ]]; then + echo "Agent branch is not ahead of develop" >&2 + exit 1 + fi + file_count="$(jq '.files | length' <<<"${comparison}")" + if [[ "${file_count}" -gt 50 ]]; then + echo "Agent branch exceeds the bounded 50-file publication limit" >&2 + exit 1 + fi + if jq -e ' + any( + .files[].filename; + startswith(".github/") + or . == "CODEOWNERS" + or endswith("/CODEOWNERS") + ) + ' >/dev/null <<<"${comparison}"; then + echo "Agent branch changes .github or CODEOWNERS policy and requires manual handling" >&2 + exit 1 + fi + + existing="$( + gh api --method GET "/repos/${repository}/pulls" \ + -f state=open \ + -f base="${base_branch}" \ + -f head="${owner}:${head_ref}" + )" + if [[ "$(jq 'length' <<<"${existing}")" -gt 0 ]]; then + number="$(jq -r '.[0].number' <<<"${existing}")" + else + title="$(gh api "/repos/${repository}/commits/${expected_head}" --jq '.commit.message | split("\n")[0]')" + title="${title//$'\r'/}" + if [[ -z "${title}" ]]; then + title="chore: bounded OpenCode maintenance" + fi + case "${title}" in + feat:*|fix:*|docs:*|test:*|refactor:*|perf:*|ci:*|chore:*|build:*|revert:*) ;; + *) title="chore: ${title}" ;; + esac + title="${title:0:180}" + body="$(printf '%s\n' \ + "## Automated bounded development slice" \ + "" \ + "This draft pull request was published after a deterministic non-model branch writer transferred exactly one validated local OpenCode candidate." \ + "" \ + "- Exact head: \`${expected_head}\`" \ + "- Base: \`${base_branch}\`" \ + "- Model credential: repository \`NVIDIA_NIM_API_KEY\` mapped only inside the preceding read-only OpenCode job" \ + "- Pull-request approval and merge: explicitly outside the development agent's authority" \ + "" \ + "All tests, security checks, review threads, independent exact-head approvals, documentation, and release gates remain required. Pending or absent evidence is not passing." \ + )" + payload="$(jq -cn \ + --arg title "${title}" \ + --arg head "${head_ref}" \ + --arg base "${base_branch}" \ + --arg body "${body}" \ + '{title: $title, head: $head, base: $base, body: $body, draft: true}')" + created="$(gh api --method POST "/repos/${repository}/pulls" --input - <<<"${payload}")" + number="$(jq -r '.number' <<<"${created}")" + fi + else + echo "Unknown agent candidate kind: ${kind}" >&2 + exit 1 + fi + + live="$(gh api "/repos/${repository}/pulls/${number}")" + live_head="$(jq -r '.head.sha' <<<"${live}")" + if [[ "${live_head}" != "${expected_head}" ]]; then + echo "Published pull request does not retain the expected exact head" >&2 + exit 1 + fi + { + printf 'pr_number=%s\n' "${number}" + printf 'head_ref=%s\n' "${head_ref}" + printf 'head_sha=%s\n' "${expected_head}" + } >> "${GITHUB_OUTPUT}" + + authorize-exact-head-checks: + needs: + - publish-agent-pull-request + if: ${{ always() && !cancelled() && needs.publish-agent-pull-request.result == 'success' && needs.publish-agent-pull-request.outputs.pr_number != '' }} + permissions: + actions: write + contents: read + pull-requests: read + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Authorize exact-head checks for the published or updated pull request + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.publish-agent-pull-request.outputs.pr_number }} + HEAD_REF: ${{ needs.publish-agent-pull-request.outputs.head_ref }} + EXPECTED_HEAD: ${{ needs.publish-agent-pull-request.outputs.head_sha }} + run: | + set -euo pipefail + repository="${GITHUB_REPOSITORY}" + number="${PR_NUMBER}" + head_ref="${HEAD_REF}" + expected_head="${EXPECTED_HEAD}" + required_workflow_names='["CI","Dependency Review","SBOM (CycloneDX)","SAST Semgrep","Security Scan"]' + all_runs_file="${RUNNER_TEMP}/pr-${number}-all-exact-head-runs.json" + runs_file="${RUNNER_TEMP}/pr-${number}-associated-exact-head-runs.json" + approved_run_ids_file="${RUNNER_TEMP}/pr-${number}-approved-run-ids.txt" + : > "${approved_run_ids_file}" + + live="$(gh api "/repos/${repository}/pulls/${number}")" + jq -e \ + --arg repo "${repository}" \ + --arg head_ref "${head_ref}" \ + --arg head_sha "${expected_head}" ' + select( + .state == "open" + and .head.repo.full_name == $repo + and .base.ref == "develop" + and .head.ref == $head_ref + and .head.sha == $head_sha + ) + ' >/dev/null <<<"${live}" + + changed_files="$( + gh api --paginate "/repos/${repository}/pulls/${number}/files?per_page=100" \ + | jq -s '(add // []) | map(.filename)' + )" + if jq -e ' + any( + startswith(".github/") + or . == "CODEOWNERS" + or endswith("/CODEOWNERS") + ) + ' >/dev/null <<<"${changed_files}"; then + echo "PR #${number} changes .github or CODEOWNERS policy; workflow runs require human authorization" >&2 + exit 1 + fi + + missing_workflow_names="${required_workflow_names}" + for _ in $(seq 1 18); do + current_head="$(gh api "/repos/${repository}/pulls/${number}" --jq '.head.sha')" + if [[ "${current_head}" != "${expected_head}" ]]; then + echo "PR #${number} moved while exact-head workflow runs were materializing" >&2 + exit 1 + fi + + gh api --paginate \ + "/repos/${repository}/actions/runs?event=pull_request&head_sha=${expected_head}&per_page=100" \ + | jq -s '(map(.workflow_runs) | add) // []' > "${all_runs_file}" + jq -c \ + --arg expected_head "${expected_head}" \ + --argjson pull_request_number "${number}" ' + [ + .[] + | select(.head_sha == $expected_head) + | select(any(.pull_requests[]?; .number == $pull_request_number)) + ] + ' "${all_runs_file}" > "${runs_file}" + + while IFS= read -r run_id; do + [[ -n "${run_id}" ]] || continue + if grep -Fxq "${run_id}" "${approved_run_ids_file}"; then + continue + fi + gh api --method POST "/repos/${repository}/actions/runs/${run_id}/approve" + printf '%s\n' "${run_id}" >> "${approved_run_ids_file}" + done < <( + jq -r ' + .[] + | select( + .conclusion == "action_required" + or .status == "waiting" + ) + | .id + ' "${runs_file}" + ) + + observed_workflow_names="$(jq -c '[.[].name] | unique' "${runs_file}")" + missing_workflow_names="$( + jq -cn \ + --argjson required "${required_workflow_names}" \ + --argjson observed "${observed_workflow_names}" \ + '$required - $observed' + )" + if [[ "$(jq 'length' <<<"${missing_workflow_names}")" -eq 0 ]]; then + break + fi + sleep 5 + done + + if [[ "$(jq 'length' <<<"${missing_workflow_names}")" -gt 0 ]]; then + echo "Missing required exact-head workflows for PR #${number}: ${missing_workflow_names}" >&2 + exit 1 + fi + + current_head="$(gh api "/repos/${repository}/pulls/${number}" --jq '.head.sha')" + if [[ "${current_head}" != "${expected_head}" ]]; then + echo "PR #${number} moved before workflow-run authorization completed" >&2 + exit 1 + fi + + echo "Authorized exact-head pull-request checks for PR #${number} branch ${head_ref} at ${expected_head}" diff --git a/.github/workflows/hourly-pr-disposition.yml b/.github/workflows/hourly-pr-disposition.yml index 020d6b9a..652f370b 100644 --- a/.github/workflows/hourly-pr-disposition.yml +++ b/.github/workflows/hourly-pr-disposition.yml @@ -80,19 +80,36 @@ jobs: fi reviews=$(gh api --paginate "/repos/${repo}/pulls/${number}/reviews?per_page=100") - blocking_reviews=$(jq -s ' + decisive_reviews=$(jq -s ' add | map(select(.state == "APPROVED" or .state == "CHANGES_REQUESTED")) | group_by(.user.login) | map(max_by(.submitted_at // "")) - | map(select(.state == "CHANGES_REQUESTED")) - | length ' <<<"${reviews}") + blocking_reviews=$(jq ' + map(select(.state == "CHANGES_REQUESTED")) + | length + ' <<<"${decisive_reviews}") if [[ "${blocking_reviews}" -gt 0 ]]; then skip "latest decisive review state includes requested changes" continue fi + independent_exact_head_approvals=$(jq \ + --arg author "${author}" \ + --arg head_sha "${head_sha}" ' + map(select( + .state == "APPROVED" + and .user.login != $author + and .commit_id == $head_sha + )) + | length + ' <<<"${decisive_reviews}") + if [[ "${independent_exact_head_approvals}" -eq 0 ]]; then + skip "independent exact-head approval is absent" + continue + fi + review_threads=$(gh api graphql --paginate \ -F owner="${owner}" \ -F name="${name}" \ diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 26aefe40..f5ee166c 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -24,8 +24,17 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' && inputs.use_self_hosted == false) }} runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout exact source revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify exact source revision + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" - name: Set up Java uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 @@ -49,8 +58,27 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' && inputs.use_self_hosted == true }} runs-on: self-hosted steps: - - name: Checkout + - name: Checkout exact source revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify exact source revision (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + + - name: Verify exact source revision (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $actualHead = git rev-parse HEAD + if ($actualHead -ne "${{ github.event.pull_request.head.sha || github.sha }}") { + throw "Checked-out revision does not match the expected source SHA." + } - name: Set up Java uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 diff --git a/AGENTS.md b/AGENTS.md index 757f5141..d6908a30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,45 @@ workflows, and service-level maintenance. - Keep automation explicit and auditable (clear triggers, least-privilege permissions). - When unsure, prefer conservative defaults that reduce security and release risk. +## Scheduled maintenance progress contract + +External review, approval, check, or read-only dependency latency is not a reason to stop all +productive mightyETL work. Those unavailable gates remain not passing and must never be reused as +merge or release evidence. + +For every failing or blocked outcome, identify the root cause before selecting a remediation. Trace +the observed evidence to a source, configuration, permission, quota, runner, provider, dependency, +or policy boundary. A symptom, queued state, repeated retry, or aggregate-green result is not a +root cause. Generate bounded options that address the identified cause, then test each option +against current permissions, branch protection, tool capability, runtime and compute budgets, +dependency state, path ownership, and repository-writer leases. + +Classify each option as executable now, requiring an external actor, or unsafe or infeasible. +Execute the highest-impact safe option that is executable during the current run and rerun the exact +failing test or gate. If the preferred option is external or infeasible, keep its gate fail-closed +and immediately continue with the next safe feasible non-overlapping remediation or independent +bounded product slice. Do not stop merely because the preferred option cannot be executed here. + +A pull request is source-actionable only when its exact current head has a valid repository-local +finding or failing source gate that mightyETL can repair. Queued checks, missing independent +approval, synthetic-merge-only scanner evidence, and a separately leased dependency that has not +yet integrated are external-only blockers rather than source-actionable findings. + +When no open pull request is source-actionable, select exactly one non-conflicting bounded +mightyETL slice from the protected `develop` head. Prefer documentation, tests, security, +reliability, packaging, release evidence, or a buyer-visible vertical slice that can be developed +and reviewed independently of the blocked stack. Keep the one-candidate-per-run publication +boundary and all exact-head validation requirements. + +Do not deepen an invalid stack or modify a blocked stack branch merely to appear productive. The +independent slice must not depend on, retarget, rewrite, or overlap files changed by the invalid +stack. If no such independent slice exists, perform read-only analysis and return without a +candidate rather than weakening ancestry, tests, or branch protection. + +ContextualWisdomLab/.github, naruon, contextual-orchestrator, and every separately leased repository +remain read-only. Inspect their exact integration state, but never mutate, dispatch a write-capable +agent, or post a mutation-trigger comment there. Their dedicated loops own those writes. + ## Code-owner review gates — disabled (on hold) As of 2026-08-04, code-owner review requirements (`require_code_owner_reviews` in branch diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b08b8c..5c8ea782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Scheduled OpenCode maintenance now performs root-cause analysis, tests remediation feasibility against live authority, protection, resource, dependency, path-ownership, and writer-lease constraints, executes and verifies the best safe option available now, and continues exactly one independent bounded mightyETL slice from protected `develop` when only external blockers remain; invalid stacks and separately leased repositories stay untouched. +- Container builds now pin Maven and Eclipse Temurin base-image tags to reviewed SHA-256 digests, with a fail-first contract test preventing mutable registry tags from re-entering the Dockerfile. +- The model-executing hourly OpenCode maintenance job now has read-only issue access; fail-first workflow-contract coverage proves `issues: write` is unnecessary while preserving issue and roadmap inspection. +- Pull-request CI and CycloneDX SBOM jobs now check out the literal current source head, immediately assert `git rev-parse HEAD` against `github.event.pull_request.head.sha`, and disable checkout credential persistence; generated merge revisions remain useful compatibility previews but no longer masquerade as direct exact-head source evidence. +- Dependency Review now relies on the immutably pinned GitHub Dependency Review Action's documented `pull_request` event endpoints; ignored `base-ref`/`head-ref` overrides are prohibited on pull-request runs, and dependency-delta evidence is invalidated whenever either endpoint moves. +- The hourly pull-request disposition loop now requires at least one non-author approval anchored to the exact current head SHA; stale approvals, comment-only reviews, and the mere absence of requested changes cannot authorize unattended merge. +- The hourly OpenCode workflow now scopes repository write permissions to its sole maintenance job, replaces the npm installation command with the immutable OpenCode 1.18.13 Linux release archive plus pinned SHA-256 validation, requires exactly one regular-file archive member before private-directory extraction, rejects non-regular or symbolic-link output, and uses a removable repository-local GitHub CLI credential helper instead of storing an encoded authorization header while retaining `persist-credentials: false`. +- The hourly OpenCode workflow now snapshots same-repository `develop` pull-request heads before the agent runs and uses job-scoped Actions write authority only to authorize approval-required workflow runs for an unchanged exact head; `.github/**` and `CODEOWNERS` changes remain human-authorized, and no review or merge authority is added. +- Updated existing pull-request candidates now carry their captured pre-agent head into the deterministic publisher, which rejects destructive ancestry, more than 50 agent-introduced files, and any agent-introduced `.github/**` or `CODEOWNERS` change before exposing the updated pull request or authorizing checks. +- The hourly OpenCode workflow now uses the current free NVIDIA `deepseek-ai/deepseek-v4-pro` endpoint for long-context coding and agentic tool use instead of the deprecated Qwen3 Coder free endpoint; model or endpoint rejection fails visibly without a non-NVIDIA, partner-only, or automatic fallback. +- The managed Jackson component set now uses the patched 2.21.5 BOM, closing CVE-2026-54515, CVE-2026-59889, and GHSA-mhm7-754m-9p8w while keeping core, annotations, datatype, and module artifacts aligned. - Durable `POST /api/etl/jobs` submissions now return RFC 9110 `202 Accepted`, a stable pending-job representation, `Location` status-monitor metadata, and explicit replay metadata without changing the synchronous `/api/etl/process` contract. The incomplete intake controller is fail-closed and requires explicit `xtrmetl.etl.jobs.intake-enabled=true` operator opt-in until worker execution and terminal payload clearing are implemented. - Concurrent requests using the same authenticated-principal-scoped semantic idempotency key now return immediate RFC 9457 `409 etl_idempotency_request_in_progress` responses through PostgreSQL `pg_try_advisory_xact_lock`; retries after completion still replay the committed response. - `POST /api/etl/process` now supports optional authenticated-principal-scoped `Idempotency-Key` retries with atomic target writes, durable response replay, payload-conflict rejection, and explicit replay response metadata. @@ -22,6 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Test-first doctoring for external-wait progress, root-cause analysis, realistic remediation feasibility, exact post-action verification, source-actionable pull-request classification, invalid-stack isolation, and read-only dependency leases in `docs/doctoring/hourly-opencode-nonblocking-progress-evidence.md`. +- Permanent fail-first exact-head workflow contracts and authoritative evidence in `docs/doctoring/exact-head-source-workflow-evidence.md`, including observed synthetic-merge checkout behavior, cross-platform source identity assertions, the rejected ignored Dependency Review ref-override experiment, corrective pull-request event-endpoint semantics, least-privilege boundaries, stack invalidation rules, rollback prohibition, and APA 7th GitHub references. +- A separate fail-closed hourly OpenCode maintenance workflow pinned to OpenCode 1.18.13 and `nvidia/deepseek-ai/deepseek-v4-pro`, using only the existing `NVIDIA_NIM_API_KEY` through OpenCode's `NVIDIA_API_KEY` provider variable while preserving the independent review agent and deterministic merge-disposition workflow. +- Exact-head workflow-run authorization doctoring evidence for the repository-token recursion boundary, before/after SHA snapshots, policy-path exclusion, time-of-check/time-of-use validation, least privilege, test-first regression evidence, and rollback in `docs/doctoring/github-token-exact-head-check-authorization-evidence.md`. +- Supply-chain doctoring evidence for checksum binding, exact archive-member and entry-type validation, private extraction, post-extraction file checks, test-first regression evidence, and rollback in `docs/doctoring/opencode-archive-extraction-evidence.md`. +- NVIDIA model-selection doctoring evidence for endpoint availability, deprecated-endpoint rejection, capability and context evidence, no-fallback semantics, test-first regression evidence, and replacement procedure in `docs/doctoring/nvidia-opencode-model-selection-evidence.md`. - Principal-scoped durable asynchronous ETL job intake and owner-scoped status resources, Flyway `etl_job_records` migration, deterministic replay/conflict coverage, and the explicit worker boundary in `docs/etl/durable-job-intake.md`. - Durable idempotency ledger migration, PostgreSQL transaction advisory-lock adapter, deterministic concurrency/rollback coverage, and the operator/client contract `docs/etl/idempotent-retries.md`. - ETL problem-details client and operator contract: `docs/api/problem-details.md`. @@ -127,8 +144,6 @@ Through code analysis, identified the platform as: - **Enterprise ETL and CDC Platform** - Microservices-based architecture using Spring Cloud - Real-time Change Data Capture using Debezium -- Data transformation pipelines with parallel processing -- JWT-based security with role-based access control - Event streaming via Apache Kafka - Service discovery with Netflix Eureka - Distributed tracing with Zipkin @@ -246,8 +261,4 @@ This changelog will be updated: - When documentation is significantly updated - For each release or milestone ---- - -**Changelog Version**: 1.0 -**Last Updated**: 2026-08-04 -**Maintained By**: Development Team \ No newline at end of file +--- \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1be2d6e0..7c07cdc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM maven:3.9.13-eclipse-temurin-25 AS build +FROM maven:3.9.13-eclipse-temurin-25@sha256:ade3c87ed2874c8b773ccb9b238cd66db8a7c56c77d99a1c825bf929f3afcb96 AS build WORKDIR /workspace @@ -18,7 +18,7 @@ RUN mvn -B -DskipTests -pl "${SERVICE}" -am package \ && mkdir -p /out \ && cp "${SERVICE}/target/${SERVICE}-"*.jar /out/app.jar -FROM eclipse-temurin:25-jre +FROM eclipse-temurin:25-jre@sha256:f19dbf0dc677ae28efed04b8b99d3123d6aaf2e6b3c9d35c09274dd8b5d53a4f WORKDIR /app COPY --from=build --chown=65532:65532 /out/app.jar /app/app.jar diff --git a/docs/doctoring/container-base-image-pinning-evidence.md b/docs/doctoring/container-base-image-pinning-evidence.md new file mode 100644 index 00000000..7280b5e6 --- /dev/null +++ b/docs/doctoring/container-base-image-pinning-evidence.md @@ -0,0 +1,62 @@ +# Container base-image pinning evidence + +## Purpose + +mightyETL container builds must not silently consume different registry bytes when a mutable image tag moves. Docker supports `FROM image:tag@sha256:` so a human-readable release tag can remain visible while the build resolves to one immutable content digest. This control reduces software-supply-chain drift and makes base-image changes reviewable as repository changes. + +This evidence is a build-input integrity control. It does **not** claim that a digest makes an image trustworthy, vulnerability-free, or permanently suitable. Vulnerability management and planned digest rotation remain separate duties. + +## Governing contract + +Every registry-backed `FROM` instruction in the repository `Dockerfile` must include a lowercase SHA-256 digest. References to an earlier local build-stage alias are exempt because they do not resolve through an external registry. + +The current reviewed references are: + +- build stage: `maven:3.9.13-eclipse-temurin-25@sha256:ade3c87ed2874c8b773ccb9b238cd66db8a7c56c77d99a1c825bf929f3afcb96` +- runtime stage: `eclipse-temurin:25-jre@sha256:f19dbf0dc677ae28efed04b8b99d3123d6aaf2e6b3c9d35c09274dd8b5d53a4f` + +The digest values were surfaced by the OpenSSF Scorecard remediation output associated with mightyETL's Security Scan. That historical scan executed against GitHub's synthetic pull-request merge revision, so it is used here only as remediation provenance for the digest-resolved references and is **not** accepted as literal-head security-gate evidence. + +## Red-green TDD evidence + +### RED + +Commit `b6efb1fc05c0161e82278c675ae7a631878b9302` added `ContainerImagePinningTest` without changing the mutable Dockerfile tags. Exact-head CI run `31238858595` checked out that literal SHA. The macOS job `93056263926` ran the Maven test suite and failed specifically with: + +`Dockerfile base image must use an immutable SHA-256 digest: maven:3.9.13-eclipse-temurin-25` + +The failure is intentionally retained in history as evidence that the contract detects the pre-existing mutable input. + +### GREEN implementation + +Commit `f8cdcf77b2a940a474d1b8080e3e9b6bfeacca4b` changed only the two external Dockerfile base references to the reviewed digest-qualified form. Exact-head CI run `31238960659` checked out that literal SHA; its macOS Maven test step completed successfully, including `ContainerImagePinningTest`. Full integrated exact-head gate acceptance is evaluated separately after all documentation commits so older-head evidence cannot be reused. + +## Enforcement design + +`ContainerImagePinningTest` parses every `FROM` instruction, tracks local stage aliases, and fails when any registry-backed image reference lacks exactly one `@sha256:` value followed by 64 lowercase hexadecimal characters. It also fails if the Dockerfile unexpectedly contains no external base image. The test is intentionally repository-level because the risk is configuration drift rather than Java runtime behavior. + +Keeping the tag alongside the digest is deliberate. Docker documents that the digest provides an immutable identifier while the tag preserves release intent and readability. A tag update, digest update, or both therefore becomes an explicit reviewed diff rather than an implicit registry-side change. + +## Update procedure + +When a Maven or Eclipse Temurin base image must be updated: + +1. Resolve the intended official image tag to its current registry digest using a trusted registry-aware Docker/BuildKit inspection path. +2. Review the upstream image release and security rationale; do not copy an untrusted third-party digest. +3. Update the readable tag and SHA-256 digest together when the release changes, or update only the digest when intentionally adopting a rebuilt image under the same reviewed tag. +4. Run the full test suite and container build verification on the exact candidate head. +5. Require exact-head Dependency Review, SBOM, SAST, security scanning, provenance/release checks, and independent review according to repository policy before integration. + +## Rollback + +If a newly pinned base image causes a regression, revert to the previously reviewed **digest-qualified** reference and rerun exact-head validation. Never remove the digest merely to make a build move again, because that would restore unreviewed registry mutability. + +## Standards and primary references + +Docker, Inc. (2026). *Dockerfile reference*. Docker Docs. https://docs.docker.com/reference/dockerfile/ + +Docker, Inc. (2026). *Building best practices*. Docker Docs. https://docs.docker.com/build/building/best-practices/ + +Docker, Inc. (2026). *docker image pull*. Docker Docs. https://docs.docker.com/reference/cli/docker/image/pull/ + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities (NIST SP 800-218).* https://doi.org/10.6028/NIST.SP.800-218 diff --git a/docs/doctoring/exact-head-source-workflow-evidence.md b/docs/doctoring/exact-head-source-workflow-evidence.md new file mode 100644 index 00000000..474dca88 --- /dev/null +++ b/docs/doctoring/exact-head-source-workflow-evidence.md @@ -0,0 +1,101 @@ +# Exact-head CI, SBOM, and Dependency Review source evidence + +## Incident: source-executing checks + +The `CI` and `SBOM (CycloneDX)` pull-request workflows previously relied on the default `actions/checkout` ref. GitHub defines `GITHUB_SHA` for a `pull_request` event as the last commit on the generated pull-request merge branch, and the default checkout therefore materializes that synthetic merge revision rather than the pull request's literal current head. + +Run `31175322160` demonstrated the mismatch on pull request #121. The macOS job checked out synthetic merge commit `95f543c80dbfc43796179c12d8ceda3196cb9eeb`, whose message merged source head `9bbd20b42967b8401776d9399cec6a5c24aa4512` into base `622e5e6c3d534f230c390f10e3832efadfc01825`. The checkout also retained its repository credential. Those results could describe merge-preview compatibility, but they were not direct execution evidence for the exact source head and could not satisfy mightyETL's expected-head policy. + +## Decision: source-executing checks + +Both source-executing workflows now bind checkout to: + +```yaml +ref: ${{ github.event.pull_request.head.sha || github.sha }} +persist-credentials: false +``` + +For `pull_request`, the expression selects the literal contributor head. For `push` and the existing manual test entrypoint, the pull-request payload is absent and the expression selects the event SHA. Each workflow immediately compares `git rev-parse HEAD` with the same expression and fails before toolchain setup when the materialized source does not match. + +The CI matrix performs this assertion on Ubuntu, macOS, and Windows. Self-hosted execution uses Bash on Unix and PowerShell on Windows but retains the identical expected SHA. SBOM generation applies the same boundary before Maven resolves the aggregate dependency graph. + +## Test-first evidence: source-executing checks + +Commit `9bbd20b42967b8401776d9399cec6a5c24aa4512` added only `ExactHeadWorkflowCheckoutTest`. CI run `31175322160` then failed exactly two new assertions while the established test surface otherwise ran: + +```text +ExactHeadWorkflowCheckoutTest.continuousIntegrationChecksOutAndAssertsTheExactSourceRevision +ExactHeadWorkflowCheckoutTest.sbomGenerationChecksOutAndAssertsTheExactSourceRevision +``` + +The failure log independently exposed the synthetic merge checkout and persisted credential. The production workflow changes were applied only after this RED evidence. + +The permanent source-execution contract requires: + +- the literal pull-request head expression in both workflows; +- checkout credential persistence disabled; +- an explicit post-checkout identity assertion; and +- no hard binding to `github.sha`, which denotes the merge revision on `pull_request` events. + +## Dependency Review event-endpoint contract + +Dependency Review does not execute contributor source. It asks GitHub's dependency-review service to evaluate the dependency delta represented by a pull-request event. The action's contract therefore differs from source-executing CI and SBOM checks. + +Fail-first commit `077340a62e267f3dfbe05099b137bec57c11a5ae` originally asserted that the workflow must pass the pull request's base and head SHAs through the action's `base-ref` and `head-ref` inputs. CI run `31177454329` failed that new assertion while the established workflow used only the pull-request event and `fail-on-severity`. + +A subsequent repair added those inputs. Review against the current upstream `actions/dependency-review-action` v5 documentation showed that premise to be incorrect: `base-ref` and `head-ref` are supported inputs, but they are **only used for event types other than `pull_request` and `pull_request_target`**. Supplying them to this `pull_request` workflow is therefore ignored and can create false confidence that an explicit binding exists when the action is actually using the event endpoints. + +The corrective TDD sequence preserves both findings without rewriting history: + +- `077340a62e267f3dfbe05099b137bec57c11a5ae` remains the original fail-first experiment; +- the intervening commits that added explicit `base-ref`/`head-ref` remain auditable as an incorrect repair; +- `cd706f235f9ddda4ee0d7244772453f2f5c934a5` changes the contract test first so ignored PR-event overrides are rejected; with the overrides still present, this is corrective RED evidence; +- `3358b40adaf52cc821e3fce923ecf5af49f77f7f` removes the ignored inputs and returns the workflow to the action's documented pull-request semantics. + +The permanent Dependency Review contract requires: + +- trigger through `pull_request` so the action obtains the comparison endpoints from the pull-request event; +- immutable pin `actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294` (v5.0.0); +- `fail-on-severity: high` retained; +- no `base-ref` or `head-ref` override on the pull-request workflow, because upstream documents those inputs for non-pull-request events only; and +- no reuse of a successful dependency-review run after either the pull-request head or base moves. + +This is an evidence correction, not a gate relaxation. The dependency review must still rerun for the current pull-request event after any endpoint change, and queued, pending, absent, skipped, neutral, cancelled, failed, or stale-event evidence is not accepted. + +## Authority boundary + +These controls change which exact revisions are measured or how their provenance is established; they do not convert checks into approval and do not weaken branch protection. + +- The workflows remain `pull_request` workflows with repository permission `contents: read` only. +- No repository, model, cloud, deployment, or signing secret is exposed to pull-request source. +- The CI/SBOM checkout credential is removed before Maven or project code executes. +- The CI/SBOM checkout ref affects the checked-out source tree, not the event or reviewer identity. +- Dependency Review derives its base/head comparison from the pull-request event according to the pinned action's documented contract; mightyETL verifies freshness by accepting only a run associated with the unchanged current pull request rather than by supplying ignored inputs. +- Organization SAST and Security Scan evidence remains independently required and must itself be proven against the exact source head before merge. +- A green generated-merge run from an older head, a predecessor base, a queued run, or an absent workflow is not accepted as exact-head evidence. + +This source-checkout pattern would be unsafe under a privileged `pull_request_target`, `workflow_run`, or comment-triggered workflow that exposes secrets or write authority to untrusted source. mightyETL does not use those privileged event shapes for these source-executing jobs. + +## Stack and review consequence + +Every change to the root stack head invalidates downstream ancestry and all older check, review, and approval evidence. After this repair passes its current exact head, each downstream branch must be advanced non-destructively to an auditable history containing the exact predecessor head, then rerun its own exact-head gates. No predecessor evidence transfers. + +For Dependency Review specifically, any base or head movement invalidates the previous dependency delta even when the dependency manifest itself appears unchanged. A fresh pull-request event run must complete for the unchanged current endpoints before merge evidence is accepted. + +## Operations and rollback + +Operators should inspect the checkout log and exact identity step whenever the event payload, checkout action, or trigger changes. A passing source-executing run must show the expected source SHA as the checked-out `HEAD` before Maven execution. + +For Dependency Review, operators should confirm that the run belongs to the current pull request after its latest base/head movement and that the workflow remains a `pull_request` workflow using the immutable action pin. Do not infer stronger evidence from `base-ref`/`head-ref` inputs on a pull-request event because upstream documents those inputs as unused for that event type. + +Rollback to implicit CI/SBOM source checkout is prohibited because it restores synthetic-merge-only execution evidence. Reintroducing ignored Dependency Review endpoint overrides is also prohibited because it restores misleading evidence. If the upstream action changes its event/ref contract, fail closed, review the pinned action and primary documentation, update tests and doctoring first, and rerun the current pull request. Do not substitute an older head, older base, generated merge revision, or manually asserted status. + +## References — APA 7th edition + +GitHub. (2026a). *Events that trigger workflows*. GitHub Docs. https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows + +GitHub. (2026b). *Securely using pull_request_target*. GitHub Docs. https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target + +GitHub. (2026c). *GITHUB_TOKEN*. GitHub Docs. https://docs.github.com/en/actions/concepts/security/github_token + +GitHub. (2026d). *Dependency review action* (v5.0.0, commit a1d282b36b6f3519aa1f3fc636f609c47dddb294) [GitHub Action]. GitHub. https://github.com/actions/dependency-review-action diff --git a/docs/doctoring/github-token-exact-head-check-authorization-evidence.md b/docs/doctoring/github-token-exact-head-check-authorization-evidence.md new file mode 100644 index 00000000..3082f3e5 --- /dev/null +++ b/docs/doctoring/github-token-exact-head-check-authorization-evidence.md @@ -0,0 +1,319 @@ +# GitHub-token publication and exact-head authorization evidence + +Reviewed on: **2026-08-09** + +## Decision + +The hourly development loop uses the repository-scoped `GITHUB_TOKEN`, but no model-executing +process receives remote repository write authority. Source analysis and local candidate creation, +branch publication, pull-request publication, workflow-run authorization, independent review, and +merge are separate authorities. + +The deployed contract is: + +```text +model execution and local commit + ≠ +deterministic branch publication + ≠ +draft pull-request publication + ≠ +workflow-run authorization + ≠ +independent review and merge +``` + +This separation addresses three concrete failure modes found during exact-head review: + +1. `pull-requests: write` on a model job could allow a model process to call pull-request lifecycle + endpoints even when its prompt prohibited that behavior. +2. model-scoped `contents: write` unnecessarily coupled repository execution to remote branch + mutation. +3. authorizing workflow runs by `head_sha` alone could authorize a run associated with another pull + request that referenced the same commit. + +The current design therefore keeps the OpenCode job read-only and transfers one checksum-bound local +candidate through deterministic jobs with narrowly separated write permissions. + +## Primary-source finding + +OpenCode 1.18.13 exposes two relevant execution paths. + +- `opencode github run` includes GitHub lifecycle behavior and can participate in pull-request + publication after model execution. +- `opencode run` is the plain non-interactive model runner and does not itself require pull-request + publication authority. + +mightyETL uses `opencode run --model ... --auto` with repository-read-only GitHub authority. The +model may inspect, edit, test, and commit one bounded candidate in the checked-out local Git +repository, but it cannot publish that commit to GitHub. Deterministic non-model jobs validate and +publish the exact candidate separately. + +## Authority topology + +```mermaid +flowchart TB + M[maintain-repository: read-only model execution] -->|checksum-bound candidate artifact| B[publish-agent-branch] + B -->|published exact branch head| P[publish-agent-pull-request] + P -->|PR number, head ref, exact SHA| A[authorize-exact-head-checks] + A --> C[required CI and security workflows] + C --> R[independent OpenCode / Noema / human review] + R --> D[expected-head merge disposition] +``` + +### Model job + +`maintain-repository` is the only job that checks out source or runs OpenCode. It has read-only GitHub authority: + +```text +actions: read +checks: read +contents: read +issues: read +pull-requests: read +security-events: read +statuses: read +``` + +The model can inspect GitHub state and can create commits only in the runner-local checkout. It has +no GitHub token permission that can update a branch, issue, pull request, workflow run, status, or +security result. It contains no `git push`. The prompt prohibition on remote pull-request mutation, +protected-branch pushes, approval, merge, and release remains defense in depth rather than the +primary authorization boundary. + +### Deterministic branch publisher + +`publish-agent-branch` is the sole `contents: write` holder. It has: + +```text +actions: read +contents: write +``` + +It never checks out repository source through `actions/checkout`, never receives `NVIDIA_API_KEY`, +and never runs OpenCode. It downloads only the candidate artifact emitted by the model job and +requires the artifact metadata and SHA-256 digest to match the exact upstream job output. + +Before a branch write, it verifies: + +- the protected `develop` head is unchanged from the model-job snapshot; +- for an existing pull request, the exact live branch and captured predecessor SHA are unchanged; +- for a new branch, the strict `automation/opencode-YYYYMMDDTHHMMSSZ-short-slug` namespace does not + already exist remotely; +- the imported bundle contains the expected exact candidate head; +- the candidate is a non-destructive descendant of the exact predecessor; +- the candidate has between 1 and 50 commits and contains no merge commit; +- at most 50 paths changed; +- no `.github/**` or `CODEOWNERS` path changed; and +- `git diff --check` succeeds. + +Only then does the job perform one non-forced branch push. It immediately reads the remote branch +back and requires the live SHA to equal the exact candidate head. A moved predecessor, changed +protected head, invalid artifact, unexpected path, destructive ancestry, ambiguous remote branch, +or mismatched post-push head fails closed. + +### Deterministic pull-request publisher + +`publish-agent-pull-request` is the sole `pull-requests: write` holder. It has: + +```text +contents: read +pull-requests: write +``` + +It never checks out or executes repository source and never receives `NVIDIA_API_KEY`. It accepts +only metadata from the successful deterministic branch publisher, rereads the live branch or +existing pull request, verifies the exact expected head and bounded path policy, and creates at most +one draft pull request when a new validated branch has no existing pull request. It contains no +review-approval or merge endpoint. + +### Exact-head run authorizer + +`authorize-exact-head-checks` is the sole `actions: write` holder. It has: + +```text +actions: write +contents: read +pull-requests: read +``` + +It never checks out source and never receives the model credential. Before authorizing a waiting +run, it requires: + +```text +run.event == pull_request +run.head_sha == expected_head +any(run.pull_requests; number == expected_pull_request_number) +live pull request head == expected_head +``` + +The `pull_requests` association is mandatory. A commit SHA is not a unique pull-request identity: +more than one pull request can reference the same commit. The job may authorize only +`action_required` or `waiting` workflow runs and contains no pull-request approval or merge +operation. + +The model-executing job receives none of those write permissions. No deterministic writer receives +the NVIDIA model credential, and neither the model job nor any deterministic scheduler job can +manufacture the independent non-author review required before merge. + +## Complete workflow materialization + +GitHub can materialize pull-request workflows asynchronously. The authorizer repeats bounded +discovery and authorizes newly visible `action_required` or `waiting` runs on every pass. It +succeeds only after this complete workflow-name set is associated with the exact pull request and +exact SHA: + +```text +CI +Dependency Review +SBOM (CycloneDX) +SAST Semgrep +Security Scan +``` + +Name presence proves only that a run was created. Every required run must still complete +successfully on acceptable exact-head evidence before merge. Queued, waiting, action-required, +skipped-required, cancelled, stale-head, synthetic-merge-only, absent, neutral-required, or failed +evidence remains non-passing. + +## Time-of-check/time-of-use controls + +The loop validates state at several boundaries: + +1. snapshot `develop`, open pull-request heads, and prior automation branches before model execution; +2. require `develop`, the open pull-request set, and automation-branch state to remain unchanged + after the model exits; +3. select at most one exact local candidate and bind its predecessor, branch, and candidate SHA into + metadata; +4. create a Git bundle plus SHA-256 digest and transfer only that bounded artifact to the branch + publisher; +5. re-read protected `develop` and the exact predecessor immediately before branch publication; +6. verify bundle integrity, ancestry, commit count, merge-free history, path count, policy-path + exclusion, and `git diff --check` before one non-forced push; +7. read the published branch back and require its live SHA to equal the candidate SHA; +8. re-read the branch or pull request before draft pull-request publication; +9. re-read the pull request before workflow-run discovery; +10. re-read the exact head on every discovery pass; and +11. re-read it once more before declaring workflow-run authorization complete. + +A moved head or base, multiple candidate, invalid namespace, changed policy path, artifact mismatch, +absent required workflow, or GitHub authorization rejection fails closed. + +## Test-first evidence + +`HourlyOpenCodeMaintenanceWorkflowTest` proves the executable authority boundary, including: + +- plain `opencode run`, not the GitHub lifecycle handler; +- `contents: read`, `issues: read`, and `pull-requests: read` on the model job; +- no `contents: write`, `pull-requests: write`, or `actions: write` on the model job; +- exactly one isolated branch publisher with `contents: write` and no model credential; +- exactly one non-checkout pull-request publisher with `pull-requests: write`; +- exactly one non-checkout authorizer with `actions: write`; +- no NVIDIA credential in any privileged deterministic writer; +- one strict publication candidate; +- draft-only deterministic pull-request publication; +- `.github/**` and `CODEOWNERS` exclusion; +- exact pull-request association for every workflow run; +- complete required-workflow materialization; and +- continued absence of review and merge endpoints. + +During the 2026-08-09 acquisition-evidence audit, this doctoring file was found to describe an older +authority topology in which the model job still held `contents: write` and pushed a branch itself. +That prose contradicted the executable workflow even though the executable least-privilege boundary +was already narrower. + +A fail-first contract was therefore added before changing this document. Exact-head commit +`b1afefd9a8264c4cf7f5c409f853abebfe70dc17` was checked out by CI run `31266495903`; macOS job +`93125370929` ran 317 tests and failed exactly the two new +`HourlyOpenCodeAuthorityDocumentationTest` methods that require current model-read-only and +separated-writer doctoring. The existing tests passed up to that intentional documentation +contract. Checks from that RED head do not transfer to a later head. + +The authoritative acceptance condition after this correction is a fresh exact-head CI run in which +those two tests and the complete project suite pass, plus the ordinary dependency, SBOM, SAST, +security, status, review-thread, and independent-approval gates for that unchanged head. + +## Residual risks, remediation feasibility, and controls + +### `contents: write` Scorecard finding + +GitHub Advanced Security currently reports the isolated `publish-agent-branch` job's job-scoped +`contents: write` permission. This is a real sensitive capability and remains visible rather than +being mislabeled as eliminated. + +Root-cause options were evaluated against the deployed architecture: + +1. **Remove `contents: write`.** Rejected for the current autonomous-development design because the + validated local commit would have no GitHub branch-persistence authority; this removes the + required function rather than reducing its write surface while preserving behavior. +2. **Move branch publication back into the model job.** Rejected because it expands the untrusted + model-execution authority and reverses the existing separation of duties. +3. **Introduce a PAT, new secret, or GitHub App solely to avoid the Scorecard signal.** Rejected + unless a separately reviewed credential actually exists and proves narrower endpoint scope, + lifetime, installation scope, auditability, and branch-protection behavior. No credential is + invented merely because a scanner dislikes a required permission. +4. **Keep one deterministic `contents: write` branch publisher with exact artifact, ancestry, + live-ref, path, commit-count, and post-write SHA validation.** Executable now and the narrowest + currently proven design that retains autonomous branch publication. + +The unresolved Scorecard thread therefore remains open while the permission exists. A green +aggregate Security Scan is not treated as literal-head proof if its relevant scanner checked a +synthetic merge. The separately leased organization scanner repair must integrate before +literal-head scanner/SARIF evidence can support final disposition. Documentation alone does not +resolve the scanner finding. + +### Other residual risks + +- The deterministic pull-request publisher necessarily has coarse `pull-requests: write` + permission. It has no checkout, model input, NVIDIA credential, or executable repository source, + and its script exposes only bounded draft publication or metadata validation. +- `actions: write` can authorize workflow execution. It is isolated in a non-checkout job, exact + pull-request association is mandatory, and workflow-path changes are refused from autonomous + candidate publication. +- Workflow-run authorization is an Actions control, not a successful check or pull-request + approval. +- A workflow or `CODEOWNERS` change always requires explicit human authorization under this + scheduler contract. +- Independent exact-head approval remains mandatory after every new commit; checks, statuses, + comments, reactions, author reviews, and textual acknowledgements do not substitute for it. + +## Failure behavior + +Any mismatch between the documented authority topology and the executable workflow is a failed +security-evidence contract, not a documentation-only cosmetic issue. The fix is to reconcile the +authoritative evidence to the narrower executable behavior or deliberately redesign the workflow +under a new fail-first contract. Do not make the executable workflow more permissive merely to make +an old document true. + +If GitHub changes `GITHUB_TOKEN` recursion, branch-update, review-request, or workflow-run approval +semantics, disable the affected autonomous path until the exact authority boundary is revalidated. +Do not restore pull-request or content write authority to the model job as a shortcut. + +## Rollback + +Rollback of this evidence correction is permitted only together with a reviewed replacement that +still describes the exact deployed authority topology. Do not roll back by moving branch +publication into the model job, by restoring model-scoped `contents: write` or `pull-requests: +write`, by accepting SHA-only workflow-run authorization, or by weakening exact-head, review, +branch-protection, and policy-path gates. + +A GitHub App or endpoint proxy may replace the deterministic branch publisher only after its +endpoint allowlist, actor identity, installation scope, token lifetime, audit log, protected-branch +behavior, and exact-head acceptance path are independently tested and documented. + +## References — APA 7th + +Anomaly. (2026). *GitHub handler (Version 1.18.13)* [Source code]. GitHub. +https://github.com/anomalyco/opencode/blob/v1.18.13/packages/opencode/src/cli/cmd/github.handler.ts + +Anomaly. (2026). *Run command (Version 1.18.13)* [Source code]. GitHub. +https://github.com/anomalyco/opencode/blob/v1.18.13/packages/opencode/src/cli/cmd/run.ts + +GitHub, Inc. (2026). *GITHUB_TOKEN*. GitHub Docs. +https://docs.github.com/en/actions/concepts/security/github_token + +GitHub, Inc. (2026). *REST API endpoints for workflow runs*. GitHub Docs. +https://docs.github.com/en/rest/actions/workflow-runs + +GitHub, Inc. (2026). *Security hardening for GitHub Actions*. GitHub Docs. +https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions diff --git a/docs/doctoring/hourly-opencode-nonblocking-progress-evidence.md b/docs/doctoring/hourly-opencode-nonblocking-progress-evidence.md new file mode 100644 index 00000000..7a5d34c4 --- /dev/null +++ b/docs/doctoring/hourly-opencode-nonblocking-progress-evidence.md @@ -0,0 +1,190 @@ +# Hourly OpenCode nonblocking progress evidence + +Reviewed on: **2026-08-08** + +## Incident + +The scheduled maintenance contract correctly refused to treat queued checks, missing approvals, +synthetic-merge-only scanner output, and separately leased repository state as passing evidence. +However, the first repair placed the nonblocking policy only in root `AGENTS.md`. The runtime +`PROMPT` passed directly to `opencode run` still selected work using these older conditions: + +- act on a pull request whenever a dependency-eligible development pull request exists; and +- develop a new product slice only when no development pull request exists. + +That made the behavior operationally incomplete. An open pull request blocked only by an external +review, approval, scanner identity, central dependency, quota, or runner condition could still be +mistaken for source-actionable work. The agent could repeatedly observe the same blocker without +performing root-cause analysis, testing whether a proposed remedy was executable in the current +run, or selecting an independent feasible product slice. + +This was an orchestration defect, not permission to weaken merge policy. The runtime contract must: + +1. keep every unavailable or stale gate non-passing; +2. identify the root cause rather than restating a symptom; +3. generate bounded remedies that address the identified cause; +4. test each remedy against live authority, protection, capacity, dependency, and ownership + constraints; +5. execute and verify the best safe option that is feasible now; +6. preserve an external or infeasible gate as blocked while immediately selecting the next safe + feasible non-overlapping action; and +7. never deepen an invalid stack or cross a repository-writer lease. + +## Root-cause analysis + +The immediate root cause was **instruction-path drift**. The repository guidance and the actual +scheduled model prompt no longer expressed the same work-selection state machine. The prompt used +open-PR existence as the branch condition, while the intended policy required source-actionability. + +The following are explicitly not root causes: + +- a queued check by itself; +- a repeated retry that reproduces the same state; +- an aggregate-green workflow whose relevant evidence uses a synthetic merge revision; +- an independent approval that does not yet exist; or +- a protected dependency that this repository is not authorized to mutate. + +Those are observations or external boundaries. A valid RCA traces them to the responsible source, +configuration, permission, quota, runner, provider, dependency, or policy boundary. + +## Decision + +Both root `AGENTS.md` and the runtime OpenCode prompt now require the same bounded loop: + +```text +observe exact current state +→ perform RCA +→ generate cause-addressing options +→ classify feasibility +→ execute the highest-impact safe feasible option +→ rerun the exact failing test or gate +→ verify the condition changed +→ otherwise keep that gate fail-closed and continue with the next feasible action +``` + +A remediation is classified as one of: + +- **executable now** — current repository authority, tooling, runtime, compute budget, dependency + state, path ownership, and writer lease permit the action; +- **requires an external actor** — a human reviewer, organization policy owner, protected central + repository, provider, entitlement administrator, or other authority must act; or +- **unsafe or infeasible** — the action would bypass protection, exceed bounded resources, race + another writer, mutate an unleased repository, deepen an invalid stack, or cannot be verified. + +The agent executes only the first category. The second and third categories do not become passing +evidence. They also do not halt unrelated work when a safe independent slice exists. + +A pull request is source-actionable only when its exact current head contains a valid +repository-local finding or failing source gate that this repository can repair. Review latency, +approval latency, queued checks, synthetic-only scanner identity, or an unintegrated read-only +central dependency remain blockers to merge but do not stop unrelated mightyETL development. + +When no open pull request is source-actionable, even if blocked pull requests remain open, the +scheduled agent may create exactly one bounded, non-conflicting candidate from the unchanged +protected `develop` head. The candidate must not depend on, retarget, rewrite, or overlap files +changed by a blocked or invalid stack. If no such slice exists, the run returns without a candidate +rather than manufacturing activity. + +The repository-writer lease is unchanged. `ContextualWisdomLab/.github`, `naruon`, +`contextual-orchestrator`, and every separately leased repository remain read-only to this loop. +The scheduler may inspect their exact integration state but may not mutate them, dispatch a +write-capable agent there, or post a mutation-trigger comment. + +## Why feasibility is an execution gate + +A technically plausible remedy is not necessarily executable. For example: + +- changing a protected central workflow from this repository violates the writer lease; +- manufacturing an approval violates independent-review policy; +- treating a synthetic merge scan as literal-head evidence does not repair source identity; +- repeatedly rerunning an unavailable provider does not address entitlement or quota; and +- opening another stacked branch can worsen an invalid dependency graph. + +The scheduler therefore checks feasibility before writing. This turns “find a solution” into a +bounded decision that can actually be executed and verified under current conditions. + +## Test-first evidence + +### Initial RED: nonblocking progress policy + +Commit `7ae7dcc2446c5a6bacd75a3602b83e8ea1f6f3f2` added +`HourlyOpenCodeProgressPolicyTest` before the nonblocking policy existed. Literal-head CI run +`31256917651` checked out that exact SHA. macOS job `93101583785` ran 313 tests and failed exactly +the two initial policy tests: + +- `continuesOneBoundedNonConflictingSliceWhenNoPullRequestIsSourceActionable`; +- `preservesReadOnlyDependencyLeasesWhileContinuingLocalWork`. + +The failure was caused by absent scheduler policy text, not compilation, unrelated production code, +or a stale checkout. That commit and run remain permanent fail-first evidence. + +### Current RED: RCA and realistic feasibility + +Commit `dba0af66481e7ba98fd16b19792feebd7fb71e1b` added two additional contract tests before the +runtime workflow prompt was changed. Literal-head CI run `31259348638` checked out that exact SHA. +macOS job `93107620623` ran 315 tests and failed exactly these two new tests: + +- `performsRootCauseAnalysisAndFeasibilityClassificationBeforeActing`; +- `executesTheBestFeasibleActionAndContinuesAfterExternalOnlyBlockers`. + +The existing 313 tests, including the earlier nonblocking and lease tests, passed. This proves the +new failure was specifically the missing runtime RCA and feasibility contract rather than a broad +repository regression. + +### Implementation and refactor + +Commit `ab3d5b28353289b8d2f3789ce06b63b506d37b8a` added the minimal runtime prompt changes required +by the current RED tests. During exact diff inspection, an unrelated `needs` identifier typo caused +by whole-file workflow publication was found before it could be accepted. Commit +`36c87525a95bfeedebd604c545af4d9427271984` restored the original exact-head authorization +expression. The corrective commit changes only that one identifier. + +Root guidance was then aligned with the runtime prompt so future agents see the same RCA, +feasibility, execution, verification, and fallback state machine. Checks from every predecessor +head are stale. The final exact current head must complete its own CI, dependency, SBOM, SAST, +security, commit-status, review-thread, and independent-approval gates before merge. + +## Safety properties + +- External-only blockers remain not passing. +- RCA must identify a responsible boundary rather than relabel a symptom. +- Proposed remedies are tested against current authority and operational constraints before use. +- Only a safe option classified executable now may be performed. +- The exact failing test or gate is rerun after remediation. +- An external or infeasible preferred option does not stop the next independent feasible action. +- No approval, review, check, or scanner evidence is synthesized. +- No protected branch is written directly. +- No invalid downstream stack boundary is deepened. +- At most one independently reviewable candidate may be produced per run. +- Candidate paths must be disjoint from blocked and invalid stack paths. +- Central and separately leased repositories remain read-only. +- The existing NVIDIA NIM and review-agent credential contracts are unchanged. + +## Failure behavior + +If the scheduler cannot trace a blocker to evidence, it must not guess at a destructive remedy. If +an option requires authority unavailable to the current run, that option remains external rather +than being retried as though it were executable. If the preferred option is infeasible, the run +continues to the next safe feasible non-overlapping action. If no safe action exists, it performs +read-only analysis and emits no candidate. + +If an agent cannot prove that a proposed slice is independent of every blocked or invalid stack +item, it must produce no candidate. If any relevant head, base, open-PR set, or automation branch +changes during the run, deterministic publication continues to fail closed. If repository guidance +conflicts with the workflow permission boundary, the narrower no-write and no-merge controls win. + +## Rollback + +Rollback removes the RCA and feasibility prompt, aligned repository guidance, and regression tests +together only after replacing them with a stricter reviewed mechanism that preserves all of these +properties: unavailable gates are not passing; remedies address evidenced causes; feasibility is +checked before execution; exact verification follows action; and external latency does not halt +unrelated bounded work. Do not roll back by allowing stack deepening, central-repository mutation, +direct pull-request lifecycle operations, or stale check reuse. + +## References + +GitHub. (n.d.). *Workflow syntax for GitHub Actions*. GitHub Docs. Retrieved August 8, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax + +OpenCode. (2026). *Intro*. https://opencode.ai/docs diff --git a/docs/doctoring/nvidia-opencode-model-selection-evidence.md b/docs/doctoring/nvidia-opencode-model-selection-evidence.md new file mode 100644 index 00000000..dac82bfa --- /dev/null +++ b/docs/doctoring/nvidia-opencode-model-selection-evidence.md @@ -0,0 +1,57 @@ +# NVIDIA OpenCode model selection evidence + +## Scope + +This evidence note governs the hosted NVIDIA model identifier used by `.github/workflows/hourly-opencode-maintenance.yml`. It does not change the existing review agent, its provider, its credential names, or its approval authority. It also does not introduce a non-NVIDIA credential or model fallback. + +## Problem statement + +The workflow originally selected `nvidia/qwen/qwen3-coder-480b-a35b-instruct`. NVIDIA's current model catalog marks that model's free endpoint as deprecated. The partner endpoint remains available, but a repository workflow that relies only on `NVIDIA_NIM_API_KEY` must not assume a separately contracted partner endpoint. A deprecated free endpoint is therefore an operational reliability defect: the scheduler may be syntactically correct yet fail before it can inspect or improve the repository. + +## Decision + +Select `nvidia/deepseek-ai/deepseek-v4-pro` for the scheduled OpenCode development agent. + +NVIDIA's current primary documentation identifies `deepseek-ai/deepseek-v4-pro` as: + +- available through a free endpoint; +- suitable for coding, agentic AI, tool use, software engineering, and enterprise assistants; +- capable of structured output and function or tool calling; +- able to accept up to one million tokens of context; +- licensed for commercial and non-commercial use under the NVIDIA Open Model Agreement and the model's MIT terms. + +The NVIDIA model card reports stronger maximum-reasoning results than DeepSeek V4 Flash on the listed software-engineering and terminal-agent benchmarks, including SWE Verified, SWE Pro, SWE Multilingual, and Terminal Bench 2.0. mightyETL does not claim those benchmark values as its own performance and does not claim that OpenCode automatically selects NVIDIA's maximum-reasoning mode. The comparison is used only as current primary-source evidence that the Pro endpoint is a defensible high-capability free model for repository-scale agentic coding. + +## Cost and reliability boundary + +The selected endpoint is currently marked free by NVIDIA. That status is operational metadata, not a permanent contractual guarantee. The workflow therefore pins one explicit model identifier and its contract test rejects the known deprecated Qwen3 Coder identifier. It does not silently route to a paid partner endpoint or another provider. + +A future endpoint deprecation must be handled through the same test-first process: + +1. confirm status in NVIDIA's current primary model catalog and API reference; +2. add or update a failing contract test for the replacement identifier; +3. select a currently available NVIDIA free endpoint suited to coding and tool use; +4. update this evidence note, operations documentation, design, implementation plan, and `CHANGELOG.md`; +5. require exact-head CI, security checks, and independent approval before merge. + +The scheduled agent intentionally has no automatic model fallback. Running a second model after a partially completed agent session could create non-deterministic workspace state, duplicate branches, or conflicting pull requests. A model rejection therefore fails the run visibly and leaves repository state for the next reviewed maintenance change. + +## Test-first evidence + +`HourlyOpenCodeMaintenanceWorkflowTest.usesCurrentFreeAgenticCodingModel` was committed before the workflow model was changed. On the pull-request CI merge ref for test-only commit `42eb7d7ac8bc3912e3a50f98b427b712f78b2b9b`, GitHub Actions run `30964719079` reported 286 tests, one failure, zero errors, and zero skipped project tests on Ubuntu. The sole failure was the new model-availability contract because the workflow still contained the deprecated Qwen3 Coder identifier. + +The production workflow then replaced only the model identifier with `nvidia/deepseek-ai/deepseek-v4-pro`. The NVIDIA credential alias, OpenCode installation, permissions, branch authority, review-agent boundary, and merge protections remain unchanged. + +This note records design evidence rather than exact-head completion. Any later commit makes earlier CI and review evidence stale. + +## Operational response + +If NVIDIA rejects the model identifier, reports endpoint deprecation, or removes free access, do not add GitHub Copilot, Anthropic, OpenAI, or a partner-endpoint credential as an emergency fallback. Disable the scheduled workflow if necessary, preserve any open feature branch or pull request, and prepare a bounded reviewed model-selection change using current NVIDIA primary documentation. + +## References + +NVIDIA Corporation. (2026). *DeepSeek V4 Pro*. NVIDIA NIM API catalog. https://build.nvidia.com/deepseek-ai/deepseek-v4-pro + +NVIDIA Corporation. (2026). *DeepSeek AI / DeepSeek V4 Pro*. NVIDIA NIM API reference. https://docs.api.nvidia.com/nim/reference/deepseek-ai-deepseek-v4-pro + +NVIDIA Corporation. (2026). *Qwen3-Coder-480B-A35B-Instruct*. NVIDIA NIM API catalog. https://build.nvidia.com/qwen/qwen3-coder-480b-a35b-instruct diff --git a/docs/doctoring/opencode-archive-extraction-evidence.md b/docs/doctoring/opencode-archive-extraction-evidence.md new file mode 100644 index 00000000..74d27903 --- /dev/null +++ b/docs/doctoring/opencode-archive-extraction-evidence.md @@ -0,0 +1,56 @@ +# OpenCode archive extraction evidence + +## Scope + +This evidence note covers only the installation boundary in `.github/workflows/hourly-opencode-maintenance.yml`. It does not authorize pull-request approval, merge, protected-branch writes, release publication, or changes to the independent review agent. + +The scheduled workflow downloads the immutable OpenCode `v1.18.13` Linux x64 release archive. GitHub marks that release immutable, and the workflow pins the Linux x64 asset's SHA-256. Checksum verification binds downloaded bytes to the reviewed asset, but it does not independently constrain the filesystem shape or entry type that an archive extractor would process. This note records the additional fail-closed archive-member, pre-extraction entry-type, extraction-directory, and post-extraction file controls. + +## Threat statement + +Archive extraction is a filesystem write operation. Unexpected archive members can broaden that write beyond the intended executable. Symbolic links, hard links, directories, device nodes, and other special entries can change the meaning or destination of extracted paths. GNU tar's security guidance therefore treats archive member names, extraction location, ownership, permissions, links, and overwrite behavior as security-relevant controls. + +For this immutable OpenCode release, upstream publishing source and release packaging identify a single root executable named `opencode`. mightyETL intentionally does not generalize that shape into a reusable archive installer. Any changed member count, name, or entry type is a supply-chain review event and fails the job. + +## Evidence chain + +1. GitHub marks OpenCode release `v1.18.13` immutable and identifies asset `501285078` as `opencode-linux-x64.tar.gz`. +2. OpenCode's upstream release process builds the Linux x64 archive and computes release digests. +3. The mightyETL workflow pins release `v1.18.13` and SHA-256 `8d500b20fed2d26e537e221895b1a575476571b4f0089bb29fb13eeb8eb9e937`. +4. The workflow validates the SHA-256 before listing or extracting the archive. +5. The workflow lists the verified archive and requires exactly one member named `opencode`. +6. With `LC_ALL=C`, the workflow obtains GNU tar's verbose metadata for that sole member and requires the first type character to be `-`, denoting a regular-file archive entry. Link, directory, and special-file entries are rejected before extraction. +7. The workflow recreates a private mode-`0700` installation directory and enables overwrite refusal. +8. Extraction does not restore archived ownership or permissions. +9. The extracted `opencode` path must be a regular file and must not be a symbolic link before executable mode is applied. +10. The executable must report exactly version `1.18.13` before its directory is added to `GITHUB_PATH`. + +These controls are deliberately cumulative. A checksum mismatch, unexpected member name or count, non-regular archive entry, extraction failure, missing file, non-regular extracted file, symbolic link, or version mismatch terminates installation before OpenCode runs. + +## Test-first evidence + +`HourlyOpenCodeArchiveValidationTest` was first added before the workflow member check. On the pull-request CI merge ref for test-only commit `751eedb852eca1165a5b936296255fc608494dad`, the Maven reactor reported 284 tests, one failure, and zero errors. The sole failure was `validatesOneExpectedArchiveMemberBeforeExtraction`, demonstrating that the prior checksum-only workflow did not satisfy the member-shape contract. + +After exact member validation was added, the test was extended before production code to require a regular-file archive entry. On the pull-request CI merge ref for test-only commit `7b82a40b12c46aed869aeec7b387a161a7b33896`, GitHub Actions run `30964191079` reported 285 tests, one failure, zero errors, and zero skipped project tests on Ubuntu. The sole failure was `validatesRegularFileEntryTypeBeforeExtraction`, demonstrating that name and count validation alone did not reject hard-link or other non-regular archive entries before extraction. + +The production workflow then added locale-stable verbose metadata inspection and a regular-entry type check before its existing extraction command. Exact member validation, a fresh private directory, overwrite refusal, disabled ownership and archived-permission restoration, and post-extraction regular non-symbolic-link checks remain defense in depth. The tests are deterministic cross-platform repository contracts; the actual scheduled installer executes only on the declared Ubuntu runner. + +This document is design and verification evidence, not a substitute for exact-head CI, security checks, independent review, or branch protection. Any later commit makes earlier exact-head evidence stale. + +## Operational response + +Treat any archive checksum, member-set, archive-entry type, extracted-file type, or version mismatch as a supply-chain incident. Do not broaden the member allowlist, permit link or special-file entries, remove post-extraction file checks, disable overwrite refusal, or change the checksum merely to restore a green workflow. Compare the exact immutable upstream release record, release-asset metadata, and upstream publishing source before proposing a reviewed pin change. + +Rollback consists of disabling the scheduled workflow or reverting the workflow, tests, operations documentation, design, implementation plan, doctoring evidence, and CHANGELOG entry through an independently reviewed pull request. Rollback must not rename or remove `NVIDIA_NIM_API_KEY` when another approved workflow also uses it, and must not alter the review-agent credential scheme. + +## References + +Anomaly. (2026). *OpenCode release publishing script* [Source code]. GitHub. https://github.com/anomalyco/opencode/blob/v1.18.13/packages/opencode/script/publish.ts + +Anomaly. (2026). *OpenCode release v1.18.13* [Software release]. GitHub. https://github.com/anomalyco/opencode/releases/tag/v1.18.13 + +Free Software Foundation. (2023). *GNU tar 1.35: Security*. https://www.gnu.org/software/tar/manual/html_section/Security.html + +GitHub, Inc. (2026). *OpenCode v1.18.13 Linux x64 release asset metadata* [JSON metadata]. GitHub REST API. https://api.github.com/repos/anomalyco/opencode/releases/assets/501285078 + +GitHub, Inc. (2026). *Security hardening for GitHub Actions*. GitHub Docs. https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions diff --git a/docs/hourly-pr-disposition.md b/docs/hourly-pr-disposition.md index 4c8964be..27a9c561 100644 --- a/docs/hourly-pr-disposition.md +++ b/docs/hourly-pr-disposition.md @@ -13,13 +13,20 @@ A pull request is merged only when all of the following hold: 3. No `do-not-merge`, `manual-merge`, `security-review`, or `breaking-change` label is present. 4. Workflow changes are excluded unless a maintainer explicitly applies `automerge-workflow`. 5. For each reviewer, the most recent decisive review (`APPROVED` or `CHANGES_REQUESTED`) is evaluated; comment-only reviews cannot erase an outstanding change request. -6. Every current, non-outdated review thread is resolved. -7. Every named required CI, dependency, SBOM, SAST, and security check has completed with `success`; a skipped required check is not sufficient. -8. No other reported check has failed or remains pending, commit status contexts are successful, and GitHub reports the pull request as cleanly mergeable. -9. The merge request includes the expected head SHA, preventing a time-of-check/time-of-use merge after the branch moves. +6. At least one reviewer other than the pull-request author has approved the exact current head SHA. An approval attached to an older commit is stale and cannot authorize a newer head. +7. Every current, non-outdated review thread is resolved. +8. Every named required CI, dependency, SBOM, SAST, and security check has completed with `success`; a skipped required check is not sufficient. +9. No other reported check has failed or remains pending, commit status contexts are successful, and GitHub reports the pull request as cleanly mergeable. +10. The merge request includes the expected head SHA, preventing a time-of-check/time-of-use merge after the branch moves. Eligible pull requests are squash-merged. GitHub branch protection remains authoritative and can still reject a merge. A rejection is recorded for that pull request without aborting disposition of the remaining queue. +## Review evidence + +The approval gate is intentionally stricter than merely checking that nobody requested changes. The workflow groups decisive reviews by reviewer, retains each reviewer's latest decisive state, blocks any outstanding `CHANGES_REQUESTED` state, and then requires a non-author `APPROVED` review whose `commit_id` exactly equals the current pull-request head SHA. + +Pushing another commit invalidates the unattended-merge evidence until an independent reviewer approves that new exact head. Comment-only reviews and successful status contexts do not substitute for approval. + ## Security properties - The scheduled workflow runs from the protected default branch, not from untrusted pull-request code. @@ -27,6 +34,7 @@ Eligible pull requests are squash-merged. GitHub branch protection remains autho - It uses the repository-scoped `GITHUB_TOKEN` with only `contents`, `pull-requests`, `checks`, and `statuses` permissions. - External forks and untrusted authors are never merged unattended. - Changes to workflow files require a separate explicit label and therefore remain manual by default. +- Independent approval must be anchored to the exact current head, preventing stale review evidence from authorizing later code. - GraphQL review-thread pagination prevents unresolved comments beyond the first page from being ignored. - The expected head SHA prevents a branch update from being merged under stale check results. diff --git a/docs/operations/hourly-opencode-maintenance.md b/docs/operations/hourly-opencode-maintenance.md new file mode 100644 index 00000000..1fd4ba9b --- /dev/null +++ b/docs/operations/hourly-opencode-maintenance.md @@ -0,0 +1,212 @@ +# Hourly OpenCode maintenance + +## Purpose + +`.github/workflows/hourly-opencode-maintenance.yml` runs at minute 43 of every UTC hour. It uses a checksum-pinned OpenCode 1.18.13 executable and the existing `NVIDIA_NIM_API_KEY` repository secret to repair one dependency-eligible development branch or prepare one bounded buyer-visible improvement. + +The workflow is not a reviewer or merger. Independent review, required checks, branch protection, the central CWL review workflows, and deterministic expected-head merge disposition remain authoritative. + +## Credential and model boundary + +The only model secret referenced by the workflow is: + +```text +NVIDIA_NIM_API_KEY +``` + +It is mapped only inside the model-execution step to: + +```text +NVIDIA_API_KEY +``` + +The selected model is `nvidia/deepseek-ai/deepseek-v4-pro`. There is no GitHub Copilot credential, `COPILOT_GITHUB_TOKEN`, Anthropic or OpenAI key, partner-only endpoint, or automatic provider fallback. A missing NVIDIA credential fails before model execution. + +The workflow invokes the plain non-interactive command: + +```text +opencode run --model "${MODEL}" --auto +``` + +It deliberately does not invoke `opencode github run`. OpenCode's GitHub schedule handler can create a pull request itself, which would require giving the model process coarse `pull-requests: write` authority. Plain `opencode run` lets the model edit, test, commit, and push one branch while deterministic non-model jobs own publication and workflow-run authorization. + +## Immutable OpenCode installation + +| Control | Value | +| --- | --- | +| Schedule | `43 * * * *` | +| OpenCode release | `v1.18.13` | +| Release asset | `opencode-linux-x64.tar.gz` | +| SHA-256 | `8d500b20fed2d26e537e221895b1a575476571b4f0089bb29fb13eeb8eb9e937` | +| Archive shape | exactly one regular-file member named `opencode` | +| Checkout | full-SHA-pinned `actions/checkout` | +| OpenCode timeout | `TERM` after 45 minutes; `KILL` after 30 seconds | +| Job timeout | 50 minutes | +| Overlap | serialized; an active run is not cancelled | + +The installer verifies the immutable archive checksum, member count, member name, GNU tar regular-file type, private mode-`0700` extraction directory, overwrite refusal, non-symbolic-link output, and exact executable version before running OpenCode. Checkout keeps `persist-credentials: false`. + +## Three-job authority separation + +```mermaid +flowchart LR + A[maintain-repository
model + checked-out source] -->|candidate JSON only| B[publish-agent-pull-request
no checkout, no model secret] + B -->|PR number + exact SHA| C[authorize-exact-head-checks
no checkout, no model secret] + C --> D[CI and security checks] + D --> E[independent review and merge disposition] +``` + +### `maintain-repository` + +This is the only job that executes checked-out repository code and OpenCode. Its pull-request permission is read-only: + +```text +actions: read +checks: read +contents: write +issues: read +pull-requests: read +security-events: read +statuses: read +``` + +`contents: write` permits one feature-branch push. `issues: read` permits issue and roadmap inspection only; the model-executing job has no issue mutation authority. Neither permission grants pull-request review or merge endpoints. The model prompt additionally forbids direct pull-request creation, update, approval, closure, or merge, but the permission map—not the prompt—is the primary authorization boundary. + +Before OpenCode starts, the job snapshots: + +- the `develop` head; +- every same-repository open pull request targeting `develop` and its exact head; +- every existing `automation/opencode-*` branch and its exact head. + +After OpenCode exits, the job requires `develop` to remain unchanged and selects at most one candidate: + +- one existing pull request whose head moved, retaining both the pre-agent and post-agent exact heads; or +- one new or advanced branch matching `automation/opencode-YYYYMMDDTHHMMSSZ-short-slug`. + +Multiple candidates fail closed. No candidate is represented as a no-op, not as successful product development. + +### `publish-agent-pull-request` + +This job has `contents: read` and the workflow's sole `pull-requests: write` grant. It never checks out or executes repository code and never receives `NVIDIA_API_KEY`. + +For an existing pull request, it re-reads and validates repository, base branch, head branch, state, and exact SHA. It then compares the captured pre-agent head with the post-agent head and requires all of the following: + +- the post-agent head is a non-destructive descendant with at least one new commit and no commits behind the captured head; +- no more than 50 files changed during the agent update; +- the agent-introduced range contains no `.github/**` or `CODEOWNERS` change. + +This range-specific comparison prevents an already-open pull request from bypassing the same policy-file boundary applied to a newly created automation branch. Policy files that existed before the agent run do not authorize the model to modify them during that run. + +For a new branch, the publisher requires: + +- the strict `automation/opencode-*` namespace; +- the live branch SHA to equal the candidate SHA; +- at least one commit ahead of `develop`; +- no more than 50 changed files; +- no `.github/**` or `CODEOWNERS` change; +- a same-repository branch and `develop` base. + +It then creates one draft pull request through a fixed script-generated payload. Commit text may supply the title, but no untrusted source is executed. The job contains no review submission or merge endpoint. + +### `authorize-exact-head-checks` + +This job has `actions: write`, `contents: read`, and `pull-requests: read`. It never checks out repository code and never receives the model credential. Its only write operation is approval of GitHub Actions workflow runs that GitHub has placed in `action_required` or `waiting` state. + +Every eligible run must satisfy all of the following: + +1. event is `pull_request`; +2. `head_sha` equals the publisher's exact SHA; +3. the run's `pull_requests` association contains the exact pull-request number; +4. the pull request still has the expected repository, base, head branch, and SHA; +5. the pull request does not change `.github/**` or `CODEOWNERS`. + +The pull-request association check matters because two pull requests can reference the same commit SHA. SHA-only filtering could authorize another pull request's waiting workflow. + +The job repeatedly discovers and authorizes runs until all of the following names materialize or the bounded wait expires: + +```text +CI +Dependency Review +SBOM (CycloneDX) +SAST Semgrep +Security Scan +``` + +Run authorization starts validation only. It does not make a check successful, approve the pull request, or permit merge. + +## Agent development contract + +When an eligible pull request exists, OpenCode may update only that same-repository head branch. When none exists, it may create exactly one strict `automation/opencode-*` branch. It must work test-first, preserve 100% configured production statement and branch coverage, add beginner-readable public documentation, update `CHANGELOG.md`, use descriptive multi-word `snake_case` database names, and record current primary standards or peer-reviewed evidence in APA 7th form where material. + +The model must not: + +- mutate pull-request lifecycle state; +- push to `develop` or `main`; +- bypass checks, reviews, security gates, or branch protection; +- modify the existing review agent or its credential names; +- inspect or disclose secret values; +- modify `.github/**` or `CODEOWNERS` without a specifically authorized automation-maintenance issue; +- publish a release. + +## Failure behavior + +| Failure | Result | +| --- | --- | +| NVIDIA credential missing | fail before model execution | +| OpenCode archive or checksum mismatch | fail before extraction or execution | +| protected `develop` head moves during the run | fail as indeterminate publication evidence | +| more than one candidate branch or PR changes | fail as ambiguous model output | +| updated PR head is not a non-destructive descendant of its captured head | refuse publication | +| candidate branch is not ahead of `develop` | refuse publication | +| agent-introduced range exceeds 50 files | refuse publication | +| candidate changes `.github/**` or `CODEOWNERS` | require explicit human handling | +| live PR or branch SHA differs from the captured SHA | fail closed | +| workflow run lacks exact PR association | exclude it from authorization | +| required workflow name never materializes | fail and list missing names | +| exact PR head moves during discovery | fail and require re-evaluation | +| any check or independent review fails | leave the PR unmerged | + +A failed OpenCode step is preserved as a failed job after candidate evidence is captured. A deterministic publisher may still expose a valid partial branch as a draft for review, but the workflow never calls that a successful maintenance run. + +## Rollback + +Disable **Hourly OpenCode maintenance** to stop the schedule immediately. Permanent rollback must revert the workflow, contract tests, this operations document, doctoring evidence, design and plan records, and corresponding changelog material through an independently reviewed pull request. + +Do not restore `pull-requests: write` to the model job. Do not remove exact pull-request association from workflow-run authorization. Do not remove the pre-agent versus post-agent range check from updated pull requests. A replacement GitHub App or token broker requires separate evidence for endpoint-level capability, actor identity, secret lifecycle, exact-head binding, and independent review. + +## Verification checklist + +Before merge, verify on the exact head: + +- Ubuntu, macOS, and Windows CI succeed; +- dependency review, SBOM, Semgrep, Trivy, OSV, Scorecard, and required security gates succeed; +- all current review threads are resolved; +- a non-author approval is anchored to the exact current SHA; +- only `NVIDIA_NIM_API_KEY` is referenced as a model secret; +- the immutable OpenCode archive and action pins remain unchanged; +- the model job has `pull-requests: read`, not write; +- the model job has `issues: read`, not write; +- the publisher is the only holder of `pull-requests: write` and performs no checkout; +- updated existing pull requests retain their captured pre-agent head and reject destructive ancestry, more than 50 agent-introduced files, `.github/**`, and `CODEOWNERS` changes; +- new branches reject more than 50 files, `.github/**`, and `CODEOWNERS` changes; +- the authorizer is the only holder of `actions: write` and performs no checkout; +- every authorized run is associated with the exact pull-request number and SHA; +- the workflow contains no pull-request review or merge operation. + +## References — APA 7th + +Anomaly. (2026). *GitHub handler (Version 1.18.13)* [Source code]. GitHub. https://github.com/anomalyco/opencode/blob/v1.18.13/packages/opencode/src/cli/cmd/github.handler.ts + +Anomaly. (2026). *Run command (Version 1.18.13)* [Source code]. GitHub. https://github.com/anomalyco/opencode/blob/v1.18.13/packages/opencode/src/cli/cmd/run.ts + +Anomaly. (2026). *OpenCode release v1.18.13* [Software release]. GitHub. https://github.com/anomalyco/opencode/releases/tag/v1.18.13 + +Free Software Foundation. (2023). *GNU tar 1.35: Security*. https://www.gnu.org/software/tar/manual/html_section/Security.html + +GitHub, Inc. (2026). *GITHUB_TOKEN*. GitHub Docs. https://docs.github.com/en/actions/concepts/security/github_token + +GitHub, Inc. (2026). *REST API endpoints for workflow runs*. GitHub Docs. https://docs.github.com/en/rest/actions/workflow-runs + +GitHub, Inc. (2026). *Security hardening for GitHub Actions*. GitHub Docs. https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions + +NVIDIA Corporation. (2026). *DeepSeek V4 Pro*. NVIDIA NIM API catalog. https://build.nvidia.com/deepseek-ai/deepseek-v4-pro diff --git a/docs/superpowers/plans/2026-08-04-hourly-opencode-maintenance-plan.md b/docs/superpowers/plans/2026-08-04-hourly-opencode-maintenance-plan.md new file mode 100644 index 00000000..f3f17985 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-hourly-opencode-maintenance-plan.md @@ -0,0 +1,128 @@ +# Hourly OpenCode Maintenance Agent Implementation Plan + +> **Execution rule:** implement each authority boundary test-first, then verify the final exact head through repository CI, security, and independent review. + +**Goal:** Run one bounded NVIDIA NIM-backed development loop every hour while ensuring model execution cannot create, approve, close, or merge pull requests and cannot authorize GitHub Actions runs. + +**Architecture:** Three jobs separate model execution, deterministic draft-PR publication, and exact-head workflow-run authorization. Independent OpenCode/Noema review and expected-head merge disposition remain outside all three jobs. + +## Global constraints + +- Use only `${{ secrets.NVIDIA_NIM_API_KEY }}` as the model secret. +- Do not introduce `COPILOT_GITHUB_TOKEN` or alter an existing review-agent secret. +- Invoke plain `opencode run`, not the GitHub lifecycle handler. +- Give the model job `pull-requests: read`, never write. +- Give exactly one non-checkout publisher `pull-requests: write`. +- Give exactly one non-checkout authorizer `actions: write`. +- Bind every workflow-run authorization to both exact SHA and exact PR number. +- Keep all `.github/**` and `CODEOWNERS` changes outside automatic publication and authorization. +- Preserve immutable OpenCode installation, branch protection, 100% configured production statement/branch coverage, public docstrings, APA 7th doctoring, and `CHANGELOG.md` maintenance. + +--- + +## Task 1 — Test the authority boundaries before production changes + +**File:** `etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeMaintenanceWorkflowTest.java` + +- [x] Require hourly serialized execution and bounded TERM/KILL handling. +- [x] Require the immutable OpenCode 1.18.13 archive, SHA-256, regular-file member, private extraction, and exact version. +- [x] Require `NVIDIA_NIM_API_KEY` as the sole model secret and `nvidia/deepseek-ai/deepseek-v4-pro` as the selected model. +- [x] Require plain `opencode run --model "${MODEL}" --auto` and reject `opencode github run`. +- [x] Require `pull-requests: read` on `maintain-repository`. +- [x] Require one non-checkout publisher with the workflow's sole `pull-requests: write` grant. +- [x] Require one non-checkout authorizer with the workflow's sole `actions: write` grant. +- [x] Require one strict existing-PR or `automation/opencode-*` candidate. +- [x] Require draft publication, policy-path exclusion, and a branch ahead of `develop`. +- [x] Require workflow-run association with both PR number and exact SHA. +- [x] Require complete CI, Dependency Review, SBOM, Semgrep, and Security Scan materialization. + +The test-only head intentionally made the preceding workflow fail before implementation. + +## Task 2 — Implement the model job without PR write authority + +**File:** `.github/workflows/hourly-opencode-maintenance.yml` + +- [x] Run at `43 * * * *` only from protected default-branch source. +- [x] Keep top-level `contents: read`. +- [x] Set model-job permissions to Actions/checks/PR/security/status read, issues write, and contents write. +- [x] Install OpenCode from the pinned immutable archive. +- [x] Configure the removable repository-local `gh auth git-credential` helper and bot author. +- [x] Pipe the bounded prompt into plain `opencode run`. +- [x] Permit an existing eligible branch update or exactly one strict automation branch. +- [x] Preserve the model step's failure after capturing any reviewable branch evidence. + +## Task 3 — Detect exactly one candidate + +**File:** `.github/workflows/hourly-opencode-maintenance.yml` + +- [x] Snapshot `develop`, current direct PR heads, and prior automation branch heads before model execution. +- [x] Require `develop` to remain unchanged afterward. +- [x] Detect one changed existing PR head or one strict automation branch. +- [x] Exclude an automation branch already represented by the changed PR. +- [x] Fail on multiple candidates. +- [x] Emit compact candidate JSON as a job output. + +## Task 4 — Publish deterministically without executing source + +**File:** `.github/workflows/hourly-opencode-maintenance.yml` + +- [x] Add `publish-agent-pull-request` with `contents: read` and `pull-requests: write` only. +- [x] Do not checkout source or pass the NVIDIA credential. +- [x] Re-read existing PR repository, state, base, branch, and exact SHA. +- [x] For a new branch, require the strict namespace, live exact SHA, positive `ahead_by`, at most 50 files, and no policy path. +- [x] Create one draft PR from a fixed JSON payload. +- [x] Expose PR number, head ref, and exact SHA for the authorizer. +- [x] Contain no review or merge endpoint. + +## Task 5 — Authorize only PR-associated exact-head runs + +**File:** `.github/workflows/hourly-opencode-maintenance.yml` + +- [x] Add `authorize-exact-head-checks` with `actions: write`, `contents: read`, and `pull-requests: read`. +- [x] Do not checkout source or pass the model credential. +- [x] Reject `.github/**` and `CODEOWNERS` changes. +- [x] Re-read the live PR before discovery and on every bounded pass. +- [x] Filter each run by `event=pull_request`, exact SHA, and `pull_requests[].number`. +- [x] Authorize only `action_required` or `waiting` runs. +- [x] Require all five named workflows to materialize. +- [x] Fail with missing names or any head movement. + +## Task 6 — Align operations, design, doctoring, and changelog evidence + +**Files:** + +- `docs/operations/hourly-opencode-maintenance.md` +- `docs/doctoring/github-token-exact-head-check-authorization-evidence.md` +- `docs/superpowers/specs/2026-08-04-hourly-opencode-maintenance-design.md` +- `docs/superpowers/plans/2026-08-04-hourly-opencode-maintenance-plan.md` +- `CHANGELOG.md` + +- [x] Document the three-job authority topology. +- [x] Record why plain OpenCode run replaces the GitHub lifecycle handler. +- [x] Record strict candidate and draft publication controls. +- [x] Record exact PR-number plus SHA association. +- [x] Record residual coarse permissions and compensating controls. +- [x] Preserve APA 7th references to OpenCode and GitHub primary sources. +- [ ] Confirm the final root changelog wording against the final exact head before merge. + +## Task 7 — Final verification and integration + +- [ ] Run focused workflow contract tests on the exact final head. +- [ ] Run the complete Maven reactor with no skipped project test. +- [ ] Confirm Ubuntu, macOS, and Windows CI. +- [ ] Confirm Dependency Review and CycloneDX SBOM. +- [ ] Confirm Semgrep, Trivy, OSV, Scorecard, and all required security evidence. +- [ ] Confirm zero unresolved current review thread. +- [ ] Obtain non-author approval anchored to the exact final SHA. +- [ ] Remove `manual-merge` only immediately before an expected-head squash merge. +- [ ] Merge #121, then retarget and revalidate #122 and every successor in stack order. + +## References — APA 7th + +Anomaly. (2026). *GitHub handler (Version 1.18.13)* [Source code]. GitHub. https://github.com/anomalyco/opencode/blob/v1.18.13/packages/opencode/src/cli/cmd/github.handler.ts + +Anomaly. (2026). *Run command (Version 1.18.13)* [Source code]. GitHub. https://github.com/anomalyco/opencode/blob/v1.18.13/packages/opencode/src/cli/cmd/run.ts + +GitHub, Inc. (2026). *GITHUB_TOKEN*. GitHub Docs. https://docs.github.com/en/actions/concepts/security/github_token + +GitHub, Inc. (2026). *REST API endpoints for workflow runs*. GitHub Docs. https://docs.github.com/en/rest/actions/workflow-runs diff --git a/docs/superpowers/specs/2026-08-04-hourly-opencode-maintenance-design.md b/docs/superpowers/specs/2026-08-04-hourly-opencode-maintenance-design.md new file mode 100644 index 00000000..4fad4ee0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-hourly-opencode-maintenance-design.md @@ -0,0 +1,154 @@ +# Hourly OpenCode Maintenance Agent Design + +## Purpose + +mightyETL requires an hourly development loop that can inspect the live pull-request queue, repair one dependency-eligible branch, or prepare one bounded buyer-visible improvement without granting model-generated code review, merge, or workflow-administration authority. + +The model credential is `NVIDIA_NIM_API_KEY`. GitHub Copilot credentials and changes to the existing review agents are outside this design. + +## Architecture decision + +Use three physically separated GitHub Actions jobs: + +```mermaid +flowchart LR + M[maintain-repository
OpenCode + source checkout] -->|one candidate record| P[publish-agent-pull-request
no checkout] + P -->|PR number + exact SHA| A[authorize-exact-head-checks
no checkout] + A --> V[CI and security validation] + V --> R[independent review and merge disposition] +``` + +### Model execution job + +`maintain-repository` uses plain `opencode run`, not `opencode github run`. The GitHub lifecycle handler can create pull requests and therefore requires pull-request write authority. Plain run separates model execution from PR publication. + +The job receives: + +```text +actions: read +checks: read +contents: write +issues: write +pull-requests: read +security-events: read +statuses: read +``` + +It may inspect pull requests and push one feature branch. It cannot create, update, approve, close, or merge a pull request with its token. + +### Deterministic publisher + +`publish-agent-pull-request` has `contents: read` and `pull-requests: write`. It has no checkout, model credential, or repository-code execution. It validates one candidate and either identifies an already-open updated PR or creates one draft PR from a strict `automation/opencode-*` branch. + +### Workflow-run authorizer + +`authorize-exact-head-checks` has `actions: write`, `contents: read`, and `pull-requests: read`. It has no checkout or model credential. It may approve only workflow runs that are: + +- `pull_request` events; +- bound to the exact expected SHA; +- associated with the exact expected pull-request number; +- in `action_required` or `waiting` state. + +It does not approve a PR or merge code. + +## Schedule and supply-chain contract + +- cron: `43 * * * *`; +- no manual dispatch; +- serialized concurrency; +- immutable `actions/checkout` full SHA; +- `persist-credentials: false`; +- OpenCode 1.18.13 immutable Linux archive; +- SHA-256 `8d500b20fed2d26e537e221895b1a575476571b4f0089bb29fb13eeb8eb9e937`; +- exactly one regular archive entry named `opencode`; +- private mode-`0700` extraction without archived ownership or permissions; +- 45-minute `TERM`, 30-second `KILL` escalation, 50-minute job timeout; +- model `nvidia/deepseek-ai/deepseek-v4-pro` with no provider fallback. + +## Candidate-state contract + +Before OpenCode starts, snapshot: + +1. protected `develop` head; +2. every same-repository open `develop` PR and head SHA; +3. every existing `automation/opencode-*` branch and head SHA. + +After OpenCode exits: + +1. require `develop` to be unchanged; +2. identify existing PR heads changed during the run; +3. identify new or advanced strict automation branches; +4. exclude an automation branch already represented by the changed PR candidate; +5. require zero or one candidate; +6. fail if the output is ambiguous. + +A new branch must match: + +```text +^automation/opencode-[0-9]{8}T[0-9]{6}Z-[a-z0-9][a-z0-9-]{0,48}$ +``` + +The publisher additionally requires a live matching SHA, at least one commit ahead of `develop`, at most 50 changed files, and no `.github/**` or `CODEOWNERS` path. + +## Exact-head workflow contract + +GitHub creates pull-request workflows asynchronously. The authorizer performs bounded repeated discovery and succeeds only when all five names are associated with the exact PR number and SHA: + +```text +CI +Dependency Review +SBOM (CycloneDX) +SAST Semgrep +Security Scan +``` + +A SHA-only filter is insufficient because multiple pull requests can reference the same commit. Every selected workflow run must satisfy: + +```text +run.head_sha == expected_head +and any(run.pull_requests; number == expected_pull_request_number) +``` + +The live PR head is checked before discovery, on every discovery pass, and after the complete set materializes. + +## Git credential lifecycle + +The model job uses an ephemeral repository-local `!gh auth git-credential` helper because checkout credentials remain disabled. It clears inherited local helpers, configures the bot author, and removes the helper through an `EXIT` trap after success, failure, or timeout. No token is written into Git configuration. + +## Agent product contract + +When a dependency-eligible PR exists, the model may update only that branch. Otherwise it may push exactly one strict automation branch. It must work test-first, preserve configured production statement and branch coverage at 100%, maintain public documentation and `CHANGELOG.md`, use descriptive multi-word `snake_case` database names, and record material primary standards or peer-reviewed evidence in APA 7th form. + +The model may not mutate PR lifecycle state, protected branches, workflow policy, review-agent credentials, repository secrets, or releases. + +## Failure semantics + +Missing credentials, archive mismatch, model failure, protected-branch movement, multiple candidates, invalid branch namespace, policy-file changes, candidate SHA movement, absent workflow association, incomplete workflow-name materialization, or any rejected API mutation fails visibly. Partial branch evidence can be exposed only as a draft by the deterministic publisher; it is not reported as a successful run. + +## Verification + +Tests must prove: + +- hourly bounded execution and immutable installation; +- exclusive NVIDIA NIM model credential; +- plain OpenCode run instead of GitHub lifecycle execution; +- PR read-only authority in the model job; +- exactly one non-checkout PR publisher; +- exactly one non-checkout Actions authorizer; +- strict single-candidate publication; +- policy-file exclusion; +- exact PR-number plus SHA run association; +- complete five-workflow materialization; +- no PR review or merge endpoint in the workflow. + +## References — APA 7th + +Anomaly. (2026). *GitHub handler (Version 1.18.13)* [Source code]. GitHub. https://github.com/anomalyco/opencode/blob/v1.18.13/packages/opencode/src/cli/cmd/github.handler.ts + +Anomaly. (2026). *Run command (Version 1.18.13)* [Source code]. GitHub. https://github.com/anomalyco/opencode/blob/v1.18.13/packages/opencode/src/cli/cmd/run.ts + +GitHub, Inc. (2026). *GITHUB_TOKEN*. GitHub Docs. https://docs.github.com/en/actions/concepts/security/github_token + +GitHub, Inc. (2026). *REST API endpoints for workflow runs*. GitHub Docs. https://docs.github.com/en/rest/actions/workflow-runs + +NVIDIA Corporation. (2026). *DeepSeek V4 Pro*. NVIDIA NIM API catalog. https://build.nvidia.com/deepseek-ai/deepseek-v4-pro diff --git a/etl-service/pom.xml b/etl-service/pom.xml index 00695de2..9e0e56b5 100644 --- a/etl-service/pom.xml +++ b/etl-service/pom.xml @@ -98,12 +98,6 @@ org.jacoco jacoco-maven-plugin 0.8.15 - - - com.xtrmetl.etl.job.* - com.xtrmetl.etl.controller.EtlJobController* - - prepare-durable-job-coverage @@ -118,6 +112,13 @@ report + + + com/xtrmetl/etl/job/*.class + com/xtrmetl/etl/controller/EtlJobController*.class + com/xtrmetl/etl/service/Sha256Digest*.class + + check-durable-job-coverage @@ -126,13 +127,24 @@ check + + com/xtrmetl/etl/job/*.class + com/xtrmetl/etl/controller/EtlJobController*.class + com/xtrmetl/etl/service/Sha256Digest*.class + + + BUNDLE + + + INSTRUCTION + TOTALCOUNT + 1 + + + CLASS - - com.xtrmetl.etl.job.* - com.xtrmetl.etl.controller.EtlJobController* - INSTRUCTION diff --git a/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobService.java b/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobService.java index 5f9b4645..e1992d17 100644 --- a/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobService.java +++ b/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobService.java @@ -9,6 +9,7 @@ import com.xtrmetl.etl.service.EtlRequestException; import com.xtrmetl.etl.service.EtlRequestLock; import com.xtrmetl.etl.service.PostgresEtlRequestLock; +import com.xtrmetl.etl.service.Sha256Digest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.lang.Nullable; @@ -17,12 +18,9 @@ import org.springframework.transaction.support.TransactionSynchronizationManager; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.sql.Timestamp; import java.time.Instant; import java.util.HashSet; -import java.util.HexFormat; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -168,10 +166,12 @@ public EtlJobSubmission submit( String validatedPayload = validatePayload(requestPayload); requireActiveTransaction(); - String principalScopeHash = sha256(validatedScope); - String submissionKeyHash = sha256(validatedKey); - String submissionLockHash = sha256(principalScopeHash + ":" + submissionKeyHash); - String requestDigest = sha256(validatedPayload); + String principalScopeHash = Sha256Digest.digest(validatedScope); + String submissionKeyHash = Sha256Digest.digest(validatedKey); + String submissionLockHash = Sha256Digest.digest( + principalScopeHash + ":" + submissionKeyHash + ); + String requestDigest = Sha256Digest.digest(validatedPayload); if (!requestLock.tryLock(submissionLockHash)) { throw new EtlRequestException(EtlRequestError.JOB_SUBMISSION_IN_PROGRESS); @@ -219,7 +219,7 @@ public EtlJobSnapshot findOwned( @Nullable String principalScope ) { UUID validatedJobId = Objects.requireNonNull(jobRecordId, "jobRecordId must not be null"); - String principalScopeHash = sha256(validatePrincipalScope(principalScope)); + String principalScopeHash = Sha256Digest.digest(validatePrincipalScope(principalScope)); List jobs = jdbcTemplate.query( SELECT_OWNED_JOB_SQL, (resultSet, rowNumber) -> mapSnapshot(resultSet.getObject("job_record_id", UUID.class), @@ -310,7 +310,7 @@ private String validatePayload(@Nullable String requestPayload) { return requestPayload; } - private static void validateRecord(@Nullable JsonNode record) { + static void validateRecord(@Nullable JsonNode record) { if (record == null || !record.isObject()) { throw new EtlRequestException(EtlRequestError.INVALID_RECORD); } @@ -380,16 +380,6 @@ private static void requireActiveTransaction() { } } - private static String sha256(String value) { - try { - MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); - byte[] digest = messageDigest.digest(value.getBytes(StandardCharsets.UTF_8)); - return HexFormat.of().formatHex(digest); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 is required by the Java platform", exception); - } - } - private record StoredJobRecord(String requestDigest, EtlJobSnapshot snapshot) { private StoredJobRecord { Objects.requireNonNull(requestDigest, "requestDigest must not be null"); diff --git a/etl-service/src/main/java/com/xtrmetl/etl/service/Sha256Digest.java b/etl-service/src/main/java/com/xtrmetl/etl/service/Sha256Digest.java new file mode 100644 index 00000000..4603b431 --- /dev/null +++ b/etl-service/src/main/java/com/xtrmetl/etl/service/Sha256Digest.java @@ -0,0 +1,53 @@ +package com.xtrmetl.etl.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** + * Produces lowercase SHA-256 hexadecimal digests for durable ETL persistence identities. + * + *

SHA-256 is required by the Java platform. The defensive exception branch therefore indicates + * a broken runtime rather than invalid customer input.

+ */ +public final class Sha256Digest { + + private Sha256Digest() { + // Utility class. + } + + /** + * Hashes one UTF-8 string into lowercase 64-character SHA-256 hexadecimal text. + * + * @param value text to hash + * @return lowercase SHA-256 hexadecimal digest + * @throws NullPointerException when the value is {@code null} + * @throws IllegalStateException when the Java runtime lacks mandatory SHA-256 support + */ + public static String digest(String value) { + return digest(value, () -> MessageDigest.getInstance("SHA-256")); + } + + static String digest(String value, MessageDigestFactory messageDigestFactory) { + String requiredValue = Objects.requireNonNull(value, "value must not be null"); + MessageDigestFactory requiredFactory = Objects.requireNonNull( + messageDigestFactory, + "messageDigestFactory must not be null" + ); + try { + MessageDigest messageDigest = requiredFactory.create(); + byte[] digest = messageDigest.digest(requiredValue.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is required by the Java platform", exception); + } + } + + @FunctionalInterface + interface MessageDigestFactory { + + MessageDigest create() throws NoSuchAlgorithmException; + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/CiCoverageDiagnosticsWorkflowTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/CiCoverageDiagnosticsWorkflowTest.java new file mode 100644 index 00000000..67148b3d --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/CiCoverageDiagnosticsWorkflowTest.java @@ -0,0 +1,82 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the failure diagnostics for every production class governed by the strict JaCoCo policy. + * + *

The coverage gate protects more than the durable-job service. When a class fails the 100% + * statement or branch threshold, the CI log must identify missed methods and source lines for + * every configured target rather than reporting only one historical class. This test keeps the + * diagnostic vocabulary synchronized with the authoritative Maven coverage configuration.

+ */ +class CiCoverageDiagnosticsWorkflowTest { + + private static String workflow; + + /** + * Reads the CI workflow with normalized line endings for deterministic platform behavior. + * + * @throws IOException when the workflow cannot be read as UTF-8 text + */ + @BeforeAll + static void readWorkflow() throws IOException { + Path workflowPath = projectRoot().resolve(".github/workflows/ci.yml"); + assertTrue(Files.exists(workflowPath), "The CI workflow must exist"); + workflow = Files.readString(workflowPath, StandardCharsets.UTF_8) + .replace("\r\n", "\n"); + } + + /** + * Requires one iterable diagnostic map for every zero-missed production coverage target. + */ + @Test + void diagnosesEveryStrictCoverageTarget() { + assertTrue(workflow.contains( + "\"com/xtrmetl/etl/job/EtlJobService\": \"EtlJobService.java\"" + )); + assertTrue(workflow.contains( + "\"com/xtrmetl/etl/controller/EtlJobController\": " + + "\"EtlJobController.java\"" + )); + assertTrue(workflow.contains( + "\"com/xtrmetl/etl/service/Sha256Digest\": \"Sha256Digest.java\"" + )); + assertTrue(workflow.contains( + "for class_name, source_name in coverage_targets.items():" + )); + } + + /** + * Finds the repository root from either root or module-local Maven execution. + * + * @return absolute repository root containing the root Maven project + * @throws IllegalStateException when no repository or Maven root can be found + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/ContainerImagePinningTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/ContainerImagePinningTest.java new file mode 100644 index 00000000..bd33262a --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/ContainerImagePinningTest.java @@ -0,0 +1,123 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Prevents mutable container tags from silently changing the software supply-chain inputs. + * + *

Docker image tags are intentionally mutable. A build that names only a tag can therefore + * consume different base-image bytes without any repository change. This contract keeps the tag + * for human readability while requiring every external Dockerfile build stage to include an + * immutable SHA-256 digest. References to an earlier local build stage are exempt because they do + * not resolve through a registry.

+ */ +class ContainerImagePinningTest { + + private static final Pattern FROM_INSTRUCTION = Pattern.compile( + "(?im)^[\\t ]*FROM\\s+(?:--platform=\\S+\\s+)?(?\\S+)" + + "(?:\\s+AS\\s+(?[A-Za-z0-9._-]+))?\\s*$" + ); + private static final Pattern SHA256_PIN = Pattern.compile( + "^[^@\\s]+@sha256:[0-9a-f]{64}$" + ); + + /** + * Requires every registry-backed Dockerfile build stage to resolve by immutable SHA-256 digest. + * + * @throws IOException when the repository Dockerfile cannot be read + */ + @Test + void pinsEveryExternalBaseImageBySha256Digest() throws IOException { + Path dockerfilePath = projectRoot().resolve("Dockerfile"); + assertTrue(Files.isRegularFile(dockerfilePath), "The repository Dockerfile must exist"); + + assertExternalImagesArePinned( + Files.readString(dockerfilePath, StandardCharsets.UTF_8) + ); + } + + /** + * Requires Docker-compatible leading indentation to remain inside the immutable-image policy. + * + *

Docker accepts spaces or tabs before an instruction. A mutable external image must + * therefore still fail this policy when its {@code FROM} instruction is indented.

+ */ + @Test + void rejectsIndentedMutableExternalBaseImage() { + String pinnedDigest = "a".repeat(64); + String dockerfile = "FROM example.invalid/build@sha256:" + pinnedDigest + " AS build\n" + + " FROM alpine:latest\n"; + + assertThrows( + AssertionError.class, + () -> assertExternalImagesArePinned(dockerfile), + "Indented FROM instructions must not bypass immutable image enforcement" + ); + } + + /** + * Applies the immutable-image policy to Dockerfile source text. + * + * @param dockerfile Dockerfile text to validate + */ + private static void assertExternalImagesArePinned(String dockerfile) { + Matcher matcher = FROM_INSTRUCTION.matcher(dockerfile); + Set localStageAliases = new HashSet<>(); + int externalImageCount = 0; + + while (matcher.find()) { + String imageReference = matcher.group("image"); + if (!localStageAliases.contains(imageReference)) { + externalImageCount++; + assertTrue( + SHA256_PIN.matcher(imageReference).matches(), + () -> "Dockerfile base image must use an immutable SHA-256 digest: " + + imageReference + ); + } + String stageAlias = matcher.group("alias"); + if (stageAlias != null) { + localStageAliases.add(stageAlias); + } + } + + assertTrue(externalImageCount > 0, "The Dockerfile must declare at least one base image"); + } + + /** + * Finds the repository root from either root-level or module-local Maven execution. + * + * @return absolute repository root containing the top-level Dockerfile and Maven project + * @throws IllegalStateException when no repository root can be found + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} \ No newline at end of file diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/DependencyReviewExactHeadWorkflowTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/DependencyReviewExactHeadWorkflowTest.java new file mode 100644 index 00000000..615a9d76 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/DependencyReviewExactHeadWorkflowTest.java @@ -0,0 +1,59 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Keeps dependency review bound to GitHub pull-request event semantics and an immutable action revision. + * + *

The dependency-review action derives the pull request base and head from the event payload for + * {@code pull_request} and {@code pull_request_target}. Its {@code base-ref}/{@code head-ref} inputs + * are documented for other event types only, so supplying them here would be ignored and would create + * misleading exact-head evidence.

+ */ +class DependencyReviewExactHeadWorkflowTest { + + @Test + void dependencyReviewUsesPullRequestEventRefsWithPinnedAction() throws IOException { + String workflow = Files.readString( + projectRoot().resolve(".github/workflows/dependency-review.yml"), + StandardCharsets.UTF_8 + ).replaceAll("\\s+", " "); + + assertTrue(workflow.contains("pull_request:")); + assertFalse(workflow.contains("base-ref:")); + assertFalse(workflow.contains("head-ref:")); + assertTrue(workflow.contains( + "uses: actions/dependency-review-action@" + + "a1d282b36b6f3519aa1f3fc636f609c47dddb294" + )); + assertTrue(workflow.contains("fail-on-severity: high")); + } + + /** @return repository root from reactor-root or module-local execution */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/ExactHeadWorkflowCheckoutTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/ExactHeadWorkflowCheckoutTest.java new file mode 100644 index 00000000..4c484c18 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/ExactHeadWorkflowCheckoutTest.java @@ -0,0 +1,86 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards pull-request quality evidence against GitHub's generated merge revision. + * + *

A pull-request workflow normally checks out {@code github.sha}, which is the synthetic merge + * commit rather than the contributor branch's exact current head. Branch protection may consume + * those results, but mightyETL's expected-head policy also requires direct evidence for the literal + * head SHA. Each source-executing workflow therefore binds checkout and an explicit identity + * assertion to the pull-request head, while push runs fall back to their event SHA.

+ */ +class ExactHeadWorkflowCheckoutTest { + + private static final String EXACT_SOURCE_EXPRESSION = + "${{ github.event.pull_request.head.sha || github.sha }}"; + + @Test + void continuousIntegrationChecksOutAndAssertsTheExactSourceRevision() throws IOException { + assertExactHeadCheckout(".github/workflows/ci.yml"); + } + + @Test + void sbomGenerationChecksOutAndAssertsTheExactSourceRevision() throws IOException { + assertExactHeadCheckout(".github/workflows/sbom.yml"); + } + + private static void assertExactHeadCheckout(String relativePath) throws IOException { + String workflow = Files.readString( + projectRoot().resolve(relativePath), + StandardCharsets.UTF_8 + ).replace("\r\n", "\n"); + + assertTrue( + workflow.contains("ref: " + EXACT_SOURCE_EXPRESSION), + relativePath + " must check out the literal pull-request head" + ); + assertTrue( + workflow.contains("persist-credentials: false"), + relativePath + " must not persist the checkout credential" + ); + assertTrue( + workflow.contains( + "test \"$(git rev-parse HEAD)\" = \"" + EXACT_SOURCE_EXPRESSION + "\"" + ), + relativePath + " must fail when the checked-out revision is not the expected head" + ); + assertFalse( + workflow.contains("ref: ${{ github.sha }}"), + relativePath + " must not bind pull-request source execution to the merge revision" + ); + } + + /** + * Finds the repository root from repository-root or module-local Maven execution. + * + * @return repository root containing the workflow files + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeArchiveValidationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeArchiveValidationTest.java new file mode 100644 index 00000000..3cd9fcf8 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeArchiveValidationTest.java @@ -0,0 +1,105 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards archive-member validation before the scheduled workflow extracts the OpenCode binary. + * + *

A checksum proves that downloaded bytes match the reviewed release asset, but it does not + * independently constrain where archive members would be written. The workflow therefore accepts + * only the documented one-file release shape: one regular member named {@code opencode}. This + * contract prevents future tool-pin changes from silently broadening extraction to directories, + * links, device nodes, absolute paths, parent-directory paths, or unexpected additional files.

+ */ +class HourlyOpenCodeArchiveValidationTest { + + private static String workflow; + + /** + * Reads the workflow as normalized UTF-8 text for deterministic assertions on every CI + * operating system. + * + * @throws IOException when the workflow cannot be read + */ + @BeforeAll + static void readWorkflow() throws IOException { + Path workflowPath = projectRoot().resolve( + ".github/workflows/hourly-opencode-maintenance.yml" + ); + workflow = Files.readString(workflowPath, StandardCharsets.UTF_8) + .replace("\r\n", "\n"); + } + + /** + * Requires exact archive membership to be checked before extraction begins. + */ + @Test + void validatesOneExpectedArchiveMemberBeforeExtraction() { + String validation = "mapfile -t archive_members < <(tar --list --gzip --file \"${archive}\")"; + String exactShape = "[[ \"${#archive_members[@]}\" -ne 1 " + + "|| \"${archive_members[0]}\" != \"opencode\" ]]"; + String extraction = "tar --extract --gzip --no-same-owner --no-same-permissions"; + + assertTrue(workflow.contains(validation)); + assertTrue(workflow.contains(exactShape)); + assertTrue(workflow.contains("OpenCode archive contains unexpected members")); + assertTrue(workflow.contains(extraction)); + assertTrue(workflow.indexOf(validation) < workflow.indexOf(extraction)); + assertTrue(workflow.indexOf(exactShape) < workflow.indexOf(extraction)); + } + + /** + * Requires the sole member to be a regular-file entry before tar writes to the filesystem. + * + *

Post-extraction symbolic-link checks remain useful defense in depth, but they cannot by + * themselves distinguish an ordinary archived file from every link-oriented archive entry. + * GNU tar's verbose listing begins each member with its type character, so the Ubuntu-only + * installer must require {@code -} for a regular file before extraction.

+ */ + @Test + void validatesRegularFileEntryTypeBeforeExtraction() { + String metadata = "archive_entry_metadata=\"$(LC_ALL=C tar --list --verbose " + + "--numeric-owner --gzip --file \"${archive}\")\""; + String regularType = "[[ \"${archive_entry_metadata:0:1}\" != \"-\" ]]"; + String extraction = "tar --extract --gzip --no-same-owner --no-same-permissions"; + + assertTrue(workflow.contains(metadata)); + assertTrue(workflow.contains(regularType)); + assertTrue(workflow.contains("OpenCode archive member is not a regular file")); + assertTrue(workflow.indexOf(metadata) < workflow.indexOf(extraction)); + assertTrue(workflow.indexOf(regularType) < workflow.indexOf(extraction)); + } + + /** + * Finds the repository root from either root-level or module-local Maven execution. + * + * @return absolute repository root containing the Maven reactor + * @throws IllegalStateException when no repository or Maven root can be found + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeAuthorityDocumentationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeAuthorityDocumentationTest.java new file mode 100644 index 00000000..a30753d3 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeAuthorityDocumentationTest.java @@ -0,0 +1,182 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Keeps the authoritative OpenCode security doctoring aligned with the executable workflow. + * + *

This contract prevents acquisition and security evidence from describing superseded token + * authority. The model-executing job must remain repository-read-only, while branch publication, + * pull-request publication, and workflow-run authorization stay in separate deterministic jobs.

+ */ +class HourlyOpenCodeAuthorityDocumentationTest { + + private static String workflow; + private static String doctoring; + private static String design; + private static String plan; + private static String changelog; + + /** + * Reads the executable workflow as raw normalized YAML and the prose sources using canonical + * whitespace so job-scoped permission checks retain YAML boundaries while prose wrapping does + * not change the documentation contract. + * + * @throws IOException when a checked repository document cannot be read + */ + @BeforeAll + static void readAuthoritySources() throws IOException { + Path root = projectRoot(); + workflow = Files.readString( + root.resolve(".github/workflows/hourly-opencode-maintenance.yml"), + StandardCharsets.UTF_8 + ).replace("\r\n", "\n"); + doctoring = canonicalWhitespace(Files.readString( + root.resolve("docs/doctoring/github-token-exact-head-check-authorization-evidence.md"), + StandardCharsets.UTF_8 + )); + design = canonicalWhitespace(Files.readString( + root.resolve("docs/superpowers/specs/2026-08-04-hourly-opencode-maintenance-design.md"), + StandardCharsets.UTF_8 + )); + plan = canonicalWhitespace(Files.readString( + root.resolve("docs/superpowers/plans/2026-08-04-hourly-opencode-maintenance-plan.md"), + StandardCharsets.UTF_8 + )); + changelog = canonicalWhitespace(Files.readString( + root.resolve("CHANGELOG.md"), + StandardCharsets.UTF_8 + )); + } + + /** Requires doctoring to state that the model job has no repository write permission. */ + @Test + void documentsModelJobAsReadOnlyGitHubAuthority() { + String maintenanceJob = between( + workflow, + " maintain-repository:", + " publish-agent-branch:" + ); + assertTrue(maintenanceJob.contains("actions: read")); + assertTrue(maintenanceJob.contains("contents: read")); + assertTrue(maintenanceJob.contains("issues: read")); + assertTrue(maintenanceJob.contains("pull-requests: read")); + assertFalse(maintenanceJob.contains("contents: write")); + assertFalse(maintenanceJob.contains("issues: write")); + + assertTrue(doctoring.contains( + "`maintain-repository` is the only job that checks out source or runs OpenCode. " + + "It has read-only GitHub authority" + )); + assertTrue(doctoring.contains( + "actions: read checks: read contents: read issues: read pull-requests: read " + + "security-events: read statuses: read" + )); + } + + /** Requires doctoring to identify the isolated deterministic jobs that own each write. */ + @Test + void documentsSeparatedBranchPullRequestAndActionsWriters() { + assertTrue(workflow.contains("publish-agent-branch:")); + assertTrue(workflow.contains("publish-agent-pull-request:")); + assertTrue(workflow.contains("authorize-exact-head-checks:")); + + assertTrue(doctoring.contains( + "`publish-agent-branch` is the sole `contents: write` holder" + )); + assertTrue(doctoring.contains( + "`publish-agent-pull-request` is the sole `pull-requests: write` holder" + )); + assertTrue(doctoring.contains( + "`authorize-exact-head-checks` is the sole `actions: write` holder" + )); + assertTrue(doctoring.contains( + "The model-executing job receives none of those write permissions" + )); + } + + /** Requires the design and implementation plan to describe the live four-job topology. */ + @Test + void documentsTheCurrentFourJobTopologyAndCredentialLifecycle() { + assertTrue(design.contains("Use four physically separated GitHub Actions jobs")); + assertTrue(design.contains("`maintain-repository` creates local commits only")); + assertTrue(design.contains("The model job uses an ephemeral `GIT_ASKPASS` script")); + assertTrue(design.contains( + "`publish-agent-branch` uses the repository-local `!gh auth git-credential` helper" + )); + + assertTrue(plan.contains( + "Four jobs separate model execution, deterministic branch publication, " + + "deterministic draft-PR publication, and exact-head workflow-run authorization" + )); + assertTrue(plan.contains("Set model-job permissions to read-only GitHub authority")); + assertTrue(plan.contains("Create local commits only; do not push from the model job")); + assertTrue(plan.contains("## Task 4 — Publish the exact branch in an isolated job")); + } + + /** Requires durable release notes to avoid time-sensitive endpoint-pricing claims. */ + @Test + void avoidsTimeSensitiveFreeEndpointClaimsInTheChangelog() { + assertFalse(changelog.contains("current free NVIDIA")); + assertTrue(changelog.contains("NVIDIA `deepseek-ai/deepseek-v4-pro` endpoint")); + } + + /** + * Extracts a required raw-text section between ordered markers. + * + * @param text complete source text + * @param startMarker inclusive section marker + * @param endMarker exclusive section marker + * @return required section text + */ + private static String between(String text, String startMarker, String endMarker) { + int start = text.indexOf(startMarker); + assertTrue(start >= 0, () -> "Missing start marker: " + startMarker); + int end = text.indexOf(endMarker, start + startMarker.length()); + assertTrue(end > start, () -> "Missing end marker after start: " + endMarker); + return text.substring(start, end); + } + + /** + * Collapses semantically irrelevant whitespace for stable Markdown prose assertions. + * + * @param value repository text to normalize + * @return one-space canonical representation + */ + private static String canonicalWhitespace(String value) { + return value.replaceAll("\\s+", " ").trim(); + } + + /** + * Finds the repository root from either reactor-root or module-local Maven execution. + * + * @return absolute repository root + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeCandidateSelectionTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeCandidateSelectionTest.java new file mode 100644 index 00000000..25844946 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeCandidateSelectionTest.java @@ -0,0 +1,189 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the candidate-selection and publication boundaries used by the hourly OpenCode workflow. + * + *

Inside {@code $existing_refs | index(...)}, the jq input changes from the branch object to the + * reference array. Reading {@code .name} after that pipe therefore fails at runtime, so production + * must capture the branch name first and pass the scalar variable to {@code index}. Updated + * existing pull requests must also preserve their pre-agent head through local candidate capture, + * isolated branch publication, and pull-request publication so policy-file changes are rejected + * before and after the remote branch update.

+ */ +class HourlyOpenCodeCandidateSelectionTest { + + /** + * Requires branch-name capture before indexing the existing-PR reference array. + * + * @throws IOException when the workflow cannot be read + */ + @Test + void capturesBranchNameBeforeExistingReferenceLookup() throws IOException { + String workflow = workflowText(); + + assertTrue(workflow.contains(".name as $branch_name")); + assertTrue(workflow.contains("index($branch_name)")); + assertFalse(workflow.contains("index(.name)")); + } + + /** + * Requires unexpected remote publication movement to use a diagnostic that is accurate for + * either one or many remote candidates. + * + * @throws IOException when the workflow cannot be read + */ + @Test + void reportsAnyUnexpectedRemotePublicationCandidateAccurately() throws IOException { + String workflow = workflowText(); + + assertTrue(workflow.contains( + "Remote publication candidates changed during the model job; " + + "refusing to race another writer" + )); + assertFalse(workflow.contains( + "Multiple agent publication candidates were detected remotely" + )); + } + + /** + * Requires policy-file rejection at both deterministic publication boundaries. + * + *

The model job now has read-only repository credentials and produces only a local commit. + * The isolated branch publisher must re-bind an existing pull request to its exact pre-agent + * head, validate the complete candidate path set before its sole branch push, and refuse policy + * files. The separate pull-request publisher must then compare that pre-agent head with the + * exact published head and independently reject the same policy paths. This keeps the original + * guard effective after publication authority was removed from the model-executing job.

+ * + * @throws IOException when the workflow cannot be read + */ + @Test + void rejectsPolicyChangesOnUpdatedExistingPullRequests() throws IOException { + String workflow = workflowText(); + String branchPublisher = between( + workflow, + "\n publish-agent-branch:\n", + "\n publish-agent-pull-request:\n" + ); + String branchExistingPrPreflight = between( + branchPublisher, + "if [[ \"${kind}\" == \"existing_pr\" ]]; then", + "elif [[ \"${kind}\" == \"new_branch\" ]]; then" + ); + String pullRequestPublisher = between( + workflow, + "\n publish-agent-pull-request:\n", + "\n authorize-exact-head-checks:\n" + ); + String pullRequestExistingPrValidation = between( + pullRequestPublisher, + "if [[ \"${kind}\" == \"existing_pr\" ]]; then", + "elif [[ \"${kind}\" == \"new_branch\" ]]; then" + ); + + assertTrue(workflow.contains("before_head_sha: $before_head_sha")); + assertTrue(branchExistingPrPreflight.contains( + "before_head=\"$(jq -r '.before_head_sha' <<<\"${metadata}\")\"" + )); + assertTrue(branchExistingPrPreflight.contains("and .head.sha == $before_head")); + assertTrue(branchPublisher.contains( + "git log --format= --name-only \"${predecessor_sha}..${candidate_head}\"" + )); + assertTrue(branchPublisher.contains( + "grep -Eq '(^\\.github/|(^|/)CODEOWNERS$)' \"${changed_paths_file}\"" + )); + assertAppearsBefore( + branchPublisher, + "grep -Eq '(^\\.github/|(^|/)CODEOWNERS$)' \"${changed_paths_file}\"", + "git push origin \"${candidate_head}:refs/heads/${head_ref}\"" + ); + + assertTrue(pullRequestExistingPrValidation.contains( + "before_head=\"$(jq -r '.before_head_sha' <<<\"${AGENT_CANDIDATE}\")\"" + )); + assertTrue(pullRequestExistingPrValidation.contains( + "comparison=\"$(gh api \"/repos/${repository}/compare/${before_head}...${expected_head}\")\"" + )); + assertTrue(pullRequestExistingPrValidation.contains(".files[].filename")); + assertTrue(pullRequestExistingPrValidation.contains("startswith(\".github/\")")); + assertTrue(pullRequestExistingPrValidation.contains("or . == \"CODEOWNERS\"")); + assertTrue(pullRequestExistingPrValidation.contains("or endswith(\"/CODEOWNERS\")")); + } + + /** + * Requires one security-sensitive marker to occur before another in the same workflow section. + * + * @param text workflow section being checked + * @param first marker that must execute first + * @param second marker that must execute later + */ + private static void assertAppearsBefore(String text, String first, String second) { + int firstIndex = text.indexOf(first); + int secondIndex = text.indexOf(second); + assertTrue(firstIndex >= 0, () -> "Missing first marker: " + first); + assertTrue(secondIndex > firstIndex, () -> "Marker must appear after first marker: " + second); + } + + /** + * Reads the normalized hourly workflow text. + * + * @return workflow content with Unix line endings + * @throws IOException when the workflow cannot be read + */ + private static String workflowText() throws IOException { + return Files.readString( + projectRoot().resolve(".github/workflows/hourly-opencode-maintenance.yml"), + StandardCharsets.UTF_8 + ).replace("\r\n", "\n"); + } + + /** + * Extracts a required text section between two unique markers. + * + * @param text complete source text + * @param startMarker inclusive section marker + * @param endMarker exclusive section marker + * @return required section text + */ + private static String between(String text, String startMarker, String endMarker) { + int start = text.indexOf(startMarker); + assertTrue(start >= 0, () -> "Missing start marker: " + startMarker); + int end = text.indexOf(endMarker, start + startMarker.length()); + assertTrue(end > start, () -> "Missing end marker after start: " + endMarker); + return text.substring(start, end); + } + + /** + * Finds the Maven reactor root from root-level or module-local execution. + * + * @return absolute project root + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeIssuePermissionWorkflowTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeIssuePermissionWorkflowTest.java new file mode 100644 index 00000000..eaf008ae --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeIssuePermissionWorkflowTest.java @@ -0,0 +1,64 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards least-privilege issue access for the scheduled OpenCode maintenance workflow. + * + *

The maintenance agent may inspect issues while selecting one bounded product gap, but no + * current workflow operation mutates issue state. The repository token therefore requires only + * read access to issues; retaining {@code issues: write} would add unnecessary authority to the + * model-executing job.

+ */ +class HourlyOpenCodeIssuePermissionWorkflowTest { + + /** Verifies that the model-executing job can read issues but cannot mutate them. */ + @Test + void grantsReadOnlyIssuePermissionToMaintenanceAgent() throws IOException { + String workflow = Files.readString( + projectRoot().resolve(".github/workflows/hourly-opencode-maintenance.yml"), + StandardCharsets.UTF_8 + ).replace("\r\n", "\n"); + + int maintenanceStart = workflow.indexOf(" maintain-repository:"); + int publisherStart = workflow.indexOf(" publish-agent-pull-request:"); + assertTrue(maintenanceStart >= 0, "The maintenance job must exist"); + assertTrue(publisherStart > maintenanceStart, "The publisher must follow maintenance"); + + String maintenanceJob = workflow.substring(maintenanceStart, publisherStart); + assertTrue(maintenanceJob.contains("issues: read")); + assertFalse(maintenanceJob.contains("issues: write")); + } + + /** + * Finds the repository root from either reactor-root or module-local execution. + * + * @return absolute repository root + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeMaintenanceWorkflowTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeMaintenanceWorkflowTest.java new file mode 100644 index 00000000..8754b508 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeMaintenanceWorkflowTest.java @@ -0,0 +1,334 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the credential, authority, supply-chain, publication, and exact-head validation + * boundaries of the scheduled OpenCode maintenance workflow. + * + *

The model may edit and commit one bounded feature-branch candidate, but it must never + * receive repository-content write authority. An isolated deterministic branch publisher may + * transfer the exact candidate commit, a second non-checkout publisher may create one draft pull + * request, and a third isolated non-checkout job may authorize only approval-required workflow + * runs associated with that exact pull request and exact head. These tests keep every write + * authority physically separated from model and repository-code execution.

+ */ +class HourlyOpenCodeMaintenanceWorkflowTest { + + private static final Pattern SECRET_REFERENCE = Pattern.compile( + "\\$\\{\\{\\s*secrets\\.([A-Z0-9_]+)\\s*}}" + ); + + private static String workflow; + + /** + * Reads the workflow once and normalizes line endings for deterministic cross-platform tests. + * + * @throws IOException when the workflow exists but cannot be read as UTF-8 text + */ + @BeforeAll + static void readWorkflow() throws IOException { + Path workflowPath = projectRoot().resolve( + ".github/workflows/hourly-opencode-maintenance.yml" + ); + assertTrue(Files.exists(workflowPath), "The hourly OpenCode workflow must exist"); + workflow = Files.readString(workflowPath, StandardCharsets.UTF_8) + .replace("\r\n", "\n"); + } + + /** Verifies one serialized, bounded run every hour. */ + @Test + void schedulesOneBoundedNonOverlappingRunPerHour() { + assertTrue(workflow.contains("cron: \"43 * * * *\"")); + assertTrue(workflow.contains("group: hourly-opencode-maintenance")); + assertTrue(workflow.contains("cancel-in-progress: false")); + assertTrue(workflow.contains("timeout-minutes: 50")); + assertTrue(workflow.contains( + "timeout --signal=TERM --kill-after=30s 45m opencode run" + )); + } + + /** Verifies immutable checkout and OpenCode installation without persisted credentials. */ + @Test + void pinsCheckoutAndOpenCodeWithoutPersistedCredentials() { + assertTrue(workflow.contains( + "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" + )); + assertTrue(workflow.contains("fetch-depth: 1")); + assertTrue(workflow.contains("persist-credentials: false")); + assertTrue(workflow.contains( + "https://github.com/anomalyco/opencode/releases/download/" + + "v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" + )); + assertTrue(workflow.contains( + "OPENCODE_SHA256: \"8d500b20fed2d26e537e221895b1a575476571b4f0089bb29fb13eeb8eb9e937\"" + )); + assertTrue(workflow.contains("sha256sum --check --strict")); + assertTrue(workflow.contains("tar --extract --gzip")); + assertTrue(workflow.contains( + "test \"$(\"${install_dir}/opencode\" --version)\" = \"${OPENCODE_VERSION}\"" + )); + assertFalse(workflow.contains("npm install")); + assertFalse(workflow.contains("opencode-ai@latest")); + assertFalse(workflow.contains("anomalyco/opencode/github@")); + } + + /** Verifies ephemeral Git credentials exist only for deterministic branch publication. */ + @Test + void bootstrapsAndRemovesDirectTokenGitCredentials() { + String maintenance = maintenanceJob(); + String branchPublisher = branchPublicationJob(); + + assertTrue(workflow.contains("GH_TOKEN: ${{ github.token }}")); + assertFalse(maintenance.contains("git_credential_key=")); + assertFalse(maintenance.contains("git push")); + assertTrue(branchPublisher.contains( + "git_credential_key=\"credential.https://github.com.helper\"" + )); + assertTrue(branchPublisher.contains("cleanup_git_credentials()")); + assertTrue(workflow.contains("trap cleanup_git_credentials EXIT")); + assertTrue(workflow.contains( + "git config --local --add \"${git_credential_key}\" \"\"" + )); + assertTrue(workflow.contains( + "git config --local --add \"${git_credential_key}\" " + + "\"!gh auth git-credential\"" + )); + assertTrue(workflow.contains( + "git config --local user.name \"opencode-agent[bot]\"" + )); + assertTrue(workflow.contains( + "git config --local user.email " + + "\"opencode-agent[bot]@users.noreply.github.com\"" + )); + assertFalse(workflow.contains("AUTHORIZATION: basic")); + } + + /** Verifies NVIDIA NIM is the sole model credential and plain OpenCode owns no PR lifecycle. */ + @Test + void usesOnlyNvidiaNimWithPlainOpenCodeRun() { + assertEquals(Set.of("NVIDIA_NIM_API_KEY"), referencedSecrets()); + assertTrue(workflow.contains( + "NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}" + )); + assertTrue(workflow.contains("MODEL: nvidia/deepseek-ai/deepseek-v4-pro")); + assertTrue(workflow.contains("opencode run --model \"${MODEL}\" --auto")); + assertFalse(workflow.contains("opencode github run")); + assertFalse(workflow.contains("USE_GITHUB_TOKEN")); + assertFalse(workflow.toLowerCase(Locale.ROOT).contains("copilot")); + assertFalse(workflow.contains("ANTHROPIC_API_KEY")); + assertFalse(workflow.contains("OPENAI_API_KEY")); + } + + /** + * Proves all repository writes are isolated from the model-executing job and its NVIDIA + * credential, while pull-request and Actions write authorities remain separately bounded. + */ + @Test + void isolatesPullRequestAndActionsWriteAuthorityFromTheAgent() { + String maintenance = maintenanceJob(); + String branchPublisher = branchPublicationJob(); + String publisher = publicationJob(); + String authorizer = authorizationJob(); + + assertTrue(workflow.contains("permissions:\n contents: read\n\njobs:")); + assertTrue(maintenance.contains("actions: read")); + assertTrue(maintenance.contains("contents: read")); + assertFalse(maintenance.contains("contents: write")); + assertTrue(maintenance.contains("pull-requests: read")); + assertFalse(maintenance.contains("pull-requests: write")); + assertFalse(maintenance.contains("actions: write")); + + assertTrue(branchPublisher.contains("contents: write")); + assertTrue(branchPublisher.contains("actions: read")); + assertFalse(branchPublisher.contains("NVIDIA_API_KEY")); + assertFalse(branchPublisher.contains("opencode run")); + assertFalse(branchPublisher.contains("pull-requests: write")); + assertTrue(branchPublisher.contains( + "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" + )); + assertTrue(maintenance.contains( + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + )); + + assertTrue(publisher.contains("pull-requests: write")); + assertTrue(publisher.contains("contents: read")); + assertFalse(publisher.contains("actions/checkout@")); + assertFalse(publisher.contains("NVIDIA_API_KEY")); + assertFalse(publisher.contains("/reviews")); + assertFalse(publisher.contains("/merge")); + + assertTrue(authorizer.contains("actions: write")); + assertTrue(authorizer.contains("pull-requests: read")); + assertFalse(authorizer.contains("actions/checkout@")); + assertFalse(authorizer.contains("NVIDIA_API_KEY")); + assertFalse(authorizer.contains("pull-requests: write")); + + assertEquals(1, countOccurrences(workflow, "actions: write")); + assertEquals(1, countOccurrences(workflow, "contents: write")); + assertEquals(1, countOccurrences(workflow, "pull-requests: write")); + assertFalse(workflow.contains("id-token:")); + assertFalse(workflow.contains("security-events: write")); + } + + /** Verifies one strict branch or existing PR is selected before deterministic publication. */ + @Test + void publishesOnlyOneValidatedAgentCandidate() { + assertTrue(workflow.contains( + "automation_branch_heads_before: " + + "${{ steps.snapshot_heads.outputs.automation_branch_heads }}" + )); + assertTrue(workflow.contains( + "agent_candidate: ${{ steps.detect_candidate.outputs.agent_candidate }}" + )); + assertTrue(workflow.contains("automation/opencode-")); + assertTrue(workflow.contains("Multiple agent publication candidates were detected")); + assertTrue(workflow.contains("kind: \"existing_pr\"")); + assertTrue(workflow.contains("kind: \"new_branch\"")); + assertTrue(workflow.contains("draft: true")); + assertTrue(workflow.contains("startswith(\".github/\")")); + assertTrue(workflow.contains("CODEOWNERS")); + assertTrue(workflow.contains("Agent branch is not ahead of develop")); + } + + /** + * Requires every workflow-run decision to bind the event, exact SHA, and associated pull + * request number before the isolated job can authorize a waiting run. + */ + @Test + void authorizesOnlyRunsAssociatedWithTheExactPullRequestHead() { + assertTrue(workflow.contains("required_workflow_names=")); + assertTrue(workflow.contains("event=pull_request")); + assertTrue(workflow.contains("head_sha=${expected_head}")); + assertTrue(workflow.contains("--argjson pull_request_number \"${number}\"")); + assertTrue(workflow.contains( + "any(.pull_requests[]?; .number == $pull_request_number)" + )); + assertTrue(workflow.contains(".head_sha == $expected_head")); + assertTrue(workflow.contains("/actions/runs/${run_id}/approve")); + assertTrue(workflow.contains("Missing required exact-head workflows")); + assertTrue(workflow.contains("PR #${number} moved")); + assertFalse(workflow.contains("gh pr review --approve")); + assertFalse(workflow.contains("/pulls/${number}/merge")); + } + + /** Verifies the prompt itself mirrors the hard authority boundary and bounded branch contract. */ + @Test + void promptForbidsPullRequestMutationMergeAndProtectedBranchPushes() { + assertTrue(workflow.contains("Start every run by inspecting every open pull request")); + assertTrue(workflow.contains("exact current head")); + assertTrue(workflow.contains( + "Do not create, update, approve, close, or merge a pull request directly" + )); + assertTrue(workflow.contains("Never push directly to develop or main")); + assertTrue(workflow.contains("exactly one automation/opencode-")); + assertTrue(workflow.contains("Do not bypass branch protection")); + assertTrue(workflow.contains("Do not alter the existing review agent")); + assertTrue(workflow.contains("Do not change any review-agent secret name")); + assertTrue(workflow.contains("Do not modify .github/workflows/")); + assertTrue(workflow.contains("automation-maintenance")); + assertTrue(workflow.contains("Do not print, echo, summarize, or expose secret values")); + } + + /** @return workflow text for the OpenCode execution job only */ + private static String maintenanceJob() { + return jobSection(" maintain-repository:", " publish-agent-branch:"); + } + + /** @return workflow text for the isolated deterministic branch publisher only */ + private static String branchPublicationJob() { + return jobSection(" publish-agent-branch:", " publish-agent-pull-request:"); + } + + /** @return workflow text for the deterministic draft-PR publisher only */ + private static String publicationJob() { + return jobSection(" publish-agent-pull-request:", " authorize-exact-head-checks:"); + } + + /** @return workflow text for the exact-head workflow-run authorizer */ + private static String authorizationJob() { + int start = workflow.indexOf(" authorize-exact-head-checks:"); + assertTrue(start >= 0, "The isolated exact-head authorization job must exist"); + return workflow.substring(start); + } + + /** + * Extracts one job section between two top-level job keys. + * + * @param startMarker first job marker + * @param endMarker following job marker + * @return exact workflow section + */ + private static String jobSection(String startMarker, String endMarker) { + int start = workflow.indexOf(startMarker); + int end = workflow.indexOf(endMarker); + assertTrue(start >= 0, "Missing workflow job: " + startMarker); + assertTrue(end > start, "Invalid workflow job order for: " + startMarker); + return workflow.substring(start, end); + } + + /** + * Counts non-overlapping literal occurrences. + * + * @param text complete text + * @param fragment non-empty literal fragment + * @return occurrence count + */ + private static int countOccurrences(String text, String fragment) { + int count = 0; + int cursor = 0; + while ((cursor = text.indexOf(fragment, cursor)) >= 0) { + count++; + cursor += fragment.length(); + } + return count; + } + + /** @return immutable set of referenced repository-secret names */ + private static Set referencedSecrets() { + Matcher matcher = SECRET_REFERENCE.matcher(workflow); + Set secretNames = new java.util.HashSet<>(); + while (matcher.find()) { + secretNames.add(matcher.group(1)); + } + return Set.copyOf(secretNames); + } + + /** + * Finds the repository root from either reactor-root or module-local execution. + * + * @return absolute repository root + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} \ No newline at end of file diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeManualRefWorkflowTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeManualRefWorkflowTest.java new file mode 100644 index 00000000..02a5e136 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeManualRefWorkflowTest.java @@ -0,0 +1,81 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Prevents the scheduled maintenance workflow from executing repository-controlled code from a + * manually selected feature branch or tag. + * + *

GitHub manual workflow dispatch can select a non-default ref. The maintenance workflow has + * repository write authority and receives the NVIDIA model credential, so its trusted workflow + * source must be schedule-only and its checkout must explicitly bind to the repository default + * branch. This test makes both authority boundaries visible to beginning maintainers.

+ */ +class HourlyOpenCodeManualRefWorkflowTest { + + private static String workflow; + + /** + * Reads the workflow with normalized line endings for deterministic cross-platform checks. + * + * @throws IOException when the workflow cannot be read as UTF-8 text + */ + @BeforeAll + static void readWorkflow() throws IOException { + Path workflowPath = projectRoot().resolve( + ".github/workflows/hourly-opencode-maintenance.yml" + ); + assertTrue(Files.exists(workflowPath), "The maintenance workflow must exist"); + workflow = Files.readString(workflowPath, StandardCharsets.UTF_8) + .replace("\r\n", "\n"); + } + + /** + * Requires schedule-only invocation and an explicit protected default-branch checkout. + */ + @Test + void rejectsManualFeatureRefsAndPinsTheDefaultBranchCheckout() { + assertFalse( + workflow.contains("workflow_dispatch:"), + "Manual dispatch must not allow a feature branch or tag to supply workflow code" + ); + assertTrue( + workflow.contains("ref: ${{ github.event.repository.default_branch }}"), + "Checkout must explicitly use the protected repository default branch" + ); + } + + /** + * Finds the repository root from either root or module-local Maven execution. + * + * @return absolute repository root containing the root Maven project + * @throws IllegalStateException when no repository or Maven root can be found + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeProgressPolicyTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeProgressPolicyTest.java new file mode 100644 index 00000000..62a08a3e --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeProgressPolicyTest.java @@ -0,0 +1,216 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Specifies how scheduled OpenCode maintenance must keep making safe repository-local progress + * while external review, check, or read-only dependency gates remain unavailable. + * + *

The policy deliberately keeps merge evidence fail-closed while preventing an unrelated + * external wait from stopping bounded, non-conflicting mightyETL work. It also preserves stack + * integrity by prohibiting work on invalid downstream boundaries.

+ */ +class HourlyOpenCodeProgressPolicyTest { + + private static String agentPolicy; + private static String workflow; + + /** + * Reads the scheduler's repository instruction and workflow inputs once with normalized line + * endings and whitespace so the contract behaves identically on every supported operating + * system. + * + * @throws IOException when either authoritative UTF-8 source cannot be read + */ + @BeforeAll + static void readSchedulerPolicy() throws IOException { + Path root = projectRoot(); + agentPolicy = canonicalWhitespace( + Files.readString(root.resolve("AGENTS.md"), StandardCharsets.UTF_8) + ); + workflow = canonicalWhitespace( + Files.readString( + root.resolve(".github/workflows/hourly-opencode-maintenance.yml"), + StandardCharsets.UTF_8 + ) + ); + } + + /** + * Requires external-only latency to remain a merge blocker without becoming a blanket + * development stop condition. + */ + @Test + void continuesOneBoundedNonConflictingSliceWhenNoPullRequestIsSourceActionable() { + assertTrue(agentPolicy.contains("## Scheduled maintenance progress contract")); + assertTrue(agentPolicy.contains( + "External review, approval, check, or read-only dependency latency is not a " + + "reason to stop all productive mightyETL work." + )); + assertTrue(agentPolicy.contains( + "A pull request is source-actionable only when its exact current head has a valid " + + "repository-local finding or failing source gate that mightyETL can repair." + )); + assertTrue(agentPolicy.contains( + "When no open pull request is source-actionable, select exactly one non-conflicting " + + "bounded mightyETL slice from the protected `develop` head." + )); + assertTrue(agentPolicy.contains( + "Do not deepen an invalid stack or modify a blocked stack branch merely to appear " + + "productive." + )); + assertTrue(agentPolicy.contains( + "The independent slice must not depend on, retarget, rewrite, or overlap files " + + "changed by the invalid stack." + )); + } + + /** Requires root-cause evidence and a realistic execution decision before remediation. */ + @Test + void performsRootCauseAnalysisAndFeasibilityClassificationBeforeActing() { + assertTrue(workflow.contains( + "For every failing or blocked outcome, perform root-cause analysis before choosing " + + "a remediation." + )); + assertTrue(workflow.contains( + "source, configuration, permission, quota, runner, provider, dependency, or policy " + + "boundary" + )); + assertTrue(workflow.contains( + "Generate bounded remediation options that address the identified cause." + )); + assertTrue(workflow.contains( + "test each option's feasibility against current permissions, branch protection, " + + "tool capability, runtime and compute budgets, dependency state, and path " + + "ownership" + )); + assertTrue(workflow.contains( + "Classify each option as executable now, requires an external actor, or unsafe or " + + "infeasible." + )); + } + + /** Requires the scheduler to act, verify, and continue after an infeasible preferred option. */ + @Test + void executesTheBestFeasibleActionAndContinuesAfterExternalOnlyBlockers() { + assertTrue(workflow.contains( + "Execute the highest-impact safe option that is executable in this run" + )); + assertTrue(workflow.contains("rerun the exact failing test or gate")); + assertTrue(workflow.contains( + "If the preferred option requires an external actor or is infeasible, keep that gate " + + "fail-closed and immediately choose the next safe feasible non-overlapping " + + "remediation or independent bounded product slice instead of stopping." + )); + assertTrue(workflow.contains( + "A pull request with only external blockers is not source-actionable." + )); + assertTrue(workflow.contains( + "When no open pull request is source-actionable, whether or not blocked pull requests " + + "remain open" + )); + } + + /** Requires every completed or deferred action to return to a live work-conserving queue. */ + @Test + void treatsEveryActionAsIntermediateAndReturnsToTheExecutableQueue() { + assertTrue(workflow.contains( + "Completing an action is intermediate state, not an invocation endpoint." + )); + assertTrue(workflow.contains( + "After every remediation, commit, documentation update, test result, deferred " + + "blocker, or completed slice, return to the highest-value safe executable " + + "queue." + )); + assertTrue(workflow.contains( + "The one remote publication candidate limit constrains mutation output, not further " + + "read-only diagnosis, testing, or documentation analysis after a candidate " + + "is prepared." + )); + assertTrue(workflow.contains( + "Queued checks, reviews, and provider waits are local deferred items, not reasons to " + + "idle." + )); + assertTrue(workflow.contains( + "Same-branch writer movement freezes only that branch; continue safe work on other " + + "non-overlapping branches or read-only lanes." + )); + } + + /** Requires two clean fresh exit sweeps before finite-run termination. */ + @Test + void requiresDoubleFreshExitSweepBeforeTermination() { + assertTrue(workflow.contains( + "Before terminating, perform a fresh whole-repository sweep of pull requests, " + + "issues, checks, reviews, security, stack ancestry, documentation, release " + + "readiness, and product gaps." + )); + assertTrue(workflow.contains( + "If that sweep finds any safe executable item, execute the highest-value item and " + + "restart the exit sweep count." + )); + assertTrue(workflow.contains( + "Terminate only on genuine finite run-budget exhaustion or after a second " + + "consecutive fresh sweep proves no safe executable action remains." + )); + assertTrue(workflow.contains("Routine status narration is not work.")); + } + + /** Keeps every separately leased repository outside this scheduler's write authority. */ + @Test + void preservesReadOnlyDependencyLeasesWhileContinuingLocalWork() { + assertTrue(agentPolicy.contains( + "ContextualWisdomLab/.github, naruon, contextual-orchestrator, and every separately " + + "leased repository remain read-only." + )); + assertTrue(agentPolicy.contains( + "Inspect their exact integration state, but never mutate, dispatch a write-capable " + + "agent, or post a mutation-trigger comment there." + )); + assertTrue(workflow.contains("Checkout protected default-branch source")); + assertTrue(workflow.contains("opencode run --model \"${MODEL}\" --auto")); + assertTrue(workflow.contains("Use the repository-scoped token for read operations only")); + } + + /** + * Collapses semantically irrelevant Markdown and platform whitespace before phrase matching. + * + * @param value UTF-8 text whose prose contract must be compared independently of wrapping + * @return one-space canonical representation + */ + private static String canonicalWhitespace(String value) { + return value.replaceAll("\\s+", " ").trim(); + } + + /** + * Finds the repository root from either reactor-root or module-local Maven execution. + * + * @return absolute repository root + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} \ No newline at end of file diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeRequiredWorkflowAuthorizationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeRequiredWorkflowAuthorizationTest.java new file mode 100644 index 00000000..3e38d1aa --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyOpenCodeRequiredWorkflowAuthorizationTest.java @@ -0,0 +1,170 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards complete exact-head workflow-run materialization after an OpenCode branch update. + * + *

GitHub can create approval-required pull-request runs asynchronously. Stopping after the first + * run appears can leave later CI or security workflows waiting forever. The isolated authorization + * job must therefore continue discovering and authorizing runs until every named pull-request + * workflow required by mightyETL has materialized for the unchanged exact head.

+ */ +class HourlyOpenCodeRequiredWorkflowAuthorizationTest { + + /** + * Requires the authorization loop to wait for, structurally associate, and account for every + * workflow. The polling count and terminal diagnostic text are intentionally pinned because + * they are part of the bounded authorization and operator-diagnostic contract. + * + * @throws IOException when the production workflow cannot be read + */ + @Test + void waitsForEveryRequiredExactHeadWorkflow() throws IOException { + String workflow = workflow(); + String associatedRunSelection = between( + workflow, + " jq -c \\\n --arg expected_head", + " ' \"${all_runs_file}\" > \"${runs_file}\"" + ); + String approvalLoop = between( + workflow, + " while IFS= read -r run_id; do", + " observed_workflow_names=" + ); + + assertTrue(workflow.contains( + "required_workflow_names='[\"CI\",\"Dependency Review\"," + + "\"SBOM (CycloneDX)\",\"SAST Semgrep\",\"Security Scan\"]'" + )); + assertTrue(workflow.contains("observed_workflow_names")); + assertTrue(workflow.contains("missing_workflow_names")); + assertTrue(workflow.contains("for _ in $(seq 1 18); do")); + assertTrue( + associatedRunSelection.contains("select(.head_sha == $expected_head)"), + "The associated-run selection must bind the exact expected head" + ); + assertTrue( + associatedRunSelection.contains( + "select(any(.pull_requests[]?; .number == $pull_request_number))" + ), + "The same associated-run selection must bind the exact pull-request number" + ); + assertAppearsBefore( + approvalLoop, + "gh api --method POST \"/repos/${repository}/actions/runs/${run_id}/approve\"", + "printf '%s\\n' \"${run_id}\" >> \"${approved_run_ids_file}\"" + ); + assertTrue(workflow.contains("jq 'length' <<<\"${missing_workflow_names}\"")); + assertTrue(workflow.contains("Missing required exact-head workflows for PR")); + assertTrue(workflow.contains("Authorized exact-head pull-request checks for PR")); + } + + /** + * Requires successful approvals to be remembered across polling passes so eventual-consistency + * lag cannot make the authorizer POST the same run twice and fail under {@code set -e}. + * + * @throws IOException when the production workflow cannot be read + */ + @Test + void doesNotApproveTheSameWorkflowRunTwiceAcrossPollingPasses() throws IOException { + String workflow = workflow(); + + assertTrue( + workflow.contains( + "approved_run_ids_file=\"${RUNNER_TEMP}/pr-${number}-approved-run-ids.txt\"" + ), + "Authorization must keep one approved-run ledger for the overall polling operation" + ); + assertTrue( + workflow.contains(": > \"${approved_run_ids_file}\""), + "The approved-run ledger must be initialized once before polling begins" + ); + assertTrue( + workflow.contains( + "if grep -Fxq \"${run_id}\" \"${approved_run_ids_file}\"; then" + ), + "Polling must skip a run id that was already approved on an earlier pass" + ); + assertTrue( + workflow.contains( + "printf '%s\\n' \"${run_id}\" >> \"${approved_run_ids_file}\"" + ), + "A run id must be recorded only after its approval request succeeds" + ); + } + + /** + * Requires one marker to occur before another in the same authorization section. + * + * @param text authorization section being checked + * @param first marker that must execute first + * @param second marker that must execute later + */ + private static void assertAppearsBefore(String text, String first, String second) { + int firstIndex = text.indexOf(first); + int secondIndex = text.indexOf(second); + assertTrue(firstIndex >= 0, () -> "Missing first marker: " + first); + assertTrue(secondIndex > firstIndex, () -> "Marker must appear after first marker: " + second); + } + + /** + * Extracts a required text section between two ordered markers. + * + * @param text complete source text + * @param startMarker inclusive section marker + * @param endMarker exclusive section marker + * @return required section text + */ + private static String between(String text, String startMarker, String endMarker) { + int start = text.indexOf(startMarker); + assertTrue(start >= 0, () -> "Missing start marker: " + startMarker); + int end = text.indexOf(endMarker, start + startMarker.length()); + assertTrue(end > start, () -> "Missing end marker after start: " + endMarker); + return text.substring(start, end); + } + + /** + * Reads the production workflow with normalized line endings. + * + * @return UTF-8 workflow source + * @throws IOException when the production workflow cannot be read + */ + private static String workflow() throws IOException { + return Files.readString( + projectRoot().resolve(".github/workflows/hourly-opencode-maintenance.yml"), + StandardCharsets.UTF_8 + ).replace("\r\n", "\n"); + } + + /** + * Finds the repository root from root- or module-scoped Maven execution. + * + * @return absolute repository root + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyPrDispositionWorkflowTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyPrDispositionWorkflowTest.java index b81fb429..d320f0f6 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyPrDispositionWorkflowTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyPrDispositionWorkflowTest.java @@ -44,6 +44,19 @@ void requiresLatestDecisiveReviewState() { assertTrue(workflow.contains("latest decisive review state includes requested changes")); } + /** + * Prevents unattended merge when nobody other than the pull-request author has approved the + * exact current head. An approval anchored to an older commit is stale evidence and must not + * authorize a newer head. + */ + @Test + void requiresIndependentApprovalForTheExactCurrentHead() { + assertTrue(workflow.contains("independent_exact_head_approvals")); + assertTrue(workflow.contains(".user.login != $author")); + assertTrue(workflow.contains(".commit_id == $head_sha")); + assertTrue(workflow.contains("independent exact-head approval is absent")); + } + @Test void requiresResolvedCurrentReviewThreads() { assertTrue(workflow.contains("reviewThreads(first: 100, after: $endCursor)")); diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonBomResolutionTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonBomResolutionTest.java new file mode 100644 index 00000000..a14e25a1 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonBomResolutionTest.java @@ -0,0 +1,86 @@ +package com.xtrmetl.etl.documentation; + +import com.fasterxml.jackson.databind.cfg.PackageVersion; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the patched Jackson bill of materials is both declared with effective Maven + * precedence and actually resolved on the test runtime classpath. + * + *

A property in a project that imports Spring Boot's dependency BOM does not override the + * imported BOM's internal property interpolation. The patched Jackson BOM therefore has to be + * imported explicitly before Spring Boot's BOM. The runtime assertion catches configuration that + * looks correct in source but still resolves a vulnerable Jackson Databind version.

+ */ +class JacksonBomResolutionTest { + + private static final Pattern JACKSON_VERSION_PROPERTY = Pattern.compile( + "([^<]+)" + ); + + /** + * Requires explicit Jackson BOM precedence and exact runtime resolution to the configured + * patched component line. + * + * @throws Exception when the root Maven model cannot be read as UTF-8 text + */ + @Test + void importsAndResolvesTheConfiguredPatchedJacksonBom() throws Exception { + String rootPom = Files.readString( + projectRoot().resolve("pom.xml"), + StandardCharsets.UTF_8 + ); + Matcher versionMatcher = JACKSON_VERSION_PROPERTY.matcher(rootPom); + assertTrue(versionMatcher.find(), "The root POM must declare jackson-bom.version"); + String configuredVersion = versionMatcher.group(1).trim(); + + int jacksonBom = rootPom.indexOf("jackson-bom"); + int springBootBom = rootPom.indexOf( + "spring-boot-dependencies" + ); + assertTrue(jacksonBom >= 0, "The Jackson BOM must be imported explicitly"); + assertTrue( + jacksonBom < springBootBom, + "The Jackson BOM import must precede Spring Boot dependency management" + ); + assertEquals( + configuredVersion, + PackageVersion.VERSION.toString(), + "The resolved jackson-databind version must match jackson-bom.version" + ); + } + + /** + * Finds the repository root from either root or module-local Maven execution. + * + * @return absolute repository root containing the root Maven project + * @throws IllegalStateException when no repository or Maven root can be found + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityVersionTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityVersionTest.java new file mode 100644 index 00000000..2f0fd23c --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/JacksonSecurityVersionTest.java @@ -0,0 +1,192 @@ +package com.xtrmetl.etl.documentation; + +import com.fasterxml.jackson.databind.cfg.PackageVersion; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Prevents the Maven dependency graph from returning to Jackson Databind versions affected by + * the 2026 creator-property authorization-bypass advisories. + * + *

The root project imports the patched FasterXML BOM before Spring Boot's dependency BOM so + * Maven's first-declaration precedence keeps the complete Jackson component set on one compatible + * security line. These tests verify the configured version, the explicit import and its order, and + * the Jackson Databind version that the test runtime actually resolved.

+ */ +class JacksonSecurityVersionTest { + + private static final String PATCHED_JACKSON_BOM_VERSION = "2.21.5"; + + /** + * Requires the root Maven project to declare the reviewed patched Jackson BOM version. + * + * @throws Exception when the root Maven model cannot be parsed securely + */ + @Test + void usesPatchedCompatibleJacksonBom() throws Exception { + Document document = rootPomDocument(); + Element version = (Element) document.getElementsByTagName("jackson-bom.version").item(0); + assertNotNull( + version, + "The root POM must declare jackson-bom.version so every Jackson module is aligned" + ); + assertEquals(PATCHED_JACKSON_BOM_VERSION, version.getTextContent().trim()); + } + + /** + * Requires the explicit Jackson BOM import to precede Spring Boot's broader dependency BOM. + * + *

Maven uses the first declaration when imported dependency-management entries overlap. + * Therefore, merely declaring {@code jackson-bom.version} is insufficient unless the Jackson + * BOM is imported explicitly before Spring Boot's BOM.

+ * + * @throws Exception when the root Maven model cannot be parsed securely + */ + @Test + void importsJacksonBomBeforeSpringBootBom() throws Exception { + Document document = rootPomDocument(); + Element dependencyManagement = (Element) document + .getElementsByTagName("dependencyManagement") + .item(0); + assertNotNull(dependencyManagement, "The root POM must declare dependencyManagement"); + Element dependencies = directChild(dependencyManagement, "dependencies"); + assertNotNull(dependencies, "dependencyManagement must contain dependencies"); + + int jacksonBomIndex = -1; + int springBootBomIndex = -1; + Element jacksonBom = null; + int dependencyIndex = 0; + NodeList children = dependencies.getChildNodes(); + for (int index = 0; index < children.getLength(); index++) { + Node child = children.item(index); + if (!(child instanceof Element dependency) + || !"dependency".equals(dependency.getTagName())) { + continue; + } + String groupId = childText(dependency, "groupId"); + String artifactId = childText(dependency, "artifactId"); + if ("com.fasterxml.jackson".equals(groupId) + && "jackson-bom".equals(artifactId)) { + jacksonBomIndex = dependencyIndex; + jacksonBom = dependency; + } + if ("org.springframework.boot".equals(groupId) + && "spring-boot-dependencies".equals(artifactId)) { + springBootBomIndex = dependencyIndex; + } + dependencyIndex++; + } + + assertTrue(jacksonBomIndex >= 0, "The root POM must explicitly import jackson-bom"); + assertTrue(springBootBomIndex >= 0, "The root POM must import Spring Boot dependencies"); + assertTrue( + jacksonBomIndex < springBootBomIndex, + "jackson-bom must appear before Spring Boot's BOM to retain Maven precedence" + ); + assertNotNull(jacksonBom, "The located Jackson BOM dependency must be available"); + assertEquals("${jackson-bom.version}", childText(jacksonBom, "version")); + assertEquals("pom", childText(jacksonBom, "type")); + assertEquals("import", childText(jacksonBom, "scope")); + } + + /** + * Requires the resolved Jackson Databind artifact to match the reviewed BOM security line. + */ + @Test + void resolvesPatchedJacksonDatabindVersion() { + assertEquals( + PATCHED_JACKSON_BOM_VERSION, + PackageVersion.VERSION.toString(), + "The resolved jackson-databind version must match jackson-bom.version" + ); + } + + /** + * Parses the root Maven model with external entities and external schemas disabled. + * + * @return securely parsed root Maven document + * @throws Exception when the root Maven model cannot be parsed + */ + private static Document rootPomDocument() throws Exception { + Path rootPom = projectRoot().resolve("pom.xml"); + assertTrue(Files.exists(rootPom), "The root Maven POM must exist"); + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder().parse(rootPom.toFile()); + } + + /** + * Finds one direct child element by its tag name. + * + * @param parent parent element to inspect + * @param tagName required child tag name + * @return matching direct child, or {@code null} when absent + */ + private static Element directChild(Element parent, String tagName) { + NodeList children = parent.getChildNodes(); + for (int index = 0; index < children.getLength(); index++) { + Node child = children.item(index); + if (child instanceof Element element && tagName.equals(element.getTagName())) { + return element; + } + } + return null; + } + + /** + * Reads and trims one required direct child value. + * + * @param parent dependency element + * @param tagName child element name + * @return trimmed child text + */ + private static String childText(Element parent, String tagName) { + Element child = directChild(parent, tagName); + assertNotNull(child, "Expected child element " + tagName); + return child.getTextContent().trim(); + } + + /** + * Finds the repository root from either root or module-local Maven execution. + * + * @return absolute repository root containing the root Maven project + * @throws IllegalStateException when no repository or Maven root can be found + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCoveragePolicyTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCoveragePolicyTest.java index 4a728d75..74fa5e0a 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCoveragePolicyTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobCoveragePolicyTest.java @@ -1,55 +1,211 @@ package com.xtrmetl.etl.job; import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.HashSet; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Keeps the durable-job production slice bound to an executable 100% coverage policy. + * Keeps the durable-job production slice bound to an executable, non-empty 100% coverage policy. * - *

The policy is intentionally scoped to the production classes introduced by the durable-job - * intake slice. It requires current Java-compatible JaCoCo instrumentation and zero missed - * instructions, lines, methods, or branches while the ordinary {@code mvn test} lifecycle runs.

+ *

JaCoCo's agent instrumentation filters and Maven report filters consume different name + * forms. A plugin-wide dotted include can therefore match neither compiled class-file paths nor + * the names seen by the agent, creating a report with zero analyzed classes that still satisfies + * zero-missed rules vacuously. This contract requires unrestricted test instrumentation, + * execution-specific class-file filters, and an explicit non-empty bundle check before the + * zero-missed instruction, line, method, and branch rules can pass.

*/ class EtlJobCoveragePolicyTest { + private static final Set DURABLE_JOB_CLASS_FILES = Set.of( + "com/xtrmetl/etl/job/*.class", + "com/xtrmetl/etl/controller/EtlJobController*.class", + "com/xtrmetl/etl/service/Sha256Digest*.class" + ); + /** - * Requires the ETL module build to fail when any durable-job production path is untested. + * Requires the ETL module build to analyze at least one intended production class and fail + * when any analyzed durable-job path is untested. * * @throws IOException when the module build descriptor cannot be read + * @throws ParserConfigurationException when the JDK XML parser cannot be created + * @throws SAXException when the Maven descriptor is not well-formed XML */ @Test - void etlModuleEnforcesCompleteInstructionAndBranchCoverageForTheDurableJobSlice() - throws IOException { - String modulePom = read("etl-service/pom.xml"); - - assertTrue(modulePom.contains("jacoco-maven-plugin")); - assertTrue(modulePom.contains("0.8.15")); - assertTrue(modulePom.contains("initialize")); - assertTrue(modulePom.contains("prepare-agent")); - assertTrue(modulePom.contains("test")); - assertTrue(modulePom.contains("report")); - assertTrue(modulePom.contains("check")); - assertTrue(modulePom.contains("com.xtrmetl.etl.job.*")); - assertTrue(modulePom.contains( + void etlModuleEnforcesNonEmptyCompleteCoverageForTheDurableJobSlice() + throws IOException, ParserConfigurationException, SAXException { + Document modulePom = parseModulePom(); + Element jacocoPlugin = findPlugin(modulePom, "jacoco-maven-plugin"); + + assertEquals("0.8.15", directText(jacocoPlugin, "version")); + Element pluginConfiguration = directChild(jacocoPlugin, "configuration"); + assertTrue( + pluginConfiguration == null || directChild(pluginConfiguration, "includes") == null, + "JaCoCo includes must not be shared across agent and report goals" + ); + + Element prepareExecution = findExecution(jacocoPlugin, "prepare-durable-job-coverage"); + assertEquals("initialize", directText(prepareExecution, "phase")); + assertTrue(goalNames(prepareExecution).contains("prepare-agent")); + Element prepareConfiguration = directChild(prepareExecution, "configuration"); + assertTrue( + prepareConfiguration == null || directChild(prepareConfiguration, "includes") == null, + "The test agent must instrument all application classes; report filtering is separate" + ); + + Element reportExecution = findExecution(jacocoPlugin, "report-durable-job-coverage"); + assertEquals("test", directText(reportExecution, "phase")); + assertTrue(goalNames(reportExecution).contains("report")); + assertConfiguredIncludes(reportExecution); + + Element checkExecution = findExecution(jacocoPlugin, "check-durable-job-coverage"); + assertEquals("test", directText(checkExecution, "phase")); + assertTrue(goalNames(checkExecution).contains("check")); + assertConfiguredIncludes(checkExecution); + assertTrue(hasLimit(checkExecution, "BUNDLE", "INSTRUCTION", "TOTALCOUNT", "minimum", "1")); + + for (String counter : Set.of("INSTRUCTION", "LINE", "METHOD", "BRANCH")) { + assertTrue( + hasLimit(checkExecution, "CLASS", counter, "MISSEDCOUNT", "maximum", "0"), + () -> "Missing zero-missed class rule for " + counter + ); + } + + String serializedPom = Files.readString(projectRoot().resolve("etl-service/pom.xml")); + assertFalse(serializedPom.contains("com.xtrmetl.etl.job.*")); + assertFalse(serializedPom.contains( "com.xtrmetl.etl.controller.EtlJobController*" )); - assertTrue(modulePom.contains("INSTRUCTION")); - assertTrue(modulePom.contains("LINE")); - assertTrue(modulePom.contains("METHOD")); - assertTrue(modulePom.contains("BRANCH")); - assertTrue(modulePom.contains("MISSEDCOUNT")); - assertTrue(modulePom.contains("0")); + assertFalse(serializedPom.contains( + "com.xtrmetl.etl.service.Sha256Digest*" + )); + } + + private static void assertConfiguredIncludes(Element execution) { + assertEquals( + DURABLE_JOB_CLASS_FILES.stream().sorted().toList(), + configuredIncludes(execution).stream().sorted().toList() + ); + } + + private static Document parseModulePom() + throws ParserConfigurationException, IOException, SAXException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder().parse(projectRoot().resolve("etl-service/pom.xml").toFile()); + } + + private static Element findPlugin(Document document, String artifactId) { + NodeList plugins = document.getElementsByTagName("plugin"); + for (int index = 0; index < plugins.getLength(); index++) { + Element plugin = (Element) plugins.item(index); + if (artifactId.equals(directText(plugin, "artifactId"))) { + return plugin; + } + } + throw new AssertionError("Missing Maven plugin " + artifactId); + } + + private static Element findExecution(Element plugin, String executionId) { + NodeList executions = plugin.getElementsByTagName("execution"); + for (int index = 0; index < executions.getLength(); index++) { + Element execution = (Element) executions.item(index); + if (executionId.equals(directText(execution, "id"))) { + return execution; + } + } + throw new AssertionError("Missing JaCoCo execution " + executionId); + } + + private static Set goalNames(Element execution) { + Element goals = directChild(execution, "goals"); + assertNotNull(goals, "Every JaCoCo execution must declare goals"); + return directTexts(goals, "goal"); + } + + private static Set configuredIncludes(Element execution) { + Element configuration = directChild(execution, "configuration"); + assertNotNull(configuration, "Report and check executions require explicit configuration"); + Element includes = directChild(configuration, "includes"); + assertNotNull(includes, "Report and check executions require class-file include patterns"); + return directTexts(includes, "include"); } - private static String read(String relativePath) throws IOException { - return Files.readString(projectRoot().resolve(relativePath), StandardCharsets.UTF_8); + private static boolean hasLimit( + Element execution, + String elementName, + String counter, + String value, + String boundName, + String boundValue + ) { + Element configuration = directChild(execution, "configuration"); + assertNotNull(configuration); + NodeList rules = configuration.getElementsByTagName("rule"); + for (int ruleIndex = 0; ruleIndex < rules.getLength(); ruleIndex++) { + Element rule = (Element) rules.item(ruleIndex); + if (!elementName.equals(directText(rule, "element"))) { + continue; + } + NodeList limits = rule.getElementsByTagName("limit"); + for (int limitIndex = 0; limitIndex < limits.getLength(); limitIndex++) { + Element limit = (Element) limits.item(limitIndex); + if (counter.equals(directText(limit, "counter")) + && value.equals(directText(limit, "value")) + && boundValue.equals(directText(limit, boundName))) { + return true; + } + } + } + return false; + } + + private static Set directTexts(Element parent, String childName) { + Set values = new HashSet<>(); + NodeList children = parent.getChildNodes(); + for (int index = 0; index < children.getLength(); index++) { + Node child = children.item(index); + if (child instanceof Element element && childName.equals(element.getTagName())) { + values.add(element.getTextContent().trim()); + } + } + return Set.copyOf(values); + } + + private static String directText(Element parent, String childName) { + Element child = directChild(parent, childName); + return child == null ? null : child.getTextContent().trim(); + } + + private static Element directChild(Element parent, String childName) { + NodeList children = parent.getChildNodes(); + for (int index = 0; index < children.getLength(); index++) { + Node child = children.item(index); + if (child instanceof Element element && childName.equals(element.getTagName())) { + return element; + } + } + return null; } private static Path projectRoot() { diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobServiceBoundaryTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobServiceBoundaryTest.java index 6ce140d6..3d524649 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobServiceBoundaryTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobServiceBoundaryTest.java @@ -110,6 +110,10 @@ void rejectsEveryCoveredPayloadAdmissionFailureBeforePersistence() { EtlRequestError.INVALID_JSON, () -> service.submit(null, IDEMPOTENCY_KEY, "tenant_alpha") ); + assertError( + EtlRequestError.INVALID_JSON, + () -> service.submit("", IDEMPOTENCY_KEY, "tenant_alpha") + ); assertError( EtlRequestError.INVALID_JSON, () -> service.submit("null", IDEMPOTENCY_KEY, "tenant_alpha") @@ -134,6 +138,10 @@ void rejectsEveryCoveredPayloadAdmissionFailureBeforePersistence() { "tenant_alpha" ) ); + assertError( + EtlRequestError.INVALID_RECORD, + () -> EtlJobService.validateRecord(null) + ); assertError( EtlRequestError.INVALID_RECORD, () -> service.submit("[null]", IDEMPOTENCY_KEY, "tenant_alpha") @@ -154,10 +162,38 @@ void rejectsEveryCoveredPayloadAdmissionFailureBeforePersistence() { EtlRequestError.INVALID_RECORD, () -> service.submit("[{\"id\":\" record_alpha\"}]", IDEMPOTENCY_KEY, "tenant_alpha") ); + assertError( + EtlRequestError.INVALID_RECORD, + () -> service.submit( + "[{\"id\":\"\u00a0record_alpha\"}]", + IDEMPOTENCY_KEY, + "tenant_alpha" + ) + ); + assertError( + EtlRequestError.INVALID_RECORD, + () -> service.submit( + "[{\"id\":\"" + "x".repeat(257) + "\"}]", + IDEMPOTENCY_KEY, + "tenant_alpha" + ) + ); assertError( EtlRequestError.INVALID_RECORD, () -> service.submit("[{\"id\":\"record\\u0000alpha\"}]", IDEMPOTENCY_KEY, "tenant_alpha") ); + assertError( + EtlRequestError.INVALID_RECORD, + () -> service.submit("[{\"id\":\"record\\u200dalpha\"}]", IDEMPOTENCY_KEY, "tenant_alpha") + ); + assertError( + EtlRequestError.INVALID_RECORD, + () -> service.submit("[{\"id\":\"record\\u2028alpha\"}]", IDEMPOTENCY_KEY, "tenant_alpha") + ); + assertError( + EtlRequestError.INVALID_RECORD, + () -> service.submit("[{\"id\":\"record\\u2029alpha\"}]", IDEMPOTENCY_KEY, "tenant_alpha") + ); assertError( EtlRequestError.INVALID_RECORD, () -> service.submit( diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobServiceCoverageCompletionTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobServiceCoverageCompletionTest.java new file mode 100644 index 00000000..442199b0 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobServiceCoverageCompletionTest.java @@ -0,0 +1,153 @@ +package com.xtrmetl.etl.job; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xtrmetl.etl.service.EtlBatchProperties; +import com.xtrmetl.etl.service.EtlRequestError; +import com.xtrmetl.etl.service.EtlRequestException; +import com.xtrmetl.etl.service.EtlRequestLock; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Exercises realistic durable-intake validation boundaries that are easy to miss in ordinary + * happy-path and persistence tests. + * + *

The cases model a parser that reaches end-of-input without producing a tree, intentionally + * empty batches, visually ambiguous Unicode identifiers, identifiers that exceed the documented + * code-point bound, and separators that can alter how an identifier appears in logs or text tools. + * Every rejection must happen before transaction, lock, or database work so an invalid client + * request cannot consume shared persistence capacity.

+ */ +class EtlJobServiceCoverageCompletionTest { + + private static final String IDEMPOTENCY_KEY = "550e8400-e29b-41d4-a716-446655440000"; + private static final String PRINCIPAL_SCOPE = "tenant_alpha"; + + /** + * Verifies the defensive parser-end-of-input boundary independently of Jackson version-specific + * empty-string behavior. A copied application mapper is allowed to return no tree, and the + * service must classify that result as invalid JSON before transaction, lock, or database work. + * + * @throws Exception when Mockito cannot configure the checked parser method + */ + @Test + void rejectsParserEndOfInputWithoutAJsonTreeBeforePersistence() throws Exception { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + EtlRequestLock requestLock = mock(EtlRequestLock.class); + ObjectMapper sourceMapper = mock(ObjectMapper.class); + ObjectMapper copiedMapper = mock(ObjectMapper.class); + when(sourceMapper.copy()).thenReturn(copiedMapper); + when(copiedMapper.readTree("parser-end-of-input")).thenReturn(null); + EtlJobService service = new EtlJobService( + jdbcTemplate, + sourceMapper, + new EtlBatchProperties(), + requestLock + ); + + EtlRequestException exception = assertThrows( + EtlRequestException.class, + () -> service.submit("parser-end-of-input", IDEMPOTENCY_KEY, PRINCIPAL_SCOPE) + ); + + assertEquals(EtlRequestError.INVALID_JSON, exception.error()); + verifyNoInteractions(requestLock, jdbcTemplate); + } + + /** + * Verifies that a valid empty JSON batch completes record validation and reaches the transaction + * boundary without attempting a lock or database operation. Empty batches are distinct from + * empty request bodies: they are well-formed arrays containing zero records and therefore + * exercise the no-iteration path of whole-batch prevalidation. + */ + @Test + void acceptsEmptyBatchThroughValidationBeforeRequiringTransaction() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + EtlRequestLock requestLock = mock(EtlRequestLock.class); + EtlJobService service = service(jdbcTemplate, requestLock); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> service.submit("[]", IDEMPOTENCY_KEY, PRINCIPAL_SCOPE) + ); + + assertEquals( + "Durable ETL job submission requires an active transaction", + exception.getMessage() + ); + verifyNoInteractions(requestLock, jdbcTemplate); + } + + /** + * Verifies identifier rejection for Unicode boundary whitespace, format controls, line and + * paragraph separators, and the documented 256-code-point maximum. + * + * @param identifier identifier embedded in an otherwise valid one-record JSON batch + * @param scenario beginner-readable reason that the case is unsafe + */ + @ParameterizedTest(name = "{1}") + @MethodSource("unsafeIdentifiers") + void rejectsUnsafeOrOverlongIdentifiersBeforeTransactionOrPersistence( + String identifier, + String scenario + ) { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + EtlRequestLock requestLock = mock(EtlRequestLock.class); + EtlJobService service = service(jdbcTemplate, requestLock); + String payload = "[{\"id\":" + new ObjectMapper().valueToTree(identifier) + "}]"; + + EtlRequestException exception = assertThrows( + EtlRequestException.class, + () -> service.submit(payload, IDEMPOTENCY_KEY, PRINCIPAL_SCOPE), + scenario + ); + + assertEquals(EtlRequestError.INVALID_RECORD, exception.error()); + verifyNoInteractions(requestLock, jdbcTemplate); + } + + /** + * Supplies concrete identifier attacks and admission-bound violations. + * + * @return parameter stream containing identifier text and its operational threat + */ + private static Stream unsafeIdentifiers() { + return Stream.of( + Arguments.of("\u00a0record_alpha", "non-breaking boundary whitespace"), + Arguments.of("x".repeat(257), "identifier longer than 256 code points"), + Arguments.of("record\u200balpha", "zero-width format control"), + Arguments.of("record\u2028alpha", "Unicode line separator"), + Arguments.of("record\u2029alpha", "Unicode paragraph separator") + ); + } + + /** + * Creates a service whose lock and database collaborators reveal any premature side effect. + * + * @param jdbcTemplate mocked database collaborator + * @param requestLock mocked transaction-lifetime lock collaborator + * @return service configured with normal production admission limits + */ + private static EtlJobService service( + JdbcTemplate jdbcTemplate, + EtlRequestLock requestLock + ) { + return new EtlJobService( + jdbcTemplate, + new ObjectMapper(), + new EtlBatchProperties(), + requestLock + ); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/service/Sha256DigestTest.java b/etl-service/src/test/java/com/xtrmetl/etl/service/Sha256DigestTest.java new file mode 100644 index 00000000..55401a48 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/service/Sha256DigestTest.java @@ -0,0 +1,55 @@ +package com.xtrmetl.etl.service; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.security.NoSuchAlgorithmException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies deterministic lowercase SHA-256 text identities and fail-closed runtime handling. + */ +class Sha256DigestTest { + + @Test + void producesThePublishedSha256Vector() { + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + Sha256Digest.digest("abc") + ); + } + + @Test + void rejectsMissingInputOrFactory() { + assertThrows(NullPointerException.class, () -> Sha256Digest.digest(null)); + assertThrows( + NullPointerException.class, + () -> Sha256Digest.digest("abc", null) + ); + } + + @Test + void convertsMissingMandatoryAlgorithmIntoBrokenRuntimeSignal() { + NoSuchAlgorithmException missingAlgorithm = new NoSuchAlgorithmException("missing"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> Sha256Digest.digest("abc", () -> { + throw missingAlgorithm; + }) + ); + + assertEquals("SHA-256 is required by the Java platform", exception.getMessage()); + assertInstanceOf(NoSuchAlgorithmException.class, exception.getCause()); + } + + @Test + void utilityConstructorCannotBeCalledNormallyButRemainsCovered() throws Exception { + Constructor constructor = Sha256Digest.class.getDeclaredConstructor(); + constructor.setAccessible(true); + constructor.newInstance(); + } +} diff --git a/pom.xml b/pom.xml index 7c0ee7c2..66720d8f 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,7 @@ 25 3.5.16 + 2.21.5 2025.0.3 42.7.12 3.3.16 @@ -33,6 +34,13 @@ + + com.fasterxml.jackson + jackson-bom + ${jackson-bom.version} + pom + import + org.springframework.boot spring-boot-dependencies