mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
release on a tag, through the forge
Publishing by hand needs a laptop with two credentials on it, and gets the gate only if whoever ran it remembered. This runs the same `check.sh` every push runs, then builds and uploads — and refuses at the first step if the tag and the workspace version disagree, because a release tagged v0.3.0 that publishes 0.2.0 cannot be undone: PyPI will not reuse a version even after it is yanked. Wheels are cross-compiled with zig for x86_64 and aarch64 against an older glibc than the build image ships, so they are not silently pinned to bookworm's, and an sdist covers everything else. PyPI is uploaded directly. npm is only staged: npm is withdrawing direct publish from tokens that bypass 2FA, and its trusted publishing is OIDC against GitHub and GitLab, which this forge is neither. So the runner does the part a token may do, and a maintainer approves the tarball with 2FA. A missing NPM_TOKEN is a warning rather than a failure, so a release can go to PyPI while the npm credential is still being sorted out.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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
|
||||
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
|
||||
cd crates/bindings/npm/dist && npm stage publish --access public
|
||||
echo "::notice::staged catena-corpus@$version; approve it with 2FA to publish"
|
||||
@@ -4,3 +4,4 @@ target/
|
||||
|
||||
# Built npm package; `crates/bindings/npm/build.sh` makes it.
|
||||
crates/bindings/npm/dist/
|
||||
dist/
|
||||
|
||||
Reference in New Issue
Block a user