From b795d4bf9b695105e64ac2a2f551088d2a2e759e Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 17 Jun 2026 23:26:10 +0800 Subject: [PATCH 1/4] fix(codegen): deterministic member-clone ordering (hash-seed independent output) Three collections in CodeGen.__init__ were iterated in Python set order (SipHash-randomized by PYTHONHASHSEED), and that order flowed into emitted C++ member declarations via orig_names -> func_var_originals -> _func_cs_var_remap (section 8c cloned var/series members): - all_func_scoped_series (set built by .update over func_series_vars values) - all_func_scoped_vars (set built over func_var_members values) - for fname in set(ctx.func_call_site_counts.keys()) The same identical input therefore transpiled to byte-different C++ across hash seeds. Make all three deterministic, ordered + de-duplicated: - all_func_scoped_series / all_func_scoped_vars -> ordered lists. - Iterate func_call_site_counts (a dict, insertion-ordered) directly. The deeper source: ctx.func_series_vars is dict[str, set] (the analyzer stores per-function series vars as sets), so iterating each *value* is also seed-randomized. Wrap those value iterations in sorted() at the two base.py consumption points. Other consumers use func_series_vars for membership or already sort, so no analyzer change is needed. _func_var_members_set stays a set (membership-only, never iterated into output). Proof: a 4-call-site function with 6 history-accessed local series vars now produces identical SHA-256 across PYTHONHASHSEED 0/1/7/12345/999999/31337/ 8675309 (7 distinct hashes before, 1 after). Co-Authored-By: Claude Opus 4.8 (1M context) --- pineforge_codegen/codegen/base.py | 32 +++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 61e7c7b..85994ca 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -185,19 +185,31 @@ def __init__(self, ctx: AnalyzerContext) -> None: # This ensures sub-function series vars get cloned for the parent's call sites. func_var_originals: dict[str, list[str]] = {} # func_name -> list of original var names - # First, collect all function-scoped series vars (union across all functions) - all_func_scoped_series: set[str] = set() + # First, collect all function-scoped series vars (union across all functions). + # Use an ordered, de-duplicated list (NOT a set): set iteration order is + # PYTHONHASHSEED-randomized, and this order reaches emitted C++ member + # declarations via ``orig_names`` -> ``func_var_originals`` -> + # ``_func_cs_var_remap``. ``ctx.func_series_vars`` is a dict whose VALUES + # are themselves sets (analyzer stores ``dict[str, set]``), so we must + # iterate each value in ``sorted`` order to be hash-seed independent. + all_func_scoped_series: list[str] = [] for svars in ctx.func_series_vars.values(): - all_func_scoped_series.update(svars) - # Also include function-scoped var_members - all_func_scoped_vars: set[str] = set() + for sv in sorted(svars): + if sv not in all_func_scoped_series: + all_func_scoped_series.append(sv) + # Also include function-scoped var_members (same ordered-list rationale). + # ``ctx.func_var_members`` values are lists (already insertion-ordered). + all_func_scoped_vars: list[str] = [] for vlist in ctx.func_var_members.values(): for n, _, _ in vlist: - all_func_scoped_vars.add(n) + if n not in all_func_scoped_vars: + all_func_scoped_vars.append(n) # For each function with call-site cloning (has TA ranges or is called multiple times), - # include ALL function-scoped series/var vars that could be used in its body - for fname in set(ctx.func_call_site_counts.keys()): + # include ALL function-scoped series/var vars that could be used in its body. + # Iterate the dict directly (insertion-ordered) rather than ``set(...keys())``, + # which would randomize the order of emitted clones across hash seeds. + for fname in ctx.func_call_site_counts: total_cs = ctx.func_call_site_counts[fname] if total_cs <= 1: continue # No cloning needed for single-call-site functions @@ -207,9 +219,9 @@ def __init__(self, ctx: AnalyzerContext) -> None: for n, _, _ in ctx.func_var_members[fname]: if n not in orig_names: orig_names.append(n) - # Include function's own series vars + # Include function's own series vars (set -> sorted for determinism) if fname in ctx.func_series_vars: - for sv in ctx.func_series_vars[fname]: + for sv in sorted(ctx.func_series_vars[fname]): if sv not in orig_names: orig_names.append(sv) # Include series vars from sub-functions (they share the same class members) From 27c7d2f068ea0254fe52191e72fb2b0187e7d548 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 17 Jun 2026 23:29:27 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(gate):=20assert=20expected=20ok/err=20?= =?UTF-8?q?verdicts,=20not=20just=20native=E2=86=94wasm=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate was purely differential: it only checked that native CPython and wasm Pyodide produced identical results. Two identical crashes — or an ok/ fixture that erroneously errors on both sides — passed silently. The gate verified "they agree", never "they're right". Add checkExpectedVerdict (pure, in compare.mjs) enforcing the verdict implied by the corpus directory: tests/gate-corpus/ok/* must yield result.ok === true, tests/gate-corpus/err/* must yield result.ok === false. The wrong verdict, a malformed/unparseable payload, or an unexpected (non-CompileError) exception on EITHER side now fails the gate with a clear, per-side message — even when the two sides match. run-gate.mjs runs this alongside the existing differential compareResults and reports parity mismatches and verdict failures separately. selftest.mjs gains 10 verdict cases covering: ok/ and err/ happy paths, an ok/ that errors on both sides, an err/ that succeeds on both sides, shared unexpected exceptions on each branch, one-sided wrong verdicts, a missing side, and a malformed payload. Verified end-to-end: injecting a passing fixture into err/ makes gate:full exit 1 with "verdict ok=true but corpus dir expects ok=false"; gate:selftest and gate:full (275 fixtures) are otherwise green. Co-Authored-By: Claude Opus 4.8 (1M context) --- gate/compare.mjs | 40 ++++++++++++++++++++++++++++++++++++ gate/run-gate.mjs | 24 +++++++++++++++++----- gate/selftest.mjs | 52 ++++++++++++++++++++++++++++++++++++----------- 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/gate/compare.mjs b/gate/compare.mjs index e38b93a..b66748c 100644 --- a/gate/compare.mjs +++ b/gate/compare.mjs @@ -27,3 +27,43 @@ export function compareResults(name, native, browser) { } return null; } + +// Expected verdict for a corpus branch: "ok/*" fixtures must transpile +// successfully (result.ok === true), "err/*" fixtures must be rejected +// (result.ok === false). Anything else — the wrong verdict, an unparseable +// payload, or an unexpected (non-CompileError) exception — is a gate failure +// even when native and wasm agree (two identical crashes must NOT pass). +// +// This is intentionally separate from compareResults so the gate enforces BOTH +// (a) native↔wasm parity and (b) the right answer. `side` is {json} or +// {unexpected}; `expectOk` is true for "ok", false for "err". +function verdictOf(side) { + if (!side) return { kind: "missing" }; + if (side.unexpected) return { kind: "unexpected", detail: side.unexpected }; + try { + const v = JSON.parse(side.json); + if (typeof v.ok !== "boolean") return { kind: "malformed", detail: side.json }; + return { kind: "verdict", ok: v.ok }; + } catch { + return { kind: "malformed", detail: side.json }; + } +} + +// Returns a failure string if either side does not match the expected verdict +// for the fixture's branch, or null if both sides produced the expected verdict. +export function checkExpectedVerdict(name, expectOk, native, browser) { + for (const [label, side] of [["native", native], ["pyodide", browser]]) { + const r = verdictOf(side); + if (r.kind === "missing") return `${name}: ${label} produced no result`; + if (r.kind === "unexpected") { + return `${name}: ${label} threw an unexpected exception (expected ok=${expectOk}): ${r.detail}`; + } + if (r.kind === "malformed") { + return `${name}: ${label} returned a malformed result (expected ok=${expectOk}): ${r.detail}`; + } + if (r.ok !== expectOk) { + return `${name}: ${label} verdict ok=${r.ok} but corpus dir expects ok=${expectOk}`; + } + } + return null; +} diff --git a/gate/run-gate.mjs b/gate/run-gate.mjs index 861e242..85fb38c 100644 --- a/gate/run-gate.mjs +++ b/gate/run-gate.mjs @@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { compareResults } from "./compare.mjs"; +import { checkExpectedVerdict, compareResults } from "./compare.mjs"; const require = createRequire(import.meta.url); const HERE = dirname(fileURLToPath(import.meta.url)); @@ -79,8 +79,14 @@ async function main() { }); const native = JSON.parse(oracleOut); - // 4. Pyodide side + compare. + // 4. Pyodide side + compare. The gate enforces TWO independent properties: + // (a) native↔wasm parity (compareResults) and + // (b) the EXPECTED verdict by corpus dir (checkExpectedVerdict): ok/* must + // succeed, err/* must be rejected. A purely differential check would + // let two identical crashes — or an ok/ fixture that erroneously errors + // — slip through; (b) closes that gap. const mismatches = []; + const verdictFailures = []; for (const { name, src } of items) { let browser; try { @@ -90,6 +96,9 @@ async function main() { } const m = compareResults(name, native[name], browser); if (m) mismatches.push(m); + const expectOk = name.startsWith("ok/"); + const v = checkExpectedVerdict(name, expectOk, native[name], browser); + if (v) verdictFailures.push(v); } // 5. release.json (versions derived from the loaded Pyodide lock). @@ -106,11 +115,16 @@ async function main() { writeFileSync(join(ROOT, "release.json"), JSON.stringify(release, null, 2) + "\n"); console.log("gate: release.json ->", JSON.stringify(release)); - if (mismatches.length) { - console.error(`gate: ${mismatches.length} MISMATCH(es):\n` + mismatches.join("\n")); + if (mismatches.length || verdictFailures.length) { + if (mismatches.length) { + console.error(`gate: ${mismatches.length} PARITY MISMATCH(es):\n` + mismatches.join("\n")); + } + if (verdictFailures.length) { + console.error(`gate: ${verdictFailures.length} VERDICT FAILURE(s) (wrong ok/err result):\n` + verdictFailures.join("\n")); + } process.exit(1); } - console.log(`gate: PARITY OK over ${items.length} fixtures`); + console.log(`gate: PARITY OK over ${items.length} fixtures (verdicts asserted: ok/* succeed, err/* rejected)`); } main().catch((e) => { diff --git a/gate/selftest.mjs b/gate/selftest.mjs index c2ff269..acf8f94 100644 --- a/gate/selftest.mjs +++ b/gate/selftest.mjs @@ -1,24 +1,52 @@ -// Canary: prove the gate's comparator actually catches a divergence. Imports the -// PURE comparator (gate/compare.mjs) so it runs in <1s without loading Pyodide. -import { compareResults } from "./compare.mjs"; +// Canary: prove the gate's checks actually catch divergences. Imports the PURE +// comparator + verdict checker (gate/compare.mjs) so it runs in <1s without +// loading Pyodide. Covers BOTH (a) native↔wasm parity (compareResults) and +// (b) expected-verdict-by-corpus-dir (checkExpectedVerdict). +import { checkExpectedVerdict, compareResults } from "./compare.mjs"; -const cases = [ - // [name, native, browser, mustFlag] - ["same-ok", { json: '{"ok":true,"cpp":"X"}' }, { json: '{"ok":true,"cpp":"X"}' }, false], +const OK = '{"ok":true,"cpp":"X"}'; +const ERR = '{"ok":false,"error":"e","diagnostics":[]}'; + +// --- (a) differential comparator cases: [name, native, browser, mustFlag] --- +const cmpCases = [ + ["same-ok", { json: OK }, { json: OK }, false], ["cpp-differs", { json: '{"ok":true,"cpp":"X"}' }, { json: '{"ok":true,"cpp":"Y"}' }, true], - ["verdict-differs", { json: '{"ok":true,"cpp":"X"}' }, { json: '{"ok":false,"error":"e","diagnostics":[]}' }, true], + ["verdict-differs", { json: OK }, { json: ERR }, true], ["error-differs", { json: '{"ok":false,"error":"a","diagnostics":[]}' }, { json: '{"ok":false,"error":"b","diagnostics":[]}' }, true], - ["unexpected-one-side", { json: '{"ok":true,"cpp":"X"}' }, { unexpected: "TypeError: boom" }, true], + ["unexpected-one-side", { json: OK }, { unexpected: "TypeError: boom" }, true], ["unexpected-both-same", { unexpected: "TypeError: boom" }, { unexpected: "TypeError: boom" }, false], ["unexpected-both-diff", { unexpected: "TypeError: a" }, { unexpected: "ValueError: b" }, true], - ["missing-native", undefined, { json: '{"ok":true,"cpp":"X"}' }, true], + ["missing-native", undefined, { json: OK }, true], +]; + +// --- (b) expected-verdict cases: [name, expectOk, native, browser, mustFlag] --- +// A native↔wasm match with the WRONG verdict (e.g. ok/ that errors, or a shared +// unexpected exception) must FAIL even though compareResults would pass it. +const verdictCases = [ + ["ok/good", true, { json: OK }, { json: OK }, false], + ["err/bad", false, { json: ERR }, { json: ERR }, false], + ["ok/that-errors-both-sides", true, { json: ERR }, { json: ERR }, true], + ["err/that-succeeds-both-sides", false, { json: OK }, { json: OK }, true], + ["ok/unexpected-both-same", true, { unexpected: "TypeError: boom" }, { unexpected: "TypeError: boom" }, true], + ["err/unexpected-both-same", false, { unexpected: "TypeError: boom" }, { unexpected: "TypeError: boom" }, true], + ["ok/native-wrong-only", true, { json: ERR }, { json: OK }, true], + ["ok/pyodide-wrong-only", true, { json: OK }, { json: ERR }, true], + ["ok/missing-native", true, undefined, { json: OK }, true], + ["ok/malformed", true, { json: "not json" }, { json: OK }, true], ]; let failed = 0; -for (const [name, n, b, mustFlag] of cases) { +for (const [name, n, b, mustFlag] of cmpCases) { const flagged = compareResults(name, n, b) !== null; if (flagged !== mustFlag) { - console.error(`selftest FAIL: ${name} expected mustFlag=${mustFlag} got ${flagged}`); + console.error(`selftest FAIL (compareResults): ${name} expected mustFlag=${mustFlag} got ${flagged}`); + failed++; + } +} +for (const [name, expectOk, n, b, mustFlag] of verdictCases) { + const flagged = checkExpectedVerdict(name, expectOk, n, b) !== null; + if (flagged !== mustFlag) { + console.error(`selftest FAIL (checkExpectedVerdict): ${name} expected mustFlag=${mustFlag} got ${flagged}`); failed++; } } @@ -26,4 +54,4 @@ if (failed) { console.error(`gate selftest: ${failed} case(s) failed`); process.exit(1); } -console.log(`gate selftest: ${cases.length} comparator cases OK`); +console.log(`gate selftest: ${cmpCases.length} comparator + ${verdictCases.length} verdict cases OK`); From d2a362d21058b703b25b4cff629894d822206fa5 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 17 Jun 2026 23:40:19 +0800 Subject: [PATCH 3/4] test(codegen): lock hash-seed determinism (multi-seed subprocess regression test) Spawns 5 python3 children with distinct PYTHONHASHSEED (0/1/2/7/12345), each transpiling a fixture that drives the call-site-cloned member path (a 4-call-site function with its own series vars that also pulls series from a sub-function), and asserts all stdout is byte-identical. PYTHONHASHSEED is read once at interpreter startup, so the running pytest process cannot vary it -- hence subprocesses. PYTHONPATH is set to the repo root so the children import the in-tree pineforge_codegen. Locks fix(codegen): deterministic member-clone ordering. Verified: passes on the fixed tree (1 distinct output) and fails on pre-fix origin/main (2 distinct outputs across these seeds). Self-contained: pure transpile, no engine/network, runs in default CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_codegen_determinism.py | 136 ++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/test_codegen_determinism.py diff --git a/tests/test_codegen_determinism.py b/tests/test_codegen_determinism.py new file mode 100644 index 0000000..56bdd11 --- /dev/null +++ b/tests/test_codegen_determinism.py @@ -0,0 +1,136 @@ +"""Regression: transpiler output must be byte-identical across PYTHONHASHSEED. + +Background +--------- +``CodeGen.__init__`` once iterated several Python ``set`` collections whose +iteration order is SipHash-randomized by ``PYTHONHASHSEED``. That order flowed +into the emitted C++ member declarations for *call-site-cloned* function-scoped +series/var members (``orig_names`` -> ``func_var_originals`` -> +``_func_cs_var_remap``). The same input therefore transpiled to byte-different +C++ depending on the (random) hash seed of the interpreter. Fixed in +``fix(codegen): deterministic member-clone ordering`` by making those +collections ordered + de-duplicated and ``sorted()``-ing the set-valued +``func_series_vars`` at the two base.py consumption points. + +This test locks that fix so it cannot silently regress. + +Why subprocesses +---------------- +``PYTHONHASHSEED`` is read once, at interpreter startup; the seed of the +*already-running* pytest process is fixed and cannot be changed in-process. +To probe multiple seeds we must spawn fresh ``python3`` children, each with a +different ``PYTHONHASHSEED`` in its environment, and compare their stdout. + +The fixture exercises the exact path that varied pre-fix: a multi-call-site +function (``calc``, 4 sites) whose body uses its own local history-accessed +series vars (``m``/``n``/``c``) *and* pulls series from a sub-function +(``sub``, which also has its own series vars). That makes the codegen clone +function-scoped series members per call site and union series across the +parent + sub-function -- the ``all_func_scoped_series`` union the fix repaired. + +Self-contained: pure transpile, no engine headers, no compiler, no network -- +so it runs in default CI. (Verified locally that it produces 1 distinct hash +on the fixed tree and >1 on pre-fix ``origin/main``.) +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +# Repo root = parent of this tests/ directory. Threaded into the child via +# PYTHONPATH so ``import pineforge_codegen`` resolves regardless of how/where +# pytest was launched. +_REPO_ROOT = Path(__file__).resolve().parents[1] + +# A strategy that drives the cloned-member path. ``calc`` is called from 4 +# distinct sites (-> call-site cloning), uses its own local series vars, and +# pulls a series value from ``sub`` (which has its own series vars too). This +# is the shape that produced byte-different C++ across hash seeds pre-fix. +FIXTURE = """//@version=6 +strategy("Determinism Fixture", overlay=true) + +// Sub-function with its OWN history-accessed local series vars. Its series +// vars must be cloned into the parent's per-call-site members, exercising the +// parent <- sub-function series union the fix made deterministic. +sub(float x) => + a = ta.sma(x, 5) + b = ta.ema(x, 8) + c = a - b + d = c[1] + c[2] + a + b + c + d + +// Parent: own local series vars (m, n) PLUS a pull from sub(). Called from 4 +// sites below -> call-site cloning (cs1..cs3) clones these function-scoped +// series members, whose declaration order is what regressed across seeds. +calc(float src, int len) => + m = ta.sma(src, len) + n = ta.rma(src, len) + p = m[1] - n[2] + q = sub(src) + r = p + q + m[3] + n[4] + r + +s1 = calc(close, 10) +s2 = calc(open, 14) +s3 = calc(high, 20) +s4 = calc(low, 7) + +plot(s1 + s2 + s3 + s4) +""" + +# Child program: read the fixture on stdin, transpile, write the C++ to stdout +# verbatim. Any exception propagates as a non-zero exit + traceback on stderr. +_CHILD = ( + "import sys\n" + "from pineforge_codegen import transpile\n" + "sys.stdout.write(transpile(sys.stdin.read()))\n" +) + +# Spread across the SipHash seed space: 0 disables randomization entirely; +# the rest are arbitrary fixed seeds. Pre-fix, this set yields >1 distinct +# output; post-fix it must yield exactly 1. +_SEEDS = [0, 1, 2, 7, 12345] + + +def _transpile_under_seed(seed: int) -> str: + """Transpile FIXTURE in a fresh interpreter pinned to ``PYTHONHASHSEED=seed``.""" + env = { + **os.environ, + "PYTHONHASHSEED": str(seed), + # Prepend repo root so the child imports the in-tree package even if + # an installed copy exists elsewhere on the path. + "PYTHONPATH": os.pathsep.join( + [str(_REPO_ROOT), os.environ.get("PYTHONPATH", "")] + ).rstrip(os.pathsep), + } + proc = subprocess.run( + [sys.executable, "-c", _CHILD], + input=FIXTURE, + env=env, + capture_output=True, + text=True, + timeout=120, + ) + assert proc.returncode == 0, ( + f"transpile subprocess failed under PYTHONHASHSEED={seed} " + f"(exit={proc.returncode}).\n--- stderr (tail) ---\n" + + "\n".join((proc.stderr or "").splitlines()[-40:]) + ) + assert proc.stdout, f"transpile produced empty output under PYTHONHASHSEED={seed}" + return proc.stdout + + +def test_transpile_byte_identical_across_hash_seeds() -> None: + """All seeds must yield byte-identical C++ (locks the member-clone fix).""" + outputs = [_transpile_under_seed(seed) for seed in _SEEDS] + distinct = set(outputs) + assert len(distinct) == 1, ( + "Transpiler output is NOT deterministic across PYTHONHASHSEED: " + f"{len(distinct)} distinct outputs across seeds {_SEEDS}. " + "This is the hash-seed member-clone-ordering regression " + "(see fix(codegen): deterministic member-clone ordering). " + "Likely a set is being iterated into emitted C++ again." + ) From 85c751ecf633c4445a205175e00561cdaa130d0c Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 17 Jun 2026 23:42:28 +0800 Subject: [PATCH 4/4] =?UTF-8?q?ci(release):=20gate-block=20PyPI=20+=20publ?= =?UTF-8?q?ish=20before=20tagging=20(gate=E2=86=92build=E2=86=92PyPI?= =?UTF-8?q?=E2=86=92tag=E2=86=92npm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 38 ++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e3face..15de33e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,8 +36,9 @@ jobs: release: runs-on: ubuntu-latest env: - BUMP: ${{ inputs.bump }} - OVERRIDE: ${{ inputs.override }} + BUMP: ${{ inputs.bump }} + OVERRIDE: ${{ inputs.override }} + PYTHONHASHSEED: "0" # deterministic gate runtime (match gate.yml) steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -83,7 +84,22 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: "3.12" + python-version: "3.14" # match the gate's runtime + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" + + # Conformance gate (BLOCKING): the differential parity gate must pass + # before anything is built, published, or tagged. A gate-broken codegen + # fails the release here — nothing reaches PyPI, no tag is pushed (so the + # tag->npm trigger never fires), and no GitHub Release is created. + - name: Run conformance gate (blocking) + run: | + python -m pip install -e . + npm ci + npm run gate:selftest + npm run gate:full - name: Build sdist + wheel run: | @@ -91,8 +107,16 @@ jobs: python -m build ls -l dist/ - # Commit + tag before publishing so a tag always corresponds to a built - # artifact set; the GitHub Release (with files) comes after PyPI succeeds. + # Publish to PyPI BEFORE the tag is pushed. The tag push triggers + # publish-pyodide.yml (npm), so PyPI must land first — otherwise a PyPI + # failure would leave npm shipping a version PyPI never got. + - name: Publish to PyPI + if: ${{ !inputs.dry_run }} + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + + # Commit + tag + push AFTER PyPI succeeds. The `git push --tags` triggers + # publish-pyodide.yml for npm (which re-runs the gate — defense in depth), + # so the tag only exists once the gate passed and PyPI published. - name: Commit + tag + push if: ${{ !inputs.dry_run }} env: @@ -105,10 +129,6 @@ jobs: git tag "v${NEW_VERSION}" git push origin HEAD --tags - - name: Publish to PyPI - if: ${{ !inputs.dry_run }} - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 - - name: Create GitHub Release if: ${{ !inputs.dry_run }} env: