Skip to content

ci(dry-run): scan the archive artifacts with VirusTotal as the final step - #1489

Merged
DeusData merged 1 commit into
mainfrom
ci/dry-run-virustotal
Aug 7, 2026
Merged

DeusData merged 1 commit into
mainfrom
ci/dry-run-virustotal

Conversation

@DeusData

@DeusData DeusData commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Preparation for the next dry-run cycle.

Why

The release gate scans every shipped artifact with VirusTotal and blocks on any detection. The dry run did not — so the single question most likely to stop a release was the one a dry run couldn't answer. v0.9.1-rc found that out the expensive way.

What

The scan mirrors the release job. The only difference is provenance: the release pulls archives off a published release, a dry run has none, so they come from the build job's artifacts — the same files, produced by the same canonical package-release.sh, extracted the same way, scanned as the same bytes a user downloads and runs. Same zero-tolerance gate (scripts/ci/check-virustotal.sh), so a dry run now fails where the release would.

It runs last, after smoke and soak. It's the slowest job (polling can take two hours) and its verdict doesn't depend on our code at all — the same unchanged bytes can come back clean one day and flagged the next. That must not delay the feedback that is about our code.

Two independent skips

input effect question it answers
skip_builds no artifacts at all "do lint and tests pass?"
skip_virustotal build + smoke + soak, no scan "do the archives pass CI?"

The second is the one requested: during a debug cycle, when the question is whether the archives survive smoke and soak, a two-hour scan burning API quota is pure noise. skip_builds implies no scan — there's nothing to scan.

Extraction moved to a script

scripts/ci/extract-release-archives.sh, not inline YAML, because the venue-parity contract requires it — a venue may provision, plumb artifacts, or call a canonical leg script, and this is leg logic. The contract caught the inline version and was right to.

Two defects found by testing the script rather than trusting it

  • The empty-input guard didn't guard. It counted files in the output directory, but the install scripts are copied in unconditionally, so the count was never zero — an input with no archives reported success. That's a clean VirusTotal run over nothing: exactly the false green the check exists to prevent. It now counts binaries recovered from archives and exits 1 on zero. Verified both ways.
  • The script was committed 100644 while the workflow invokes it directly as a command — the "Permission denied" failure test_script_exec_bit_contract.sh was written for. That contract scans scripts/ but not .github/workflows/, so it didn't catch it here. Now 100755, matching its siblings.

Scope notes

  • No PR-CI impact: dry-run.yml is workflow_dispatch-only, so nothing here runs on PRs or pushes.
  • Cost: adds a VT scan per dry run — API quota plus up to two hours of polling. Skippable by design.
  • New failure surface: a detection now reds the dry run. That's the intent (early warning), and it does mean an upstream false positive can block a dry run the same way it currently blocks the release.

Contracts pass: venue-parity, release-gate-chain, exec-bit.

…step

Preparation for the next dry-run cycle. The release gate scans every shipped
artifact with VirusTotal and blocks on any detection; the dry run did not, so
the one question most likely to stop a release was the one a dry run could not
answer. v0.9.1-rc found that out the expensive way.

The scan mirrors the release job. The only difference is where the archives come
from: the release pulls them off a published release, a dry run has none, so
they come from the build job's artifacts — the same files, produced by the same
canonical package-release.sh, extracted the same way and scanned as the same
bytes a user would download and run. Same zero-tolerance gate
(scripts/ci/check-virustotal.sh), so a dry run now fails where the release would.

It runs LAST, after smoke and soak. It is the slowest job — polling can take two
hours — and its verdict does not depend on our code at all: the same unchanged
bytes can come back clean one day and flagged the next. That must not delay the
feedback that IS about our code.

Two independent skips, because they answer different questions:

  skip_builds      no artifacts at all          "do lint and tests pass?"
  skip_virustotal  build + smoke + soak, no scan "do the archives pass CI?"

The second is the one asked for: during a debug cycle, when the question is
whether the archives survive smoke and soak, a two-hour scan burning API quota
is pure noise. skip_builds implies no scan, since there is nothing to scan.

Extraction lives in scripts/ci/extract-release-archives.sh rather than inline in
the YAML because the venue-parity contract requires it — a venue may provision,
plumb artifacts, or call a canonical leg script, and this is leg logic. The
contract caught the inline version.

Two defects found by testing the script instead of trusting it:

  * The empty-input guard did not guard. It counted files in the output
    directory, but the install scripts are copied in unconditionally, so the
    count was never zero and an input with no archives reported success — a
    clean VirusTotal run over nothing, the exact false green the check exists to
    prevent. It now counts binaries recovered from archives, and exits 1 on
    zero.
  * The script was committed 100644 while the workflow invokes it directly as a
    command, which is the "Permission denied" failure
    test_script_exec_bit_contract.sh was written for. That contract scans
    scripts/ but not .github/workflows/, so it did not catch it here.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData
DeusData merged commit 399ea7c into main Aug 7, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant