Files
funkpower d55486977d release: staging cannot create a package that has never existed
The registry answers POST /-/stage/package with a 404 for a name it has not
seen, so the first release of a new name cannot be staged at all — no token
scope changes that. The step now says so and lets the release stand rather
than failing after PyPI has already published.

The first version goes out by hand with 2FA; every one after it stages from
here.
2026-09-21 08:45:50 -04:00

162 lines
8.1 KiB
YAML

name: release
# Tag-driven. Cargo.toml carries the version; the tag only points at it, and
# the first step refuses to go on if the two disagree — a release tagged
# v0.3.0 that publishes 0.2.0 cannot be undone, because PyPI will not let a
# version be reused even after it is yanked.
on:
push:
tags: ['v*']
# One job on purpose. Splitting build from publish would mean passing wheels
# between jobs through actions/upload-artifact, which is a JavaScript action:
# the runner executes it with `node` inside this container, and a Rust image
# has none until this workflow installs one. Everything a release needs fits
# in one job, so nothing has to be handed over.
jobs:
publish:
runs-on: docker
container:
image: rust:1.98-bookworm
steps:
# Not actions/checkout, for the reason above. The token goes in a header
# rather than in the remote URL, so it is not written into .git/config
# where a later step could print it — and the clone keeps working if the
# repository is ever made private.
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git init -q .
git config --local "http.$GITHUB_SERVER_URL/.extraheader" \
"Authorization: Basic $(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')"
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
git fetch -q --depth 1 origin "$GITHUB_SHA"
git checkout -q FETCH_HEAD
- name: Tag must match the workspace version
run: |
tag="${GITHUB_REF_NAME#v}"
crate=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
echo "tag=$tag workspace=$crate"
[ "$tag" = "$crate" ] || { echo "::error::tag $tag != Cargo.toml $crate"; exit 1; }
# What CI runs on every push, run again here. A release is the one build
# nobody gets to re-run after the fact: PyPI will not reuse a version and
# neither will npm, so the gate belongs before the upload rather than
# only on the branch that led to it.
- name: The gate
run: |
apt-get update -qq
apt-get install -qq -y --no-install-recommends python3 >/dev/null
rustup component add rustfmt clippy
rustup target add wasm32-unknown-unknown
./check.sh
- name: Build wheels and sdist
run: |
apt-get install -qq -y --no-install-recommends python3-venv >/dev/null
python3 -m venv /tmp/venv
# readme-renderer[md] is what makes `twine check` actually parse the
# long description. Without it the render returns None and the check
# reports PASSED regardless, even with --strict.
/tmp/venv/bin/pip install -q 'maturin[zig]' twine 'readme-renderer[md]'
# The venv has to be first on PATH, not merely called by absolute
# path. maturin looks for zig by running `python3 -m ziglang`, and
# `maturin[zig]` puts ziglang in the venv only, so a bare python3
# resolves to the system one, which does not have it.
export PATH="/tmp/venv/bin:$PATH"
python3 -m ziglang version
# --zig cross-compiles against an older glibc than this image ships,
# so the wheel is not silently pinned to bookworm's.
cd crates/bindings
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
rustup target add "$target"
maturin build --release --zig --target "$target" --out ../../dist
done
maturin sdist --out ../../dist
ls -l ../../dist
- name: Build the npm package
run: |
# Node from nodesource rather than apt. `npm stage` below wants npm
# 11.15 and Node 22.14, and bookworm ships Node 18.
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -qq -y --no-install-recommends nodejs >/dev/null
npm install --global --silent npm@^11.15
curl -sSfL https://rustwasm.github.io/wasm-pack/installer/init.sh | sh
# wasm-pack fetches binaryen itself and that fetch dies on this
# runner, which is how the first release failed. It uses a wasm-opt
# already on PATH when it finds one, so fetch the same archive with
# curl — which works here — and let it find that instead. The
# version is the one wasm-pack asks for; a mismatch only means it
# would download its own again.
binaryen=version_117
curl -sSfL "https://github.com/WebAssembly/binaryen/releases/download/$binaryen/binaryen-$binaryen-x86_64-linux.tar.gz" \
| tar xz -C /tmp
export PATH="/tmp/binaryen-$binaryen/bin:$PATH"
wasm-opt --version
cd crates/bindings/npm
# The declarations are hand-written, so nothing but a type checker
# would notice them going wrong. build.sh skips the check when tsc
# is absent, which in a release is not good enough.
npm install --no-save --silent typescript@5
./build.sh
node test.mjs
- name: Publish to PyPI
env:
# PyPI's trusted publishing is OIDC and supports GitHub, GitLab,
# Google Cloud Build and ActiveState — not Forgejo — so this forge
# has to hold a long-lived credential. Scope it to this project once
# the project exists: the first upload of a new name needs an
# account-wide token, because a project-scoped one cannot create the
# project it is scoped to.
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
# By absolute path: each step gets its own shell, so the PATH the
# build step exported for zig's sake does not reach this one.
/tmp/venv/bin/twine check --strict dist/*
# --skip-existing so a retry after a partial upload finishes the job
# instead of dying on the first file. A version cannot be reused, so
# a half-published release would otherwise burn it.
/tmp/venv/bin/twine upload --non-interactive --skip-existing dist/*
# Staged, not published. npm is withdrawing direct publish from any token
# that bypasses 2FA, and its trusted publishing is OIDC against GitHub
# Actions and GitLab, which this forge is neither. So the runner does the
# part a token may do and stages the tarball, and a maintainer approves
# it with 2FA: `npm stage list`, then `npm stage approve <id>`, or the
# Staged Packages tab on npmjs.com. Nothing reaches the registry until
# then.
- name: Stage the npm release
env:
# Granular, write on catena-corpus alone, and it does not need to
# bypass 2FA — staging is exactly what a token that cannot bypass it
# is still allowed to do.
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "$NPM_TOKEN" ]; then
echo "::warning::NPM_TOKEN is not set; nothing staged on npm"
exit 0
fi
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
version="${GITHUB_REF_NAME#v}"
if npm view "catena-corpus@$version" version >/dev/null 2>&1; then
echo "::notice::catena-corpus@$version is already published"
exit 0
fi
# Staging offers a new version of a package that exists. It cannot
# bring one into being: the registry answers `POST /-/stage/package`
# with a 404 for a name it has never seen. So the first release of a
# new name is published by hand, with 2FA, and every release after
# it goes through here. Not a failure — PyPI is already out.
if ! npm view catena-corpus version >/dev/null 2>&1; then
echo "::warning::catena-corpus does not exist on npm yet, and staging cannot create it."
echo "::warning::Publish the first version by hand: npm publish crates/bindings/npm/dist --access public"
exit 0
fi
cd crates/bindings/npm/dist && npm stage publish --access public
echo "::notice::staged catena-corpus@$version; approve it with 2FA to publish"