Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions .github/workflows/design.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Checks and releases for @myfirstbitcoin/design. CONTRIBUTING.md explains the flow.
#
# rebuild-check every event the committed outputs equal a fresh build (scripts/check.mjs)
# version-check pull requests a changed version is above the newest v* tag and not yet tagged
# release push to master if this push raised package.json's version to a new one above the
# newest v* tag, tag this commit and publish a release
#
# There is deliberately no workflow-level `paths:` filter. A workflow skipped that way leaves its
# required checks waiting as "Expected", which would block every pull request that touches no
# brand file.
#
# Only GitHub-owned actions are used, each pinned to a full commit SHA (the release tag it came
# from is in the comment). The token is read-only except in the release job.

name: design

on:
pull_request:
branches: [master]
push:
branches: [master]
workflow_dispatch:

permissions:
contents: read

jobs:
rebuild-check:
name: rebuild-check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
package-manager-cache: false
- name: Rebuild and compare (stale outputs, geometry gate, missing tokens, inert package.json)
run: node scripts/check.mjs

version-check:
name: version-check
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# A pull request is checked out as its merge commit. Depth 2 brings the merge commit's
# first parent, which is exactly the master it was merged onto. Tags are not fetched by
# the checkout, so they are read from the remote below.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 2
- name: Check package.json's version against the tags
run: |
set -euo pipefail
base=$(git rev-parse HEAD^1)
head_version=$(node -p 'require("./package.json").version')
base_version=$(git show "$base:package.json" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version')
tags=$(git ls-remote --tags --refs origin 'refs/tags/v*' | sed 's#.*refs/tags/##')
newest=$(printf '%s\n' "$tags" | sed -n 's/^v\([0-9]*\.[0-9]*\.[0-9]*\)$/\1/p' | sort -V | tail -n 1)
echo "package.json version: master $base_version, this pull request $head_version. Newest tag: v${newest:-none}."

if [ "$head_version" != "$base_version" ]; then
if ! printf '%s' "$head_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error file=package.json::The version \"$head_version\" is not of the form X.Y.Z."
exit 1
fi
if printf '%s\n' "$tags" | grep -qxF "v$head_version"; then
echo "::error file=package.json::The tag v$head_version already exists. Tags never move, so choose a new version."
exit 1
fi
if [ -n "$newest" ] && [ "$(printf '%s\n%s\n' "$newest" "$head_version" | sort -V | tail -n 1)" != "$head_version" ]; then
echo "::error file=package.json::The version $head_version is not above the newest tag, v$newest."
exit 1
fi
echo "Version bump to $head_version: merging this pull request releases v$head_version."
else
changed=$(git diff --name-only "$base" HEAD -- README.md brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ')
if [ -n "$changed" ]; then
echo "::warning::Published files change without a version bump, so merging releases nothing: $changed"
else
echo "No version change, and no published file changes."
fi
fi

release:
name: release
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: rebuild-check
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
# The full history makes the push's previous master head (github.event.before) available,
# so the job can tell whether this push is the one that raised the version. The repository
# is small. Tags are still read from the remote, which is the authority.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Tag and release package.json's version if this push raised it
env:
GH_TOKEN: ${{ github.token }}
BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
version=$(node -p 'require("./package.json").version')
if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error file=package.json::The version \"$version\" is not of the form X.Y.Z."
exit 1
fi
before_version=$(git show "$BEFORE:package.json" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version')
echo "package.json version: before this push $before_version, now $version."

# --exit-code gives 2 when the tag is absent. Any other failure stops the job, so an
# unreadable remote never leads to a release. The last line is the tagged commit (the
# peeled line, for an annotated tag).
rc=0
tag_lines=$(git ls-remote --exit-code --tags origin "refs/tags/v$version" "refs/tags/v$version^{}") || rc=$?
if [ "$rc" -ne 0 ] && [ "$rc" -ne 2 ]; then
echo "::error::Could not read the tags from origin (git ls-remote exit $rc), so nothing was released."
exit 1
fi

if [ "$rc" -eq 0 ]; then
tag_sha=$(printf '%s\n' "$tag_lines" | tail -n 1 | cut -f1)
if [ "$tag_sha" = "$GITHUB_SHA" ] || [ "$before_version" = "$version" ]; then
echo "v$version is already tagged: nothing to release."
changed=$(git diff --name-only "$BEFORE" "$GITHUB_SHA" -- README.md brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ')
if [ "$tag_sha" != "$GITHUB_SHA" ] && [ -n "$changed" ]; then
echo "::warning::Published files changed without a version bump, so they are in no release until the next one: $changed"
fi
exit 0
fi
echo "::error file=package.json::This push changes the version to $version, but v$version already exists. Tags never move, so nothing was released."
exit 1
fi

if [ "$before_version" = "$version" ]; then
echo "::warning::v$version is not tagged, but this push did not raise the version, so this commit is not tagged. Re-run the release job of the push that raised it."
exit 0
fi

# This push raised the version. Tags cannot be undone, so the rule that version-check
# applies to pull requests is applied again here: a direct push to master has no other gate.
newest=$(git ls-remote --tags --refs origin 'refs/tags/v*' | sed -n 's#.*refs/tags/v\([0-9]*\.[0-9]*\.[0-9]*\)$#\1#p' | sort -V | tail -n 1)
if [ -n "$newest" ] && [ "$(printf '%s\n%s\n' "$newest" "$version" | sort -V | tail -n 1)" != "$version" ]; then
echo "::error file=package.json::The version $version is not above the newest tag, v$newest, so nothing was released."
exit 1
fi

# GitHub creates the tag server side, as a lightweight tag on this commit, like v1.0.0 to v1.3.0.
gh release create "v$version" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --title "v$version" --generate-notes
echo "Released v$version at $GITHUB_SHA."
113 changes: 113 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Contributing to @myfirstbitcoin/design

This repository is the single source of the My First Bitcoin brand as code: the design tokens,
the build that turns them into the published package, the written brand specification, and the
check against the Brand Book in Figma. The Brand Book in Figma is where the brand is decided;
`tokens.json` mirrors it and is edited only to match it.

Propose every change by pull request. The conventions in [AGENTS.md](AGENTS.md) apply to all
contributions, by people and AI assistants alike.

## What is in the repository

| Path | What it is | Edit it by hand? |
|------|------------|------------------|
| `tokens.json` | The design tokens (published as is) | Yes, only to match the Brand Book |
| `package.json` | Name, version, exports and the `files` list (published) | Yes: the version is how a release happens |
| `src/supergraphics.canon.css` | The supergraphics canon, the origin of the published supergraphics (see below) | Yes, by pull request, with no added comment |
| `src/brand-spec.template.md` | The prose of the brand specification | Yes |
| `scripts/` | Build, spec generator and checks | Yes |
| `README.md`, `brand.css`, `theme.css`, `tailwind.js`, `index.js`, `supergraphics.css` | Generated package files (published) | No: rebuild them |
| `brand-spec.md` | Generated brand specification (public, not part of the package) | No: regenerate it |

What consumers install is exactly the list in `package.json`'s `files`, plus `package.json`
itself: `README.md`, `brand.css`, `index.js`, `supergraphics.css`, `tailwind.js`, `theme.css`
and `tokens.json`. Nothing else in this repository reaches them.

`package.json` must never get `scripts` (including `prepare` or `postinstall`) or any kind of
dependencies. Consumers install this package as a git dependency, and npm runs a git
dependency's `prepare` script, after installing its dependencies, on every consumer install.
`scripts/check.mjs` fails if one appears.

## Making a change

The scripts need Node.js 22 and nothing else (there is no `npm install`). From the repository root:

```
node scripts/build-design-package.mjs # rewrites the generated package files
node scripts/brand-spec.mjs # rewrites brand-spec.md
node scripts/check.mjs # the rebuild check that GitHub Actions runs
```

Commit the source change and the regenerated files together. The build refuses to write
anything if the canon's geometry disagrees with `tokens.json`, and the spec generator refuses if
a token it needs is missing.

## Checks on every pull request

- **rebuild-check** (`scripts/check.mjs`): rebuilds into a temporary directory and fails if any
committed output differs from a fresh build, if the build or the spec generator refuses, or if
`package.json` gains scripts or dependencies. It also runs on every push to `master`.
- **version-check**: if the pull request changes the version in `package.json`, the new version
must be above the newest `v*` tag and must not already be tagged. If published files change
without a version bump, it warns: merging would release nothing.

## Releases

A release happens only when a merged pull request raises the version in `package.json`. The
author proposes the version, and Quentin accepts it when he decides on the pull request. On the
push to `master`, the `release` job creates the tag `vX.Y.Z` on that commit and a GitHub Release
with generated notes. It acts only on the push that raised the version, and checks again that
the version is above the newest `v*` tag. If the version is already tagged, it does nothing, so
merges without a version bump release nothing.

Tags never move and are never deleted. A wrong release is followed by a new one. Consumers pin a
tag, for example `"@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v1.3.0"`, and see a
release only when they raise their pin.

## The brand-change loop

1. Patrick changes the Brand Book in Figma and tells Quentin, or the Monday check flags a
difference between Figma and `tokens.json`.
2. The admin session opens a pull request with the change to `tokens.json` and the rebuild.
3. Quentin decides.
4. Merging a pull request that raises the version releases it.
5. The Monday digest line shows which projects are behind the newest tag.

## The Figma check

`scripts/verify-figma.mjs` reads the Brand Book page in Figma and checks that every color token
is present there as a rendered fill and every font size and weight token as a rendered text
style. It needs a read-only Figma token in `FIGMA_TOKEN`:

```
FIGMA_TOKEN=... node scripts/verify-figma.mjs [--json] [--summary] [--status FILE]
```

It is not run in GitHub Actions, and no Figma token is stored in this repository. The Monday
check runs it outside GitHub. `--status FILE` writes the full result as JSON for that job, and
`--summary` writes a short Markdown summary (to `$GITHUB_STEP_SUMMARY` when that is set).

## The supergraphics canon

`src/supergraphics.canon.css` is the origin of the supergraphics primitives. It began as a
byte-for-byte copy of `supergraphics.css` in `@mfb/shared`, My First Bitcoin's internal shared
package, which keeps an identical copy. The build appends it unchanged to the published
`supergraphics.css`, after a prelude generated from `tokens.json`, and refuses to build when the
canon's geometry (`--sg-angle-base` and the other `--sg-*` values) disagrees with the tokens.

Keep it free of added comments: any byte added here changes the published file. Change the canon
here, by pull request; the internal copy is then brought into line, and a weekly check compares
the two.

The canon, `tokens.json` and the README template keep the punctuation they were published with,
em-dashes included, because rewording them changes published files. Such rewording belongs in a
release pull request.

## Where the history lives

This repository's history starts with the first generated package, `v1.0.0`. The build, the
spec generator and the Figma check moved into it later, without their history. That earlier
history, and the history of `tokens.json` before it was published here, stays in My First
Bitcoin's private `brand-tokens` repository, which is kept and never deleted. It was
deliberately not imported into this public repository.
Loading
Loading