Skip to content

Commit 187923e

Browse files
authored
fix: ci: release with sem instead of legacy semantic-release (#19)
Every other repo that cuts releases this way runs tishlang/sem; deck was the odd one out, still on semantic-release with the whole prerelease flow written by hand. Same git workflow either way — green main cuts a prerelease carrying the tarballs, promoting it fires npm-release.yml and crates-release.yml — but the machinery is now the shared one. What that deletes is most of the point. The release job had been hand-rolling a `release/vX` branch, a `git log` changelog, and raw curl calls to the releases API with a 422-means-it-exists retry path and separate asset uploads. `@sem/github` does all of it, so ~190 lines of workflow become a plugin config. Two things carried over from chuggie-engine's setup because they are not obvious: - git identity is configured before sem runs. A tag is a commit-shaped object and actions/checkout sets no identity, so sem dies on "Committer identity unknown". That failure is why chuggie-engine had zero releases for months. - `issues: write`, because @sem/github opens an issue when a release throws. Without it the real error is buried under a 403 from the issues API. Also added the licence gate from that repo, since this is the repo whose two mispublished versions it was written about — it now refuses to build an artifact unless both LICENSE files are present. .releaserc.json becomes .semrc.json, and the semantic-release toolchain leaves devDependencies entirely: the action carries its own, so npm ci drops ~500 transitive packages.
1 parent 297dd2e commit 187923e

6 files changed

Lines changed: 610 additions & 6064 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 116 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
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

47
name: 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)"

‎.releaserc.json‎

Lines changed: 0 additions & 9 deletions
This file was deleted.

‎.semrc.json‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"branches": [
3+
"main"
4+
],
5+
"plugins": [
6+
"@sem/commit-analyzer",
7+
"@sem/release-notes-generator",
8+
[
9+
"@sem/npm",
10+
{
11+
"npmPublish": false
12+
}
13+
],
14+
[
15+
"@sem/github",
16+
{
17+
"prerelease": true
18+
}
19+
]
20+
]
21+
}

‎README.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,12 @@ npm also exports `./grammar`, `./ast`, `./examples`, `./extension` and `./host`
127127

128128
## Release
129129

130-
Matches [lattish](https://github.com/tishlang/lattish): semantic-release prerelease → promote → OIDC npm publish.
130+
Version bumps come from [sem](https://github.com/tishlang/sem) — Conventional Commits drive semver
131+
(`feat`/`fix`/`perf`/`BREAKING` release; `chore`/`docs`/`ci` do not). Config: [.semrc.json](.semrc.json).
132+
133+
A green `main` cuts a **prerelease** carrying both npm tarballs. Promoting it to a full release fires
134+
`npm-release.yml` and `crates-release.yml`, so nothing is published by the same run that decided to
135+
publish it.
131136

132137
## Test / coverage
133138

0 commit comments

Comments
 (0)