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. # 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: - name: Checkout run: | git init -q . 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 Cargo.toml run: | tag="${GITHUB_REF_NAME#v}" crate=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) echo "tag=$tag crate=$crate" [ "$tag" = "$crate" ] || { echo "::error::tag $tag != Cargo.toml $crate"; exit 1; } - name: Tests run: cargo test - name: Build wheels and sdist run: | apt-get update && apt-get install -y --no-install-recommends python3 python3-venv 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 '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: "Failed to find # zig", after apt and rustup have already run. 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. 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 -y --no-install-recommends nodejs npm install --global npm@^11.15 rustup target add wasm32-unknown-unknown version=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock) curl -sSfL "https://github.com/rustwasm/wasm-bindgen/releases/download/$version/wasm-bindgen-$version-x86_64-unknown-linux-musl.tar.gz" \ | tar xz -C /tmp export PATH="/tmp/wasm-bindgen-$version-x86_64-unknown-linux-musl:$PATH" npm install --global typescript@5 cargo build --release npm/build.sh node npm/test.mjs - name: Publish env: # A project-scoped token, not the account-wide one. 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. Scoping it to this one project is the whole of the # mitigation available: if the forge is compromised, what is lost is the # ability to publish typikon rather than every package the account owns. 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 `, or the Staged # Packages tab on npmjs.com. Nothing reaches the registry until then. - name: Stage the npm release env: # Granular, write on typikon alone, and it does not need to bypass # 2FA, because 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 "typikon@$version" version >/dev/null 2>&1; then echo "::notice::typikon@$version is already published" exit 0 fi cd npm/dist && npm stage publish --access public echo "::notice::staged typikon@$version; approve it with 2FA to publish"