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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions .github/scripts/check_cooldown.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Prove the supply-chain cooldown in bunfig.toml actually rejects a fresh release.
#
# Requested by Dima Ryskin (Wix secplatform) as the review for #597: "i think the
# best 'review' would be a test. Can we stack another PR on top of that and check
# if installing a fresh-package is rejected? I used
# npmjs.com/package/electron-nightly for always 'fresh' versions".
#
# This MUST run without the Wix embargo gateway. With the gateway in front,
# embargo would refuse the fresh version itself and the run would prove nothing
# about Bun's guardrail — which is what the publish workflows actually rely on.
#
# Three cases, because "it errored" is not the only pass and not the only failure:
# control a long-stable package still installs, so a red result means the
# cooldown fired rather than the probe being broken
# floating `bun add <pkg>` must not land a version inside the cooldown —
# either refused, or silently resolved to an older one
# exact pin `bun add <pkg>@<fresh-version>` must be refused. This is the
# bypass path that matters: a PR pinning an exact fresh version.
#
# Linux/GNU only (runs on ubuntu-latest). Age arithmetic is done in Node to avoid
# date(1) portability problems.
set -uo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
FRESH_PKG="electron-nightly" # publishes nightly, so its latest is always fresh
CONTROL_PKG="lodash" # unchanged for years; must install

# Read the policy from bunfig.toml rather than hardcoding it, so this test cannot
# drift away from the setting it is supposed to be verifying.
COOLDOWN_SECONDS="$(sed -nE 's/^[[:space:]]*minimumReleaseAge[[:space:]]*=[[:space:]]*([0-9]+).*/\1/p' "$REPO_ROOT/bunfig.toml" | head -1)"
if [ -z "$COOLDOWN_SECONDS" ]; then
echo "FAIL: no minimumReleaseAge found in bunfig.toml — nothing to verify"
exit 1
fi
echo "Cooldown under test: ${COOLDOWN_SECONDS}s ($((COOLDOWN_SECONDS / 86400)) days)"
echo "Bun: $(bun --version)"
echo

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
cp "$REPO_ROOT/bunfig.toml" "$WORK/bunfig.toml"
cd "$WORK"
printf '{ "name": "cooldown-probe", "private": true, "version": "0.0.0" }\n' >package.json

failures=0

# Age in seconds of a specific published version, per the registry's own metadata.
published_age_seconds() {
curl -sS "https://registry.npmjs.org/$1" | node -e "
let raw = '';
process.stdin.on('data', (d) => (raw += d)).on('end', () => {
const when = JSON.parse(raw).time?.['$2'];
if (!when) { console.error('no publish time for $1@$2'); process.exit(1); }
console.log(Math.floor((Date.now() - Date.parse(when)) / 1000));
});
"
}

echo "── control: bun add $CONTROL_PKG ──────────────────────────────"
if bun add "$CONTROL_PKG" >control.log 2>&1; then
echo "PASS control package installed, so the probe environment works"
else
echo "FAIL control package could not install — the probe is broken, not the cooldown"
sed 's/^/ /' control.log
failures=$((failures + 1))
fi
echo

echo "── floating: bun add $FRESH_PKG ───────────────────────────────"
if bun add "$FRESH_PKG" >floating.log 2>&1; then
resolved="$(node -p "require('$WORK/node_modules/$FRESH_PKG/package.json').version")"
age="$(published_age_seconds "$FRESH_PKG" "$resolved")" || age=""
if [ -z "$age" ]; then
echo "FAIL installed $resolved but could not determine its publish time"
failures=$((failures + 1))
elif [ "$age" -ge "$COOLDOWN_SECONDS" ]; then
echo "PASS resolved $resolved, published ${age}s ago (>= cooldown)"
echo " fresh versions were filtered out of resolution"
else
echo "FAIL installed $resolved, published only ${age}s ago — inside the cooldown"
failures=$((failures + 1))
fi
else
echo "PASS bun refused to install $FRESH_PKG"
sed 's/^/ /' floating.log | tail -5
fi
echo

echo "── exact pin: bun add $FRESH_PKG@<newest> ─────────────────────"
newest="$(curl -sS "https://registry.npmjs.org/$FRESH_PKG" | node -e "
let raw = '';
process.stdin.on('data', (d) => (raw += d)).on('end', () => {
const doc = JSON.parse(raw);
console.log(doc['dist-tags'].nightly ?? doc['dist-tags'].latest);
});
")"
newest_age="$(published_age_seconds "$FRESH_PKG" "$newest")" || newest_age=""
echo "Newest published: $newest (${newest_age:-unknown}s old)"

if [ -n "$newest_age" ] && [ "$newest_age" -ge "$COOLDOWN_SECONDS" ]; then
echo "SKIP newest version is already older than the cooldown; nothing fresh to reject"
echo " (unexpected for $FRESH_PKG — check it is still publishing nightly)"
elif bun add "$FRESH_PKG@$newest" >pinned.log 2>&1; then
echo "FAIL an exact pin bypassed the cooldown and installed $newest"
echo " a PR pinning a fresh version would defeat the guardrail"
failures=$((failures + 1))
else
echo "PASS bun refused the exact fresh pin $newest"
sed 's/^/ /' pinned.log | tail -5
fi
echo

if [ "$failures" -gt 0 ]; then
echo "RESULT: $failures check(s) failed — the cooldown does not hold"
exit 1
fi
echo "RESULT: cooldown holds — fresh releases cannot enter the dependency tree"
69 changes: 60 additions & 9 deletions .github/scripts/check_wix_proxy_steps.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
#!/usr/bin/env python3
"""Fail if any GitHub Actions job skips the mandatory Wix gateway proxy action.

There is no opt-out marker by design. A job that genuinely cannot run the proxy
(a non-ubuntu runner, say) changes this script in the same PR, so the exception
gets reviewed in the open.
There is still no per-job opt-out marker by design. A job that genuinely cannot
run the proxy changes this script in the same PR, so the exception gets reviewed
in the open — which is exactly how GATEWAY_EXEMPT_WORKFLOWS below came to exist.

The rule is bidirectional: jobs must run the proxy, and jobs in an exempt
workflow must not.
"""

from __future__ import annotations
Expand All @@ -19,6 +22,30 @@
# so a sparse checkout has to materialize both directories.
REQUIRED_PATHS = (".github/actions/wix-gateway-proxy", ".github/certs")

# Workflows that must NOT route npm through the embargo gateway.
#
# secplatform's interim policy for OSS repos (Dima Ryskin): use embargo for
# non-publish tasks, and protect publish tasks with an enforced lockfile
# (`bun install --frozen-lockfile`) plus a package manager that honors a
# minimal-age directive (`minimumReleaseAge` in bunfig.toml) instead. Those jobs
# resolve nothing, so dropping the gateway does not widen what they can pull.
# The gateway cannot carry a publish today — `npm publish` sends
# `PUT /<package>`, which misses its `^~ /-/` passthrough block, and it sets no
# client_max_body_size so nginx's 1 MB default rejects a packument carrying the
# base64 tarball.
#
# Keyed on exact filename, each with the reason it abstains, and every exemption
# is printed on success — so this cannot quietly grow. Revisit once the embargo
# publish bug is fixed: the publish workflows should go back to being gatewayed
# like everything else.
GATEWAY_EXEMPT_WORKFLOWS = {
".github/workflows/manual-publish.yml": "publishes; installs are frozen-lockfile only",
".github/workflows/preview-publish.yml": "publishes; installs are frozen-lockfile only",
# The gateway would reject the fresh release itself, so a gatewayed run could
# not tell us whether Bun's cooldown works. This job exists to prove it does.
".github/workflows/cooldown-check.yml": "must reach the registry ungatewayed to test the cooldown",
}

FIX_HINT = """Every job must run the Wix gateway proxy immediately after a checkout that
puts it on disk, or that job's npm installs bypass the Wix embargo gateway.

Expand Down Expand Up @@ -118,6 +145,19 @@ def job_problem(job: dict, workflows: frozenset[str]) -> str | None:
return _checkout_problem(steps[0])


def exempt_job_problem(job: dict, reason: str) -> str | None:
"""Describe why this exempt job wrongly runs the proxy, or None if it abstains."""
if "uses" in job:
return None
steps = job.get("steps") or []
if any(_uses(step) == PROXY_ACTION for step in steps):
return (
"runs the Wix gateway proxy, but this workflow is exempt and must not "
f"({reason})"
)
return None


def _job_lines(text: str) -> dict[str, int]:
document = yaml.compose(text)
if document is None:
Expand All @@ -133,16 +173,23 @@ def main(repo_root: pathlib.Path = REPO_ROOT) -> int:
paths = sorted(p for p in workflows_dir.iterdir() if p.suffix in (".yml", ".yaml"))
workflows = frozenset(p.relative_to(repo_root).as_posix() for p in paths)
problems = []
jobs = calls = 0
jobs = calls = exempt = 0
exempt_paths = set()

for path in paths:
rel = path.relative_to(repo_root).as_posix()
text = path.read_text(encoding="utf-8")
lines = _job_lines(text)
for job_id, job in ((yaml.safe_load(text) or {}).get("jobs") or {}).items():
job = job or {}
jobs += 1
calls += "uses" in job
problem = job_problem(job, workflows)
if rel in GATEWAY_EXEMPT_WORKFLOWS:
exempt += 1
exempt_paths.add(rel)
problem = exempt_job_problem(job, GATEWAY_EXEMPT_WORKFLOWS[rel])
else:
jobs += 1
calls += "uses" in job
problem = job_problem(job, workflows)
if problem:
problems.append((path.relative_to(repo_root), lines[job_id], job_id, problem))

Expand All @@ -156,9 +203,13 @@ def main(repo_root: pathlib.Path = REPO_ROOT) -> int:

print(
f"Wix gateway proxy: verified {jobs - calls} of {jobs} jobs across "
f"{len(paths)} workflows ({calls} reusable-workflow calls delegate to "
f"the workflow they call)."
f"{len(paths) - len(exempt_paths)} workflows ({calls} reusable-workflow calls "
f"delegate to the workflow they call)."
)
if exempt:
print(f"Exempt by policy, and verified to abstain ({exempt} job(s)):")
for rel in sorted(exempt_paths):
print(f" {rel} — {GATEWAY_EXEMPT_WORKFLOWS[rel]}")
return 0


Expand Down
73 changes: 73 additions & 0 deletions .github/scripts/test_check_wix_proxy_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,79 @@ def test_job_with_no_body_is_reported_as_missing_the_proxy(self):
self.assertIn('Job "build" does not run the Wix gateway proxy.', output)


class GatewayExemptionTests(unittest.TestCase):
"""Exempt workflows must abstain from the proxy; everything else must run it."""

PUBLISH_WITHOUT_PROXY = textwrap.dedent("""\
name: Manual Package Publish
on:
workflow_dispatch:

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm publish
""")

PUBLISH_WITH_PROXY = textwrap.dedent("""\
name: Package Preview Publish
on:
pull_request:

jobs:
publish-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Wix gateway proxy (mandatory)
uses: ./.github/actions/wix-gateway-proxy
- run: npm publish
""")

def test_publish_workflow_may_omit_the_proxy(self):
with fixture_repo(**{"manual-publish": self.PUBLISH_WITHOUT_PROXY}) as root:
code, output = run_main(root)

self.assertEqual(code, 0)
self.assertIn("Exempt by policy", output)
self.assertIn(".github/workflows/manual-publish.yml", output)

def test_each_exemption_prints_its_reason(self):
# An exemption is only reviewable if the run says why it exists.
with fixture_repo(**{"cooldown-check": self.PUBLISH_WITHOUT_PROXY}) as root:
code, output = run_main(root)

self.assertEqual(code, 0)
expected = checker.GATEWAY_EXEMPT_WORKFLOWS[".github/workflows/cooldown-check.yml"]
self.assertIn(expected, output)

def test_exempt_jobs_are_not_counted_as_verified(self):
with fixture_repo(
**{"manual-publish": self.PUBLISH_WITHOUT_PROXY, "good": COMPLIANT_WORKFLOW}
) as root:
code, output = run_main(root)

self.assertEqual(code, 0)
self.assertIn("verified 1 of 1 jobs across 1 workflows", output)

def test_publish_workflow_running_the_proxy_is_rejected(self):
with fixture_repo(**{"preview-publish": self.PUBLISH_WITH_PROXY}) as root:
code, output = run_main(root)

self.assertEqual(code, 1)
self.assertIn("this workflow is exempt and must not", output)

def test_a_non_publish_workflow_still_needs_the_proxy(self):
with fixture_repo(**{"some-publish-helper": self.PUBLISH_WITHOUT_PROXY}) as root:
code, output = run_main(root)

self.assertEqual(code, 1)
self.assertIn("does not run the Wix gateway proxy", output)


class RepositoryTests(unittest.TestCase):
def test_every_job_in_this_repository_runs_the_proxy(self):
self.assertEqual(checker.main(), 0)
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/cooldown-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Supply-chain Cooldown Check

# Proves the `minimumReleaseAge` cooldown in bunfig.toml actually rejects a
# freshly published release — the test Dima Ryskin asked for as the review of the
# interim policy, using electron-nightly for reliably "fresh" versions.
#
# Deliberately does NOT run the Wix gateway proxy: with embargo in front, the
# gateway would refuse the fresh version and this would prove nothing about Bun's
# guardrail, which is what the publish workflows rely on. check-wix-proxy.yml
# knows about this exemption and verifies the job abstains.
#
# Uses no third-party actions. `oven-sh/setup-bun` is not on the org allowlist
# (github_owned_allowed only), and SHA pinning is now required — so Bun is
# installed from a run step and actions/checkout is pinned by SHA.

on:
workflow_dispatch:
pull_request:
paths:
- "bunfig.toml"
- ".github/scripts/check_cooldown.sh"
- ".github/workflows/cooldown-check.yml"

jobs:
cooldown:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
# actions/checkout v7.0.1, SHA-pinned per the org policy.
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1

- name: Install Bun
# Not oven-sh/setup-bun: third-party actions are not allowlisted for this
# org, and the cooldown does not apply to Bun's own installer anyway.
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >>"$GITHUB_PATH"

- name: Verify the cooldown rejects a fresh release
run: bash .github/scripts/check_cooldown.sh
Loading