Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 348 additions & 0 deletions .github/workflows/run_sampler.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
# Reusable workflow. Runs the Liquid Sampler (silverfin-cli `run-sampler`) for a single
# partner's changed reconciliation/account templates and posts the result on a PR.
#
# This is deliberately partner-agnostic and repo-layout-agnostic: given a partner id, a list
# of already-classified handles/account templates, and firm ids, it loads that partner's
# credentials, runs the sampler, and reports back. All market-specific logic (which templates
# changed, which partner they belong to) lives in the calling wrapper — see
# docs/liquid_sampler.md and the CI_AUTH_SAMPLER_PLAN.md design notes (§6, §8) this implements.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion — new reusable workflow has no corresponding README/docs entry.

This file's own header points to docs/liquid_sampler.md, but that file doesn't exist anywhere in this repo, and the README's "Individual Action Documentation" section (which documents every other reusable workflow — check_auth.yml, run_tests.yml, slack_changelog.yml, push_to_review_firm.yml — each with Description/Trigger/Inputs/Steps/Prerequisites) has no section for run_sampler.yml. The most recent precedent (#24, adding push_to_review_firm.yml) updated README.md in the same PR. Not a functional blocker, but worth adding before/shortly after merge so this workflow doesn't become the one undocumented exception, and so the header comment's doc reference isn't dangling.

#
# Design decisions this encodes (see plan §6 D1-D9, §8):
# - Serialized per partner: the backend allows only one sampler run per partner at a time.
# `concurrency: { group, queue: max }` queues overlapping runs FIFO instead of cancelling —
# cancelling the CI job does NOT cancel the backend run, it just orphans it and blocks the
# partner until it clears.
# - Cross-repo collisions (two market repos sharing a partner id) can't be solved by GitHub
# Actions concurrency groups (they don't span repos), so a 422 "already in progress" from the
# backend is treated as the real mutex: poll-and-retry instead of failing immediately.
# - The partner api_key is a single rotate-on-401 credential stored in its own
# PARTNER_CONFIG_JSON_<partner> secret (never the firm CONFIG_JSON). This workflow is the
# sole writer of that secret, and only writes back if the on-disk token actually changed
# during the run (diffed before/after, not inferred from log output).
# - `results.zip` is uploaded as a short-lived workflow artifact (7 days) for the rare
# rendering-only regression the compact named_results diff can't see; the presigned report
# URL is the long-lived source of truth.
name: run-liquid-sampler
run-name: "Liquid sampler · partner ${{ inputs.partner }}"

on:
workflow_call:
inputs:
partner:
description: "Partner environment id (must be authorized — see PARTNER_CONFIG_JSON secret)"
required: true
type: string
handles:
description: "Reconciliation text handles to sample, space-separated (directory names under reconciliation_texts/). Optional if account_templates is set."
required: false
type: string
default: ""
account_templates:
description: "Account template names to sample, space-separated (directory names under account_templates/). Optional if handles is set."
required: false
type: string
default: ""
firm_ids:
description: "Firm id(s) to sample against, space-separated. The backend 422s if empty."
required: true
type: string
ref:
description: "Git ref (commit SHA) to check out — the PR head, so sampled template content matches the PR under review."
required: true
type: string
pull_request_number:
description: "PR number to post the result comment on. If empty, no comment is posted (results still upload as an artifact)."
required: false
type: string
default: ""
secrets:
SF_API_CLIENT_ID:
description: "Silverfin API OAuth client id (silverfin-cli needs it at startup)."
required: true
SF_API_SECRET:
description: "Silverfin API OAuth client secret."
required: true
SF_BASIC_AUTH:
description: "HTTP Basic credentials (base64) for the staging gateway. The CLI only sends this when it auto-detects a *.staging.getsilverfin.com host; a no-op elsewhere."
required: false
PARTNER_CONFIG_JSON:
description: "This partner's stored credentials (host + partnerCredentials.<partner>.token). Caller resolves the per-partner secret, e.g. secrets[format('PARTNER_CONFIG_JSON_{0}', matrix.partner)]."
required: true
REPO_ACCESS_TOKEN:
description: "PAT with repo scope, used to write the rotated partner token back via `gh secret set`."
required: true

permissions:
contents: read
pull-requests: write

# Lives inside the reusable workflow itself (not the wrapper) so every adopter gets the
# serialization for free with no way to forget it. `github.repository` here resolves to the
# CALLER's repo (GitHub Actions behavior for reusable workflows), so two different market repos
# each get their own group — see the cross-repo mitigation below for why that's not sufficient
# on its own.
concurrency:
group: sampler-${{ github.repository }}-${{ inputs.partner }}
# 'queue: max' is a GitHub Actions beta sub-key (FIFO, nothing cancelled). If a target repo's
# plan rejects it, fall back to `queue: single` or plain `cancel-in-progress: false` — never
# cancel-in-progress: true, since cancelling this job does not cancel the backend run.
queue: max

jobs:
run-sampler:
runs-on: ubuntu-latest
# Generous ceiling: a single run is ~30-60 min, plus up to ~90 min of poll-and-retry on a
# cross-repo 422 collision (see the retry loop below).
timeout-minutes: 180
# Job-level (not just on the sampler step): the CLI checks for these at process startup for
# every command, including a bare `-V` version probe - confirmed live (the "Install
# silverfin-cli" step failed with "Missing API credentials" before these were job-scoped).
env:
SF_API_CLIENT_ID: ${{ secrets.SF_API_CLIENT_ID }}
SF_API_SECRET: ${{ secrets.SF_API_SECRET }}
steps:
- name: Validate inputs
run: |
if [[ -z "${{ inputs.handles }}" && -z "${{ inputs.account_templates }}" ]]; then

@michieldegezelle michieldegezelle Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — two issues in this validation step

  1. Expression injection: ${{ inputs.handles }} / ${{ inputs.account_templates }} (this line) and ${{ inputs.firm_ids }} (line 110) are spliced directly into the run: script instead of being passed through env:. This is the classic GitHub Actions script-injection anti-pattern — the ${{ }} substitution happens as raw text before bash parses the script, so a value containing shell metacharacters becomes executable code with SF_API_CLIENT_ID/SF_API_SECRET already in scope via the job-level env:. It's inconsistent with this same file's own correct pattern two steps later (SAMPLER_PARTNER/SAMPLER_HANDLES passed via env:) and with this repo's existing convention (check_dependencies.yml passes pull_request_number via env:, never inline). The same pattern recurs at lines 142-143 (Load PARTNER_CONFIG_JSON), 151 (Capture ... before), 257/259/263 (Capture ... after / write-back), and 347 (Fail job) — see the review comment on line 266 for the highest-severity instance (it runs with a repo-write PAT active).

  2. Whitespace-only values slip through: this only checks -z (empty string). A caller passing a single space for handles/account_templates/firm_ids passes this validation, then word-splits to zero tokens later (HANDLE_ARGS=(-h ${SAMPLER_HANDLES}) etc.), leaving a dangling -h/-at/--firm-ids flag that silently consumes the next CLI token as its value instead of failing loudly here.

Fix for both:

env:
  HANDLES: ${{ inputs.handles }}
  ACCOUNT_TEMPLATES: ${{ inputs.account_templates }}
  FIRM_IDS: ${{ inputs.firm_ids }}
run: |
  if [[ -z "${HANDLES// /}" && -z "${ACCOUNT_TEMPLATES// /}" ]]; then
    echo "::error::Neither handles nor account_templates was supplied — nothing to sample."
    exit 1
  fi
  if [[ -z "${FIRM_IDS// /}" ]]; then
    echo "::error::firm_ids is empty. The sampler backend 422s without firm ids."
    exit 1
  fi

echo "::error::Neither handles nor account_templates was supplied — nothing to sample."
exit 1
fi
if [[ -z "${{ inputs.firm_ids }}" ]]; then
echo "::error::firm_ids is empty. The sampler backend 422s without firm ids."
exit 1
fi

- name: Checkout repository at PR head
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
fetch-depth: 1

Comment on lines +115 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Set persist-credentials: false on checkout.

This job never git pushes — the write-back path uses gh secret set with REPO_ACCESS_TOKEN (Lines 251-266), not the checkout token. Persisting the ephemeral GITHUB_TOKEN-derived credentials in the local git config on disk (zizmor's artipacked finding) is unnecessary here and needlessly widens the credential's exposure window on the runner.

Suggested fix
       - name: Checkout repository at PR head
         uses: actions/checkout@v6
         with:
           ref: ${{ inputs.ref }}
           fetch-depth: 1
+          persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Checkout repository at PR head
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
fetch-depth: 1
- name: Checkout repository at PR head
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
fetch-depth: 1
persist-credentials: false
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 115-119: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/run_sampler.yml around lines 115 - 120, Update the
Checkout repository at PR head step to set persist-credentials to false, leaving
the existing ref and fetch-depth settings unchanged.

Source: Linters/SAST tools

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22

- name: Install silverfin-cli
run: |
# Unpinned, matching every other production workflow's convention (none pin to a
# branch/SHA/tag) — relies on run-sampler + --compact + result-URL surfacing being on
# main (silverfin-cli PR #261, "agustin-bso-sampler-easy-results", merged 2026-07-15).
npm install https://github.com/silverfin/silverfin-cli.git
VERSION=$(node ./node_modules/silverfin-cli/bin/cli.js -V)
echo "CLI version: ${VERSION}"

- name: Load PARTNER_CONFIG_JSON (host + partnerCredentials)
env:
PARTNER_CONFIG_JSON: ${{ secrets.PARTNER_CONFIG_JSON }}
run: |
echo "::add-mask::${PARTNER_CONFIG_JSON}"
mkdir -p "$HOME/.silverfin"
printf '%s' "${PARTNER_CONFIG_JSON}" > "$HOME/.silverfin/config.json"
if ! jq -e --arg p "${{ inputs.partner }}" '.partnerCredentials[$p].token' "$HOME/.silverfin/config.json" > /dev/null 2>&1; then
echo "::error::PARTNER_CONFIG_JSON has no partnerCredentials entry for partner ${{ inputs.partner }}. Authorize it first with 'silverfin authorize-partner -i ${{ inputs.partner }} -k <api-key>' and store the resulting config.json as this secret."
exit 1
fi
echo "Host: $(jq -r '.host // \"(default)\"' "$HOME/.silverfin/config.json")"

@michieldegezelle michieldegezelle Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion (confirmed bug, cosmetic): jq -r '.host // \"(default)\"' ... — inside single quotes, \" is two literal characters (backslash + quote), not an escaped quote; bash single-quoted strings don't support backslash escapes at all. Reproduced locally: this is a jq syntax error on every single run (prints a "jq: 1 compile error" to stderr) and the "Host: ..." diagnostic always prints blank instead of the configured host or the "(default)" fallback. It doesn't fail the step — the failing command substitution is embedded inside echo's argument, which doesn't trip errexit — but it defeats the diagnostic and adds log noise on every run.

echo "Host: $(jq -r '.host // "(default)"' "$HOME/.silverfin/config.json")"


- name: Capture partner token before the run
id: before
run: |
TOKEN=$(jq -r --arg p "${{ inputs.partner }}" '.partnerCredentials[$p].token' "$HOME/.silverfin/config.json")
echo "::add-mask::${TOKEN}"
echo "token=${TOKEN}" >> "$GITHUB_OUTPUT"

- name: Run liquid sampler (retries on a cross-repo 422 collision)
id: sampler
env:
SF_BASIC_AUTH: ${{ secrets.SF_BASIC_AUTH }}
SAMPLER_PARTNER: ${{ inputs.partner }}
SAMPLER_HANDLES: ${{ inputs.handles }}
SAMPLER_ACCOUNT_TEMPLATES: ${{ inputs.account_templates }}
SAMPLER_FIRM_IDS: ${{ inputs.firm_ids }}
run: |
# Intentional word-splitting: handles/account templates arrive as a single
# space-separated string and must become separate CLI args (shellcheck SC2206).
HANDLE_ARGS=()
# shellcheck disable=SC2206
[[ -n "${SAMPLER_HANDLES}" ]] && HANDLE_ARGS=(-h ${SAMPLER_HANDLES})
ACCOUNT_ARGS=()
# shellcheck disable=SC2206
[[ -n "${SAMPLER_ACCOUNT_TEMPLATES}" ]] && ACCOUNT_ARGS=(-at ${SAMPLER_ACCOUNT_TEMPLATES})

# GitHub Actions concurrency (group + queue: max) serializes runs within THIS repo, but
# concurrency groups do not span repositories — two market repos sharing this partner id
# can still collide. The backend's own one-run-per-partner 422 is the real cross-repo
# mutex, so treat it as a "try again shortly" signal, not a hard failure. Bounded to
# match the worst-case run length (~60 min) plus margin.
DEADLINE=$(( $(date +%s) + 90*60 ))
ATTEMPT=1
while true; do
echo "[$(date -u +%H:%M:%S)] run-sampler attempt ${ATTEMPT}: -p ${SAMPLER_PARTNER} ${HANDLE_ARGS[*]} ${ACCOUNT_ARGS[*]} --firm-ids ${SAMPLER_FIRM_IDS} --compact"
set +e
# shellcheck disable=SC2086 # SAMPLER_FIRM_IDS is intentionally word-split (space-separated ids -> separate args)
OUTPUT=$(node ./node_modules/silverfin-cli/bin/cli.js run-sampler -p "${SAMPLER_PARTNER}" "${HANDLE_ARGS[@]}" "${ACCOUNT_ARGS[@]}" --firm-ids ${SAMPLER_FIRM_IDS} --compact 2>&1 | tr -d '\r')
RC=$?
set -e
echo "----- run-sampler output (exit ${RC}) -----"
echo "$OUTPUT"
echo "--------------------------------------------"

if echo "$OUTPUT" | grep -qi "already in progress"; then
NOW=$(date +%s)
if (( NOW >= DEADLINE )); then
# Don't exit here: fall through to the normal output-setting logic below (which
# will correctly record sampler_ok=false / no report_url) so downstream steps
# (write-back, PR comment) still run via their own `if:` conditions instead of
# being skipped by a hard step failure. The final "Fail job" step still fails
# the job overall.
echo "::error::Partner ${SAMPLER_PARTNER} still has a sampler run in progress after 90 minutes of retrying. Giving up — check whether a previous run is stuck (§2.3/2.4c: no CLI cancel exists for a backend run)."
break
fi
echo "[$(date -u +%H:%M:%S)] partner ${SAMPLER_PARTNER} has a run in progress elsewhere (cross-repo or stale) — retrying in 60s..."
sleep 60
ATTEMPT=$((ATTEMPT+1))
continue
fi
break
done

{
echo "rc=${RC}"
} >> "$GITHUB_OUTPUT"

if [[ "${RC}" -ne 0 ]]; then
echo "sampler_ok=false" >> "$GITHUB_OUTPUT"
elif echo "$OUTPUT" | grep -qi "completed successfully"; then
echo "sampler_ok=true" >> "$GITHUB_OUTPUT"
else
echo "sampler_ok=false" >> "$GITHUB_OUTPUT"
fi

# Report URL: "Sampler report: https://..." (strip ANSI first).
# `|| true`: under `set -eo pipefail`, grep finding no match (a failed/timed-out run
# never prints this line) makes the whole pipeline's exit status non-zero even though
# head/sed after it succeed on empty input - that would abort this step here, before
# the compact-diff extraction below and before the "always run" downstream steps ever
# see a value, silently dropping the PR comment on failure. Confirmed by reproducing
# locally: without `|| true` this line aborted the step on every non-completed run.
REPORT_URL=$(echo "$OUTPUT" | sed 's/\x1b\[[0-9;]*m//g' | grep -oE 'Sampler report: https?://[^[:space:]]+' | head -n1 | sed 's/^Sampler report: //') || true
echo "report_url=${REPORT_URL}" >> "$GITHUB_OUTPUT"

# Compact named_results diff, between the CLI's own markers.
COMPACT=$(echo "$OUTPUT" | sed 's/\x1b\[[0-9;]*m//g' | awk '/SAMPLER_COMPACT_START/{f=1;next} /SAMPLER_COMPACT_END/{f=0} f')
{
echo "compact<<SAMPLER_COMPACT_EOF_9c2a"
echo "$COMPACT"
echo "SAMPLER_COMPACT_EOF_9c2a"
} >> "$GITHUB_OUTPUT"

- name: Capture partner token after the run and write back if rotated
id: after
# Gated on steps.before succeeding, not just always(): if "Load"/"Capture...before" never
# got a valid token for this partner (e.g. the secret is missing a partnerCredentials
# entry), steps.before.outputs.token is empty - comparing that against whatever
# AFTER_TOKEN happens to parse to would read as "rotated" and write back a config that
# was never actually valid to begin with. Caught live: a run that failed at "Load"
# (invalid secret content) still had this step fire under bare always() and wrote the
# same bad content back to the secret - a no-op here, but the wrong behavior in general.
if: always() && steps.before.outcome == 'success'
env:
GH_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
run: |
if [[ ! -f "$HOME/.silverfin/config.json" ]]; then
echo "No config.json on disk (load step must have failed) — nothing to write back."
exit 0
fi
AFTER_TOKEN=$(jq -r --arg p "${{ inputs.partner }}" '.partnerCredentials[$p].token' "$HOME/.silverfin/config.json")
echo "::add-mask::${AFTER_TOKEN}"
if [[ "${AFTER_TOKEN}" == "${{ steps.before.outputs.token }}" ]]; then
echo "Partner token unchanged — no write-back needed (sole-writer secret stays untouched)."
exit 0
fi
echo "Partner token rotated mid-run (401 refresh) — writing PARTNER_CONFIG_JSON_${{ inputs.partner }} back."
CONFIG_JSON=$(cat "$HOME/.silverfin/config.json" | tr -d '\n')
echo "::add-mask::${CONFIG_JSON}"
echo "$CONFIG_JSON" | gh secret set "PARTNER_CONFIG_JSON_${{ inputs.partner }}"

@michieldegezelle michieldegezelle Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — same expression-injection pattern, highest-impact instance

This step runs with REPO_ACCESS_TOKEN (a repo-scoped write PAT) active via GH_TOKEN. ${{ inputs.partner }} is spliced directly into jq --arg p "${{ inputs.partner }}" (line 257), an echo message (line 263), and here into gh secret set "PARTNER_CONFIG_JSON_${{ inputs.partner }}" — which also means the unsanitized partner value directly determines which secret gets overwritten, with no allowlist check against known/authorized partner ids. Combined with the active write PAT, an injectable partner value here is the worst-case instance of the pattern flagged on line 106 (same root cause also present at lines 142-143 and 151).

Same fix throughout: thread partner through env: (e.g. PARTNER: ${{ inputs.partner }}) and reference "$PARTNER" in each of these steps, matching the SAMPLER_PARTNER pattern this file already uses correctly in the "Run liquid sampler" step.

env:
  GH_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
  PARTNER: ${{ inputs.partner }}
run: |
  ...
  AFTER_TOKEN=$(jq -r --arg p "$PARTNER" '.partnerCredentials[$p].token' "$HOME/.silverfin/config.json")
  ...
  echo "$CONFIG_JSON" | gh secret set "PARTNER_CONFIG_JSON_${PARTNER}"


- name: Download results.zip for the workflow artifact
id: download
if: steps.sampler.outputs.report_url != ''
env:
REPORT_URL: ${{ steps.sampler.outputs.report_url }}
run: |
# The CLI's --compact path downloads to a temp dir and deletes it after printing the
# diff, so results.zip never lands on disk on its own — fetch it again here for the
# artifact (Q12: full zip kept for rendering-only regressions the compact diff can't see).
if curl -sL --max-time 300 -o results.zip "${REPORT_URL}"; then

@michieldegezelle michieldegezelle Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major: curl -sL --max-time 300 -o results.zip "${REPORT_URL}" has no -f/--fail. A non-2xx response after following redirects (an expired presigned URL, a permission error) still returns curl exit code 0 with the error body written to results.zip — so this branch reports downloaded=true and the next step uploads a bogus non-zip file as the sampler-results-partner-* artifact, instead of correctly falling back to downloaded=false.

if curl -sfL --max-time 300 -o results.zip "${REPORT_URL}"; then

echo "downloaded=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::Could not download results.zip from the report URL for the artifact upload (the report link itself is still valid)."
echo "downloaded=false" >> "$GITHUB_OUTPUT"
fi

Comment on lines +268 to +283

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add --fail to the curl download so HTTP error responses aren't treated as a successful artifact.

Without --fail, curl -sL returns exit 0 and writes the response body to results.zip even on a 4xx/5xx response (e.g. an expired or malformed report URL), so the artifact upload step would then publish an HTML error page mislabeled as results.zip.

Suggested fix
-          if curl -sL --max-time 300 -o results.zip "${REPORT_URL}"; then
+          if curl -sL --fail --max-time 300 -o results.zip "${REPORT_URL}"; then
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Download results.zip for the workflow artifact
id: download
if: steps.sampler.outputs.report_url != ''
env:
REPORT_URL: ${{ steps.sampler.outputs.report_url }}
run: |
# The CLI's --compact path downloads to a temp dir and deletes it after printing the
# diff, so results.zip never lands on disk on its own — fetch it again here for the
# artifact (Q12: full zip kept for rendering-only regressions the compact diff can't see).
if curl -sL --max-time 300 -o results.zip "${REPORT_URL}"; then
echo "downloaded=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::Could not download results.zip from the report URL for the artifact upload (the report link itself is still valid)."
echo "downloaded=false" >> "$GITHUB_OUTPUT"
fi
- name: Download results.zip for the workflow artifact
id: download
if: steps.sampler.outputs.report_url != ''
env:
REPORT_URL: ${{ steps.sampler.outputs.report_url }}
run: |
# The CLI's --compact path downloads to a temp dir and deletes it after printing the
# diff, so results.zip never lands on disk on its own — fetch it again here for the
# artifact (Q12: full zip kept for rendering-only regressions the compact diff can't see).
if curl -sL --fail --max-time 300 -o results.zip "${REPORT_URL}"; then
echo "downloaded=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::Could not download results.zip from the report URL for the artifact upload (the report link itself is still valid)."
echo "downloaded=false" >> "$GITHUB_OUTPUT"
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/run_sampler.yml around lines 268 - 283, Update the curl
invocation in the “Download results.zip for the workflow artifact” step to
include the --fail option, ensuring HTTP 4xx/5xx responses enter the existing
warning path instead of being marked as downloaded.

- name: Upload results.zip artifact
if: steps.download.outputs.downloaded == 'true'
uses: actions/upload-artifact@v4
with:
name: sampler-results-partner-${{ inputs.partner }}
path: results.zip
retention-days: 7

- name: Post result comment on the PR
if: inputs.pull_request_number != ''

@michieldegezelle michieldegezelle Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — downstream steps skip silently on an early hard failure

This step's if: (and "Upload results.zip artifact" at line 285, and "Fail job if the sampler run did not complete successfully" at line 345) don't include always(). If an earlier step hard-fails — checkout, npm install, the "Load PARTNER_CONFIG_JSON" validation, or the write-back step (lines 249-266) — GitHub Actions' implicit success() skips all three of these. The job still ends up red overall (the earlier step's own failure fails the job), but reviewers get no PR comment at all, and the job fails with whatever raw error the earlier step produced instead of this workflow's own clean ::error::Sampler run for partner ... did not complete successfully message.

if: always() && inputs.pull_request_number != ''              # Post result comment
if: always() && steps.download.outputs.downloaded == 'true'   # Upload artifact
if: always() && steps.sampler.outputs.sampler_ok != 'true'     # Fail job

uses: actions/github-script@v7
env:
PARTNER: ${{ inputs.partner }}
HANDLES: ${{ inputs.handles }}
ACCOUNT_TEMPLATES: ${{ inputs.account_templates }}
FIRM_IDS: ${{ inputs.firm_ids }}
SAMPLER_OK: ${{ steps.sampler.outputs.sampler_ok }}
REPORT_URL: ${{ steps.sampler.outputs.report_url }}
COMPACT: ${{ steps.sampler.outputs.compact }}
PR_NUMBER: ${{ inputs.pull_request_number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const marker = `<!-- silverfin-sampler-result-${process.env.PARTNER} -->`;
const ok = process.env.SAMPLER_OK === "true";
const reportUrl = process.env.REPORT_URL || "";
const compact = (process.env.COMPACT || "").trim();
const runUrl = process.env.RUN_URL;

const lines = [`## Liquid sampler · partner \`${process.env.PARTNER}\``, ""];
if (ok) {
lines.push("This is a **review artifact, not a pass/fail gate** — check the diff below (or the full report) for unintended changes.");
} else {
lines.push(`⚠️ The sampler run did not complete cleanly — see the [workflow run](${runUrl}) for details.`);
}
lines.push("");
if (process.env.HANDLES) lines.push(`- Reconciliation handles: \`${process.env.HANDLES}\``);
if (process.env.ACCOUNT_TEMPLATES) lines.push(`- Account templates: \`${process.env.ACCOUNT_TEMPLATES}\``);
lines.push(`- Firm(s): \`${process.env.FIRM_IDS}\``);
if (reportUrl) lines.push(`- **[📊 Open full sampler report](${reportUrl})** (downloads \`results.zip\`)`);
lines.push(`- Workflow run: ${runUrl}`);
lines.push("");
if (compact) {
lines.push(compact);
} else if (ok) {
lines.push("_No compact diff was produced (see the run log)._");
}
lines.push("", marker);
const body = lines.join("\n");

const { owner, repo } = context.repo;
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100 });
const existing = comments.find(c => c.user && c.user.type === "Bot" && c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
}

- name: Fail job if the sampler run did not complete successfully
if: steps.sampler.outputs.sampler_ok != 'true'
run: |
echo "::error::Sampler run for partner ${{ inputs.partner }} did not complete successfully — see the run-sampler step output above."
exit 1
Loading