feat: Play the whole catalog: site examples, and a CLI to render them… #63
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Run tests and on main push create prerelease with npm package. | |
| # When you "Set as latest release" (uncheck prerelease), the npm-release workflow publishes to npm. | |
| # | |
| # Version bumps use tishlang/sem (@tishlang/sem) — Conventional Commits drive semver | |
| # (feat/fix/perf/BREAKING release; chore/docs/ci do not). Config: .semrc.json | |
| name: CI (test, prerelease) | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| jobs: | |
| test: | |
| name: Test | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22" | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Run tests with coverage | |
| run: npm run test:coverage | |
| - name: Conformance corpus (JS) | |
| run: npm run test:conformance | |
| # The third target. The JS build and the Rust crate were both checked against the corpus; the | |
| # Tish source itself was not, so "one source, three targets" had a leg missing. | |
| - name: Conformance corpus (Tish) | |
| run: npm run test:conformance:tish | |
| - name: Examples | |
| run: npm run examples | |
| - name: Build | |
| run: npm run build | |
| # The player is the host half: Web Audio, transport, host defaults/clamps. It builds against | |
| # the language package IN THIS REPO (a file: link), so a grammar change that breaks playback | |
| # fails here rather than after publish. | |
| - name: Player (@spacedevin/deck-player) | |
| run: npm test -w @spacedevin/deck-player | |
| # The Rust crate is emitted from the SAME src/index.tish as dist/deck.js, which is what stops the | |
| # JS host and tish-gba's build-time bake from drifting. Nothing verified that: a change that broke | |
| # the rust-lib emit, or that made the two targets parse differently, would have gone unnoticed | |
| # until release — or until a consumer hit it. `cargo test` here runs the same conformance corpus | |
| # the JS build is checked against, so both targets are held to one contract on every PR. | |
| rust_crate: | |
| name: Rust crate (deckfile) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22" | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Install Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: crate | |
| - name: Emit the crate | |
| run: | | |
| export PATH="$PWD/node_modules/.bin:$PATH" | |
| npm run build:rust | |
| - name: Conformance corpus (from Rust) | |
| working-directory: crate | |
| run: cargo test | |
| # A crate that cannot be packaged cannot be released; catch that on the PR, not at publish. | |
| # | |
| # The dry run leaves an extracted copy of the crate at `target/package/deckfile-<version>/`. | |
| # rust-cache's post step prunes `target/` before saving it, walks into that copy, and dies on | |
| # the trybuild layout it expects but this crate does not have: | |
| # | |
| # ENOENT: opendir '…/crate/target/package/deckfile-0.1.0/tests/trybuild' | |
| # | |
| # Four `##[error]` annotations on an otherwise green run. It only shows up on a cache MISS, | |
| # since rust-cache skips the prune entirely on a hit — which is why it appears sporadically, | |
| # whenever a change to the emitted crate moves the cache key. Nothing should be caching the | |
| # output of a dry-run publish, so drop it before the post step can find it. | |
| - name: Packaging check | |
| working-directory: crate | |
| run: | | |
| cargo publish --dry-run --allow-dirty | |
| rm -rf target/package | |
| # ── Would this land a release? Runs on PRs so a bad commit type is caught before merge. ─────── | |
| release_check: | |
| name: Release check (sem dry-run) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: { fetch-depth: 0 } | |
| - uses: actions/setup-node@v4 | |
| with: { node-version: "22" } | |
| - name: Dry-run sem | |
| id: sem | |
| uses: tishlang/sem@v1 | |
| with: | |
| dry_run: true | |
| config: | | |
| branches: [main] | |
| plugins: | |
| - "@sem/commit-analyzer" | |
| - "@sem/release-notes-generator" | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Report bump | |
| run: | | |
| if [ "${{ steps.sem.outputs.new_release_published }}" = "true" ]; then | |
| echo "::notice::Would release ${{ steps.sem.outputs.new_release_version }} from these commits." | |
| elif [ "${{ steps.sem.outputs.skipped }}" = "true" ]; then | |
| echo "::notice::Soft-skipped (${{ steps.sem.outputs.skip_reason }})." | |
| else | |
| echo "::notice::No version bump. Conventional Commits drive semver — feat/fix/perf/BREAKING release, chore/docs/ci do not." | |
| fi | |
| release: | |
| name: Prerelease | |
| needs: [test, rust_crate, release_check] | |
| if: github.ref == 'refs/heads/main' && github.event_name == 'push' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| # @sem/github opens a failure issue when a release throws. Without this it cannot, and the | |
| # real error is buried under a 403 from the issues API. | |
| issues: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: { fetch-depth: 0 } | |
| - uses: actions/setup-node@v4 | |
| with: { node-version: "22" } | |
| - name: Install dependencies | |
| run: npm ci | |
| # A tag is a commit-shaped object and actions/checkout configures no identity, so sem dies with | |
| # "Failed to create tag: Committer identity unknown" without this. chuggie-engine shipped zero | |
| # releases for months on exactly that. | |
| - name: Configure git identity for the release tag | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| - name: Next version via sem (dry-run) | |
| id: sem | |
| uses: tishlang/sem@v1 | |
| with: | |
| dry_run: true | |
| config: | | |
| branches: [main] | |
| plugins: | |
| - "@sem/commit-analyzer" | |
| - "@sem/release-notes-generator" | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Skip when no bump | |
| id: gate | |
| run: | | |
| if [ "${{ steps.sem.outputs.new_release_published }}" != "true" ]; then | |
| echo "::notice::No version bump — skipping prerelease." | |
| echo "continue=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "continue=true" >> "$GITHUB_OUTPUT" | |
| echo "version=${{ steps.sem.outputs.new_release_version }}" >> "$GITHUB_OUTPUT" | |
| echo "Next version: ${{ steps.sem.outputs.new_release_version }}" | |
| # crates.io has no delete, only yank, and npm unpublish is a 72-hour window — so an artifact | |
| # published under the wrong licence is effectively permanent. This repo has already shipped two | |
| # versions under the wrong one and had to yank both. Refuse to build an artifact unless the | |
| # LICENSE the packages and the crate point at is actually present. | |
| - name: Refuse to package without a licence | |
| if: steps.gate.outputs.continue == 'true' | |
| run: | | |
| for f in LICENSE packages/player/LICENSE packages/synths/LICENSE; do | |
| if [ ! -f "$f" ]; then | |
| echo "::error::Missing $f, but package.json declares a licence. Add the file, or correct the declaration, before anything is published." | |
| exit 1 | |
| fi | |
| done | |
| - name: Stamp the version and pack @spacedevin/deck | |
| if: steps.gate.outputs.continue == 'true' | |
| run: | | |
| node -e ' | |
| const fs = require("fs"), p = "./package.json"; | |
| const j = JSON.parse(fs.readFileSync(p, "utf8")); | |
| j.version = process.argv[1]; | |
| fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n"); | |
| ' "${{ steps.gate.outputs.version }}" | |
| npm pack | |
| mv spacedevin-deck-*.tgz spacedevin-deck-npm-package.tgz | |
| # The synths catalog releases in lockstep too. Same `file:../..` → real version rewrite as the | |
| # player, and for the same reason: the workspace links locally so CI builds the source in this | |
| # repo, but a published tarball needs a version a consumer can actually resolve. | |
| - name: Stamp the version and pack @spacedevin/deck-synths | |
| if: steps.gate.outputs.continue == 'true' | |
| run: | | |
| node -e ' | |
| const fs = require("fs"), p = "./packages/synths/package.json"; | |
| const j = JSON.parse(fs.readFileSync(p, "utf8")); | |
| j.version = process.argv[1]; | |
| j.peerDependencies["@spacedevin/deck"] = "^" + process.argv[1]; | |
| delete j.devDependencies["@spacedevin/deck"]; | |
| fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n"); | |
| ' "${{ steps.gate.outputs.version }}" | |
| npm pack -w @spacedevin/deck-synths | |
| mv spacedevin-deck-synths-*.tgz spacedevin-deck-synths-npm-package.tgz | |
| # The player releases in lockstep: same tag, same version. Its dependency on the language | |
| # package is `file:../..` so the workspace links locally and CI tests the source in this repo — | |
| # that has to become the real published version before packing, or the tarball is uninstallable. | |
| # | |
| # Caret, not an exact pin. `tish build` inlines the parser into dist/deck-player.js, so the | |
| # dependency only matters to a consumer compiling from src/index.tish via the `tish` export | |
| # condition — and for them a compatible minor is fine. An exact pin would just force npm to | |
| # install a second copy alongside a consumer's own @spacedevin/deck. | |
| - name: Stamp the version and pack @spacedevin/deck-player | |
| if: steps.gate.outputs.continue == 'true' | |
| run: | | |
| node -e ' | |
| const fs = require("fs"), p = "./packages/player/package.json"; | |
| const j = JSON.parse(fs.readFileSync(p, "utf8")); | |
| j.version = process.argv[1]; | |
| j.peerDependencies["@spacedevin/deck"] = "^" + process.argv[1]; | |
| delete j.devDependencies["@spacedevin/deck"]; | |
| fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n"); | |
| ' "${{ steps.gate.outputs.version }}" | |
| npm pack -w @spacedevin/deck-player | |
| mv spacedevin-deck-player-*.tgz spacedevin-deck-player-npm-package.tgz | |
| # Promoting this prerelease to a full release is what fires npm-release.yml and | |
| # crates-release.yml. The two-step exists so a release is never published by the same run that | |
| # decided to make one — there is a human between "CI is green" and "this is on npm forever". | |
| - name: Create the prerelease via @sem/github | |
| if: steps.gate.outputs.continue == 'true' | |
| uses: tishlang/sem@v1 | |
| with: | |
| force: ${{ steps.gate.outputs.version }} | |
| config: | | |
| branches: [main] | |
| plugins: | |
| - "@sem/commit-analyzer" | |
| - "@sem/release-notes-generator" | |
| - - "@sem/npm" | |
| - npmPublish: false | |
| - - "@sem/github" | |
| - prerelease: true | |
| successComment: false | |
| assets: | |
| - path: spacedevin-deck-npm-package.tgz | |
| name: spacedevin-deck-npm-package.tgz | |
| label: npm package (@spacedevin/deck) | |
| - path: spacedevin-deck-player-npm-package.tgz | |
| name: spacedevin-deck-player-npm-package.tgz | |
| label: npm package (@spacedevin/deck-player) | |
| - path: spacedevin-deck-synths-npm-package.tgz | |
| name: spacedevin-deck-synths-npm-package.tgz | |
| label: npm package (@spacedevin/deck-synths) | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |