11# Run tests and on main push create prerelease with npm package.
22# When you "Set as latest release" (uncheck prerelease), the npm-release workflow publishes to npm.
3+ #
4+ # Version bumps use tishlang/sem (@tishlang/sem) — Conventional Commits drive semver
5+ # (feat/fix/perf/BREAKING release; chore/docs/ci do not). Config: .semrc.json
36
47name : CI (test, prerelease)
58
@@ -104,105 +107,114 @@ jobs:
104107 cargo publish --dry-run --allow-dirty
105108 rm -rf target/package
106109
110+ # ── Would this land a release? Runs on PRs so a bad commit type is caught before merge. ───────
107111 release_check :
108- name : Release check (semantic-release dry-run)
112+ name : Release check (sem dry-run)
109113 runs-on : ubuntu-latest
110- if : github.ref == 'refs/heads/main' && github.event_name == 'push'
111114 steps :
112- - name : Checkout
113- uses : actions/checkout@v4
114- with :
115- fetch-depth : 0
116-
117- - name : Setup Node
118- uses : actions/setup-node@v4
115+ - uses : actions/checkout@v4
116+ with : { fetch-depth: 0 }
117+ - uses : actions/setup-node@v4
118+ with : { node-version: "22" }
119+
120+ - name : Dry-run sem
121+ id : sem
122+ uses : tishlang/sem@v1
119123 with :
120- node-version : " 22"
121-
122- - name : Install dependencies (incl. semantic-release)
123- run : npm ci
124-
125- - name : Set repository URL
126- run : |
127- node -e "
128- const fs = require('fs');
129- const p = require('./package.json');
130- p.repository = { type: 'git', url: process.env.REPO_URL };
131- fs.writeFileSync('./package.json', JSON.stringify(p, null, 2));
132- "
133- env :
134- REPO_URL : ${{ format('{0}/{1}.git', github.server_url, github.repository) }}
135-
136- - name : Require incremental release
124+ dry_run : true
125+ config : |
126+ branches: [main]
127+ plugins:
128+ - "@sem/commit-analyzer"
129+ - "@sem/release-notes-generator"
137130 env :
138131 GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
132+
133+ - name : Report bump
139134 run : |
140- npx semantic-release --dry-run --extends ./.releaserc.json 2>&1 | tee release-check.log
141- if grep -q "There are no relevant changes, so no new version is released." release-check.log; then
142- echo "No incremental release would be triggered. Push commits that trigger a release (feat/fix/perf/BREAKING CHANGE per conventional commits)."
143- exit 1
135+ if [ "${{ steps.sem.outputs.new_release_published }}" = "true" ]; then
136+ echo "::notice::Would release ${{ steps.sem.outputs.new_release_version }} from these commits."
137+ elif [ "${{ steps.sem.outputs.skipped }}" = "true" ]; then
138+ echo "::notice::Soft-skipped (${{ steps.sem.outputs.skip_reason }})."
139+ else
140+ echo "::notice::No version bump. Conventional Commits drive semver — feat/fix/perf/BREAKING release, chore/docs/ci do not."
144141 fi
145- echo "Release would be triggered - check passed."
146142
147143 release :
148- name : Release (prerelease branch + GitHub API)
144+ name : Prerelease
149145 needs : [test, rust_crate, release_check]
150146 if : github.ref == 'refs/heads/main' && github.event_name == 'push'
151147 runs-on : ubuntu-latest
152148 permissions :
153149 contents : write
150+ # @sem/github opens a failure issue when a release throws. Without this it cannot, and the
151+ # real error is buried under a 403 from the issues API.
152+ issues : write
154153 steps :
155- - name : Checkout
156- uses : actions/checkout@v4
157- with :
158- fetch-depth : 0
159-
160- - name : Setup Node
161- uses : actions/setup-node@v4
162- with :
163- node-version : " 22"
154+ - uses : actions/checkout@v4
155+ with : { fetch-depth: 0 }
156+ - uses : actions/setup-node@v4
157+ with : { node-version: "22" }
164158
165159 - name : Install dependencies
166160 run : npm ci
167161
168- - name : Set repository URL
162+ # A tag is a commit-shaped object and actions/checkout configures no identity, so sem dies with
163+ # "Failed to create tag: Committer identity unknown" without this. chuggie-engine shipped zero
164+ # releases for months on exactly that.
165+ - name : Configure git identity for the release tag
169166 run : |
170- node -e "
171- const fs = require('fs');
172- const p = require('./package.json');
173- p.repository = { type: 'git', url: process.env.REPO_URL };
174- fs.writeFileSync('./package.json', JSON.stringify(p, null, 2));
175- "
176- env :
177- REPO_URL : ${{ format('{0}/{1}.git', github.server_url, github.repository) }}
167+ git config user.name "github-actions[bot]"
168+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
178169
179- - name : Get next version (semantic-release dry-run)
180- id : next_version
170+ - name : Next version via sem (dry-run)
171+ id : sem
172+ uses : tishlang/sem@v1
173+ with :
174+ dry_run : true
175+ config : |
176+ branches: [main]
177+ plugins:
178+ - "@sem/commit-analyzer"
179+ - "@sem/release-notes-generator"
181180 env :
182181 GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
182+
183+ - name : Skip when no bump
184+ id : gate
183185 run : |
184- npx semantic-release --dry-run --extends ./.releaserc.json 2>&1 | tee release.log
185- VERSION=$(sed -n 's/.*Published release \([0-9.]*\).*/\1/p' release.log | head -1)
186- if [ -z "$VERSION" ]; then
187- echo "Could not determine next version from semantic-release output"
188- exit 1
186+ if [ "${{ steps.sem.outputs.new_release_published }}" != "true" ]; then
187+ echo "::notice::No version bump — skipping prerelease."
188+ echo "continue=false" >> "$GITHUB_OUTPUT"
189+ exit 0
189190 fi
190- echo "version=$VERSION" >> $GITHUB_OUTPUT
191- echo "Next version: $VERSION"
192-
193- - name : Set package version
191+ echo "continue=true" >> "$GITHUB_OUTPUT"
192+ echo "version=${{ steps.sem.outputs.new_release_version }}" >> "$GITHUB_OUTPUT"
193+ echo "Next version: ${{ steps.sem.outputs.new_release_version }}"
194+
195+ # crates.io has no delete, only yank, and npm unpublish is a 72-hour window — so an artifact
196+ # published under the wrong licence is effectively permanent. This repo has already shipped two
197+ # versions under the wrong one and had to yank both. Refuse to build an artifact unless the
198+ # LICENSE the packages and the crate point at is actually present.
199+ - name : Refuse to package without a licence
200+ if : steps.gate.outputs.continue == 'true'
194201 run : |
195- node -e "
196- const fs = require('fs');
197- const p = require('./package.json');
198- p.version = process.env.VERSION;
199- fs.writeFileSync('./package.json', JSON.stringify(p, null, 2));
200- "
201- env :
202- VERSION : ${{ steps.next_version.outputs.version }}
202+ for f in LICENSE packages/player/LICENSE; do
203+ if [ ! -f "$f" ]; then
204+ echo "::error::Missing $f, but package.json declares a licence. Add the file, or correct the declaration, before anything is published."
205+ exit 1
206+ fi
207+ done
203208
204- - name : Create npm package tarball (@spacedevin/deck)
209+ - name : Stamp the version and pack @spacedevin/deck
210+ if : steps.gate.outputs.continue == 'true'
205211 run : |
212+ node -e '
213+ const fs = require("fs"), p = "./package.json";
214+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
215+ j.version = process.argv[1];
216+ fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n");
217+ ' "${{ steps.gate.outputs.version }}"
206218 npm pack
207219 mv spacedevin-deck-*.tgz spacedevin-deck-npm-package.tgz
208220
@@ -214,111 +226,43 @@ jobs:
214226 # dependency only matters to a consumer compiling from src/index.tish via the `tish` export
215227 # condition — and for them a compatible minor is fine. An exact pin would just force npm to
216228 # install a second copy alongside a consumer's own @spacedevin/deck.
217- - name : Set player version and pin its dependency
218- run : |
219- node -e "
220- const fs = require('fs');
221- const p = './packages/player/package.json';
222- const j = JSON.parse(fs.readFileSync(p));
223- j.version = process.env.VERSION;
224- j.dependencies['@spacedevin/deck'] = '^' + process.env.VERSION;
225- fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
226- "
227- env :
228- VERSION : ${{ steps.next_version.outputs.version }}
229-
230- - name : Create npm package tarball (@spacedevin/deck-player)
229+ - name : Stamp the version and pack @spacedevin/deck-player
230+ if : steps.gate.outputs.continue == 'true'
231231 run : |
232+ node -e '
233+ const fs = require("fs"), p = "./packages/player/package.json";
234+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
235+ j.version = process.argv[1];
236+ j.dependencies["@spacedevin/deck"] = "^" + process.argv[1];
237+ fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n");
238+ ' "${{ steps.gate.outputs.version }}"
232239 npm pack -w @spacedevin/deck-player
233240 mv spacedevin-deck-player-*.tgz spacedevin-deck-player-npm-package.tgz
234241
235- - name : Create or update release branch and push
236- env :
237- GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
238- run : |
239- git config user.name "github-actions[bot]"
240- git config user.email "github-actions[bot]@users.noreply.github.com"
241- BRANCH="release/v${{ steps.next_version.outputs.version }}"
242- git checkout -B "$BRANCH"
243- git push origin "$BRANCH" --force
244-
245- - name : Generate release notes from commits
246- id : changelog
247- run : |
248- LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
249- if [ -n "$LAST_TAG" ]; then
250- CHANGELOG=$(git log "$LAST_TAG..HEAD" --pretty=format:"- %s (%h)" --no-merges 2>/dev/null || echo "")
251- else
252- CHANGELOG=$(git log -30 --pretty=format:"- %s (%h)" --no-merges 2>/dev/null || echo "")
253- fi
254- [ -z "$CHANGELOG" ] && CHANGELOG="- No commits to list"
255- {
256- echo "## Changes"
257- echo ""
258- echo "$CHANGELOG"
259- echo ""
260- } > release-body.md
261-
262- - name : Create or update GitHub prerelease via API
263- id : create_release
264- env :
265- GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
266- VERSION : ${{ steps.next_version.outputs.version }}
267- run : |
268- TAG="v${VERSION}"
269- BRANCH="release/v${VERSION}"
270- BODY=$(cat release-body.md)
271- RESP=$(curl -s -w "\n%{http_code}" -X POST \
272- -H "Authorization: Bearer $GITHUB_TOKEN" \
273- -H "Accept: application/vnd.github+json" \
274- -H "X-GitHub-Api-Version: 2022-11-28" \
275- "https://api.github.com/repos/${{ github.repository }}/releases" \
276- -d "{\"tag_name\":\"$TAG\",\"target_commitish\":\"$BRANCH\",\"name\":\"$TAG\",\"body\":$(echo "$BODY" | jq -Rs .),\"prerelease\":true}")
277- HTTP_CODE=$(echo "$RESP" | tail -n1)
278- BODY_RESP=$(echo "$RESP" | sed '$d')
279- if [ "$HTTP_CODE" = "201" ]; then
280- echo "Created prerelease $TAG"
281- RELEASE_ID=$(echo "$BODY_RESP" | jq -r .id)
282- elif [ "$HTTP_CODE" = "422" ]; then
283- RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
284- "https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG" | jq -r .id)
285- if [ "$RELEASE_ID" != "null" ] && [ -n "$RELEASE_ID" ]; then
286- curl -s -X PATCH \
287- -H "Authorization: Bearer $GITHUB_TOKEN" \
288- -H "Accept: application/vnd.github+json" \
289- -H "X-GitHub-Api-Version: 2022-11-28" \
290- "https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID" \
291- -d "{\"target_commitish\":\"$BRANCH\",\"body\":$(echo "$BODY" | jq -Rs .),\"prerelease\":true}"
292- echo "Updated existing prerelease $TAG"
293- else
294- echo "Release creation failed (422) and could not find release by tag"
295- exit 1
296- fi
297- else
298- echo "Unexpected response: $HTTP_CODE"
299- echo "$BODY_RESP"
300- exit 1
301- fi
302- echo "release_id=$RELEASE_ID" >> $GITHUB_OUTPUT
303-
304- - name : Upload npm package to prerelease
242+ # Promoting this prerelease to a full release is what fires npm-release.yml and
243+ # crates-release.yml. The two-step exists so a release is never published by the same run that
244+ # decided to make one — there is a human between "CI is green" and "this is on npm forever".
245+ - name : Create the prerelease via @sem/github
246+ if : steps.gate.outputs.continue == 'true'
247+ uses : tishlang/sem@v1
248+ with :
249+ force : ${{ steps.gate.outputs.version }}
250+ config : |
251+ branches: [main]
252+ plugins:
253+ - "@sem/commit-analyzer"
254+ - "@sem/release-notes-generator"
255+ - - "@sem/npm"
256+ - npmPublish: false
257+ - - "@sem/github"
258+ - prerelease: true
259+ successComment: false
260+ assets:
261+ - path: spacedevin-deck-npm-package.tgz
262+ name: spacedevin-deck-npm-package.tgz
263+ label: npm package (@spacedevin/deck)
264+ - path: spacedevin-deck-player-npm-package.tgz
265+ name: spacedevin-deck-player-npm-package.tgz
266+ label: npm package (@spacedevin/deck-player)
305267 env :
306268 GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
307- run : |
308- RELEASE_ID="${{ steps.create_release.outputs.release_id }}"
309- REPO="${{ github.repository }}"
310- UPLOAD_URL="https://uploads.github.com/repos/$REPO/releases/$RELEASE_ID/assets"
311- curl -s -X POST \
312- -H "Authorization: Bearer $GITHUB_TOKEN" \
313- -H "Accept: application/vnd.github+json" \
314- -H "X-GitHub-Api-Version: 2022-11-28" \
315- -H "Content-Type: application/octet-stream" \
316- --data-binary @spacedevin-deck-npm-package.tgz \
317- "${UPLOAD_URL}?name=spacedevin-deck-npm-package.tgz&label=npm%20package%20(@spacedevin/deck)"
318- curl -s -X POST \
319- -H "Authorization: Bearer $GITHUB_TOKEN" \
320- -H "Accept: application/vnd.github+json" \
321- -H "X-GitHub-Api-Version: 2022-11-28" \
322- -H "Content-Type: application/octet-stream" \
323- --data-binary @spacedevin-deck-player-npm-package.tgz \
324- "${UPLOAD_URL}?name=spacedevin-deck-player-npm-package.tgz&label=npm%20package%20(@spacedevin/deck-player)"
0 commit comments