From 259aa2d755e7fd14f9f2e738cc305e1304231272 Mon Sep 17 00:00:00 2001 From: Min <55829146+cloneismin@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:23:35 +0900 Subject: [PATCH] Wait for public registry propagation without republishing --- .github/workflows/publish.yml | 47 ++++++++++++++++- tests/scripts/provenance-wait.test.mjs | 73 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tests/scripts/provenance-wait.test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6ad10d9..02cc48b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,6 +22,11 @@ on: required: false type: boolean default: false + pypi_verify_only: + description: Verify an already submitted PyPI release without republishing + required: false + type: boolean + default: false permissions: contents: read concurrency: @@ -122,7 +127,17 @@ jobs: npm install --prefix "$consumer" --ignore-scripts --prefer-online "$package_spec" cd "$consumer" node --input-type=module -e 'await import("@clone-ai/prompt-prediction"); await import("@clone-ai/prompt-prediction/server")' - npm audit signatures + # Registry metadata can become visible before its attestation endpoint. + # Wait only for E404; an invalid or missing signature still fails closed. + for signature_attempt in {1..20}; do + if npm audit signatures --json > "$RUNNER_TEMP/npm-signatures.json"; then + cat "$RUNNER_TEMP/npm-signatures.json" + break + fi + node -e 'const result = require(process.argv[1]); process.exit(result.error?.code === "E404" ? 0 : 1)' "$RUNNER_TEMP/npm-signatures.json" + [[ "$signature_attempt" -lt 20 ]] || { cat "$RUNNER_TEMP/npm-signatures.json"; exit 1; } + sleep 15 + done registry_tarball=$(npm pack "$package_spec" --pack-destination "$RUNNER_TEMP" --json | node -e 'let s=""; process.stdin.on("data",c=>s+=c).on("end",()=>console.log(JSON.parse(s)[0].filename))') cmp "$RUNNER_TEMP/$registry_tarball" "$GITHUB_WORKSPACE/release/clone-ai-prompt-prediction-$version.tgz" pypi: @@ -151,12 +166,42 @@ jobs: gh release download "$RELEASE_TAG" --pattern '*.whl' --pattern '*.tar.gz' --dir released-python for file in dist/*; do cmp "$file" "released-python/$(basename "$file")"; done - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 + if: ${{ !inputs.pypi_verify_only }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: python-version: '3.11' - name: Verify anonymous PyPI installation run: | + set -euo pipefail version=$(python -c 'from pathlib import Path; print(next(Path("dist").glob("*.whl")).name.split("-")[1])') + # Do not rerun a successful upload when the public index is catching up. + for pypi_attempt in {1..20}; do + if python - "$version" <<'PYCODE' + import hashlib, json, sys, urllib.error, urllib.request + from pathlib import Path + try: + with urllib.request.urlopen("https://pypi.org/pypi/clone-sdk/" + sys.argv[1] + "/json", timeout=15) as response: + metadata = json.load(response) + except urllib.error.HTTPError as error: + if error.code == 404: + raise SystemExit(3) + raise + wheel = next(Path("dist").glob("*.whl")) + public = next(item for item in metadata["urls"] if item["filename"] == wheel.name) + expected = hashlib.sha256(wheel.read_bytes()).hexdigest() + assert not public["yanked"] and public["digests"]["sha256"] == expected + request = urllib.request.Request("https://pypi.org/simple/clone-sdk/", headers={"Accept": "application/vnd.pypi.simple.v1+json"}) + with urllib.request.urlopen(request, timeout=15) as response: + index = json.load(response) + indexed = next((item for item in index["files"] if item["filename"] == wheel.name), None) + if indexed is None: + raise SystemExit(3) + assert not indexed.get("yanked") and indexed["hashes"]["sha256"] == expected + PYCODE + then break; else lookup_status=$?; fi + [[ "$lookup_status" -eq 3 && "$pypi_attempt" -lt 20 ]] || exit 1 + sleep 15 + done python -m venv "$RUNNER_TEMP/pypi-consumer" "$RUNNER_TEMP/pypi-consumer/bin/pip" install "clone-sdk==$version" cd "$RUNNER_TEMP" diff --git a/tests/scripts/provenance-wait.test.mjs b/tests/scripts/provenance-wait.test.mjs new file mode 100644 index 0000000..55796cd --- /dev/null +++ b/tests/scripts/provenance-wait.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; + +const workflow = await readFile(new URL('../../.github/workflows/publish.yml', import.meta.url), 'utf8'); +const loops = { + npm: workflow.match(/ for signature_attempt in \{1\.\.20\}; do\n[\s\S]*? done/)[0], + python: workflow.match(/ for pypi_attempt in \{1\.\.20\}; do\n[\s\S]*? done/)[0], +}; + +async function verify(t, code, failures, tool = 'npm') { + const directory = await mkdtemp(join(tmpdir(), 'clone-provenance-wait-')); + t.after(() => rm(directory, { recursive: true, force: true })); + await writeFile(join(directory, tool), `#!/bin/bash +count=0 +[[ ! -f "$RUNNER_TEMP/count" ]] || read -r count < "$RUNNER_TEMP/count" +count=$((count + 1)) +printf '%s' "$count" > "$RUNNER_TEMP/count" +if [[ "$count" -le "$PROBE_FAILURES" ]]; then + if [[ "$PROBE_TOOL" == python && "$PROBE_CODE" == E404 ]]; then exit 3; fi + printf '{"error":{"code":"%s"}}' "$PROBE_CODE" + exit 1 +fi +printf '{"verified":1}' +`, { mode: 0o700 }); + await writeFile(join(directory, 'sleep'), '#!/bin/bash\nexit 0\n', { mode: 0o700 }); + const loop = loops[tool].split('\n').map(line => line.replace(/^ /, '')).join('\n'); + const result = spawnSync('bash', ['-euc', loop], { encoding: 'utf8', + env: { ...process.env, RUNNER_TEMP: directory, + PATH: directory + ':' + dirname(process.execPath) + ':' + process.env.PATH, + PROBE_CODE: code, PROBE_FAILURES: String(failures), PROBE_TOOL: tool, version: '0.2.1' } }); + return { ...result, attempts: Number(await readFile(join(directory, 'count'), 'utf8')) }; +} + +test('provenance verification recovers from registry E404 without republishing', async t => { + const result = await verify(t, 'E404', 1); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.attempts, 2); + assert.match(result.stdout, /verified/); +}); + +test('an invalid signature fails immediately', async t => { + const result = await verify(t, 'EINTEGRITY', 1); + assert.equal(result.status, 1); + assert.equal(result.attempts, 1); +}); + +test('a persistently unavailable attestation fails at the bound', async t => { + const result = await verify(t, 'E404', 100); + assert.equal(result.status, 1); + assert.equal(result.attempts, 20); +}); + +test('PyPI verification waits for the uploaded version to reach the index', async t => { + const result = await verify(t, 'E404', 1, 'python'); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.attempts, 2); +}); + +test('PyPI verification does not retry an integrity failure', async t => { + const result = await verify(t, 'EINTEGRITY', 1, 'python'); + assert.equal(result.status, 1); + assert.equal(result.attempts, 1); +}); + +test('a persistently unavailable PyPI version fails at the bound', async t => { + const result = await verify(t, 'E404', 100, 'python'); + assert.equal(result.status, 1); + assert.equal(result.attempts, 20); +});