1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
name: Publish to crates.io
# Publish the crate. Triggered two ways:
# * `workflow_call` from auto-release.yml on a version bump (automated path).
# That commit already passed lint/test before landing on main (via
# cut-release.yml's `needs: [version, lint, test]` gate and/or PR branch
# protection), so re-running here would be redundant — skipped below.
# * a manual `v*` tag push (escape hatch for hotfixes cut by hand). Unlike
# the automated path, this can tag *any* commit, verified or not, so this
# is where the gap was (see #260): publish ran without ever checking
# cargo test/fmt/clippy. Fixed by calling lint.yml/test.yml (already
# workflow_call-callable) and gating `publish` on them via `needs:`.
#
# The `lint`/`test` skip below keys off `inputs.tag`, not `github.event_name`:
# a *nested* `workflow_call` (this workflow, called from auto-release.yml,
# itself triggered by a `push` to main) inherits `github.event_name` from the
# chain's originating event — it reads `push` here too, not `workflow_call` —
# so `github.event_name == 'push'` was always true and never actually skipped
# anything. That silently ran this workflow's own `test.yml` call alongside
# release.yml's identical call *and* the plain push-triggered `test.yml`, all
# three racing for the same `test-${{ github.ref }}` concurrency group with
# `cancel-in-progress: true` — auto-release.yml's `publish`/`release` jobs
# routinely lost that race and the crate silently failed to publish. `inputs`
# is only populated on the `workflow_call` path (`required: true` there), so
# `inputs.tag == ''` reliably means "not called with a tag" — i.e. the direct
# tag-push escape hatch — regardless of what `github.event_name` reports.
on:
push:
tags:
- 'v*'
workflow_call:
inputs:
tag:
description: Tag to publish (e.g. v0.14.0)
required: true
type: string
# Restrict the default GITHUB_TOKEN to read-only; this workflow only checks
# out the repo to build and publish to crates.io (via CARGO_REGISTRY_TOKEN,
# a separate secret) — it never pushes back to GitHub.
permissions:
contents: read
jobs:
# Gate for the manual `push: tags` escape hatch only — see #260. Skipped
# entirely on the `workflow_call` path since that commit is already verified
# (see header comment above).
lint:
name: Lint tagged commit
if: inputs.tag == ''
uses: ./.github/workflows/lint.yml
test:
name: Test tagged commit
if: inputs.tag == ''
uses: ./.github/workflows/test.yml
publish:
name: Publish
needs:
# Run if lint/test passed (push path) or were skipped (workflow_call path).
# Do NOT use the default `success()` here: a skipped need would otherwise
# make this job skip too.
if: always() && (needs.lint.result == 'success' || needs.lint.result == 'skipped') && (needs.test.result == 'success' || needs.test.result == 'skipped')
runs-on: ubuntu-latest
permissions:
id-token: write # required for crates.io Trusted Publishing (OIDC)
contents: read
env:
TAG: ${{ inputs.tag || github.ref_name }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
- name: Cache cargo registry (registry only, not target)
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile
- name: Build UI
run: cargo check
# Runs build.rs fresh (no target cache) → pnpm builds the React client → writes
# prebuilt.html at the package root (not client/): it isn't a Cargo workspace member
# and cargo publish strips it from the moadim tarball otherwise.
- name: Verify prebuilt UI was generated
run: test -s prebuilt.html || (echo "prebuilt.html missing or empty" && exit 1)
- name: Verify prebuilt UI ships in the published tarball
run: cargo package --list --allow-dirty | grep -qx prebuilt.html || (echo "prebuilt.html not in package tarball" && exit 1)
# No commit step for prebuilt.html here: it used to be an untracked build
# artifact committed on the runner, but it's tracked in the repo now (kept
# in lockstep with client/src by prebuilt.yml), so the fresh build is
# normally byte-identical and a bare `git commit` fails the job with
# "nothing to commit" — which silently blocked the v1.5.0/v1.6.0 crates.io
# publishes. `cargo publish --allow-dirty` below packages the working-tree
# file either way, so a commit was never needed for tarball inclusion.
- name: Verify tag matches Cargo.toml version
run: |
CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
TAG_VERSION=${TAG#v}
if [ "$CARGO_VERSION" != "$TAG_VERSION" ]; then
echo "Version mismatch: Cargo.toml=$CARGO_VERSION, tag=$TAG_VERSION"
exit 1
fi
- name: Authenticate with crates.io (Trusted Publishing)
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
id: auth
- name: Publish
run: cargo publish --allow-dirty
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}