Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/sync-figma-variables.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
ref: ${{ github.ref }}
persist-credentials: false

- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
Expand Down Expand Up @@ -109,8 +109,8 @@ jobs:
echo "Release label: \`${RELEASE_LABEL}\`"
echo
echo "**Release label rules:**"
echo "- \`major\` — an existing token path was removed, renamed, or had its value changed (consumer code may break)."
echo "- \`minor\` — only new token paths were added."
echo "- \`major\` — an existing Figma variable key was removed, renamed, or had its emitted token value changed (consumer code may break)."
echo "- \`minor\` — only new Figma variable keys were added."
if [ "$RELEASE_LABEL_OUTCOME" != "success" ]; then
echo
echo "> [!WARNING]"
Expand Down
18 changes: 9 additions & 9 deletions token-sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ The token is never committed. There are two places it needs to live:
- **CI** — add two repository secrets at **Settings → Secrets and variables → Actions**:

| Secret name | Value |
|------------------------------|--------------------------------------------|
| ---------------------------- | ------------------------------------------ |
| `FIGMA_VARIABLES_SYNC_TOKEN` | Same token as above |
| `FIGMA_FILE_KEY` | Backpack Foundations & Components file key |

Expand Down Expand Up @@ -101,14 +101,14 @@ When generated output changes under `token-sync/tokens/` or `token-sync/css/`, t
any existing open `figma-token-sync/*` pull request, then creates a fresh
`figma-token-sync/<timestamp>-<run-id>` branch and opens one pull request against `main`. Because the
fresh branch is generated from the latest Figma state against `main`, it includes any still-unmerged
token changes from previous sync runs. The pull request is labelled `major` when an existing token
path is removed, renamed, or has its value changed (because consumer code or downstream visuals may
break), and `minor` only when the diff is purely additive (new token paths). Removed or renamed
token paths, plus changed token values, are listed in the pull request body so reviewers can verify
usage migrations and visual impact. Pure additions are not listed because a delete-and-add diff can
represent a change that needs reviewer judgement. If release label classification fails, the pull
request defaults to `major` for review. Figma API or Style Dictionary failures fail the workflow at
the failing step.
token changes from previous sync runs. Generated DTCG tokens include Figma variable metadata under
`$extensions.figma`, allowing the workflow to compare stable Figma variable keys across runs. The
pull request is labelled `major` when an existing Figma variable key is removed, renamed, or has its
emitted token value changed (because consumer code or downstream visuals may break), and `minor`
only when the diff is purely additive (new Figma variable keys). Deleted, renamed, changed, and added
token paths are listed in the pull request body so reviewers can verify usage migrations and visual
impact. If release label classification fails, the pull request defaults to `major` for review.
Figma API or Style Dictionary failures fail the workflow at the failing step.

For human takeover (when the automated PR needs to be replaced with a hand-curated one), see the
"Manual intervention" section in [`RUNBOOK.md`](RUNBOOK.md).
Expand Down
19 changes: 12 additions & 7 deletions token-sync/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ to a repository output change.
- It targets `main`.
- It is opened from a `figma-token-sync/<timestamp>-<run-id>` branch.
- It has the `design-token-automation` label.
- It has a `major` label when an existing token path was removed, renamed, or had its value
changed.
- It has a `minor` label only when the diff is purely additive (new token paths added).
- Any removed, renamed, or changed token paths are listed in the pull request body, grouped by
token file. Pure additions are not listed.
- It has a `major` label when an existing Figma variable key was removed, renamed, or had its
emitted token value changed.
- It has a `minor` label only when the diff is purely additive (new Figma variable keys added).
- Any removed, renamed, changed, or added token paths are listed in the pull request body,
grouped by token file.
6. After validation, revert the controlled Figma change if it was only for testing, then rerun the
workflow or wait for the next scheduled run to confirm the repository output returns to the
expected state.
Expand All @@ -59,8 +59,9 @@ different branch prefix to keep the workflow from auto-closing your PR.
output, and open the pull request.
4. Apply labels manually following the same rules the workflow uses:
- `design-token-automation`
- `major` if an existing token path was removed, renamed, or had its value changed.
- `minor` only if the diff is purely additive (new token paths added).
- `major` if an existing Figma variable key was removed, renamed, or had its emitted token value
changed.
- `minor` only if the diff is purely additive (new Figma variable keys added).

While your manual pull request is open the next scheduled run will still detect the same Figma
changes and may open another `figma-token-sync/*` pull request. Either merge or close your manual
Expand All @@ -79,6 +80,10 @@ pull requests while your manual change is in flight.
- **No-op runs** - If the workflow exits after `Detect meaningful fetched token changes`, no
repository output PR is expected. This means either Figma has no token value changes compared with
`main`, or the only fetched change was `manifest.json`'s `generatedAt` metadata.
- **Caveat**: when the run is a no-op, the "Close superseded Figma token sync pull requests"
step is also skipped. If a stale `figma-token-sync/*` pull request is still open while `main`
and Figma are now in sync, it will not be auto-closed — close it manually, or wait until the
next real Figma change triggers a run that closes it.
- **PR automation failures** - If token generation succeeds but no PR appears, check the
`create-github-app-token`, `Commit generated token changes`, and `Open pull request` steps. Verify
`GH_APP_ID` and `GH_APP_PRIVATE_KEY` are configured and that the app can push branches and open
Expand Down
77 changes: 57 additions & 20 deletions token-sync/src/build-dtcg-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,11 @@ import path from 'node:path';
import {
BACKPACK_MODE_DARK,
BACKPACK_MODE_LIGHT,
KEY_SEM_CANVAS_CONTRAST,
KEY_SEM_CANVAS_DEFAULT,
buildFixtureResponse,
} from './__fixtures__/figma-variable';
import {
buildDTCG,
buildDTCGOutputs,
formatBuildSummary,
} from './build-dtcg';
import { buildDTCG, buildDTCGOutputs, formatBuildSummary } from './build-dtcg';
import { FigmaApi } from './figma-api';
import { TARGET_COLLECTION_NAMES } from './sync-helpers';

Expand All @@ -50,7 +48,10 @@ describe('buildDTCGOutputs (end-to-end on fixtures)', () => {
);

expect(classified).toEqual([
{ collection: expect.objectContaining({ name: 'Backpack' }), role: 'semantic' },
{
collection: expect.objectContaining({ name: 'Backpack' }),
role: 'semantic',
},
{
collection: expect.objectContaining({ name: 'Primitives' }),
role: 'primitive',
Expand All @@ -72,8 +73,18 @@ describe('buildDTCGOutputs (end-to-end on fixtures)', () => {
expect(backpackLight.tree).toEqual({
Canvas: {
$type: 'color',
Contrast: { $value: '{Colour.Pink}' },
Default: { $value: '{Colour.Pink}' },
Contrast: {
$value: '{Colour.Pink}',
$extensions: {
figma: expect.objectContaining({ key: KEY_SEM_CANVAS_CONTRAST }),
},
},
Default: {
$value: '{Colour.Pink}',
$extensions: {
figma: expect.objectContaining({ key: KEY_SEM_CANVAS_DEFAULT }),
},
},
},
});
expect(backpackLight.stats).toMatchObject({
Expand Down Expand Up @@ -113,9 +124,17 @@ describe('buildDTCGOutputs (end-to-end on fixtures)', () => {
modeNameMap: { Light: 'LightSky', Dark: 'DarkSky' },
});

expect(outputs.filter((o) => o.collectionName === 'Backpack')).toMatchObject([
{ modeName: 'DarkSky', tree: { Canvas: { Default: { $value: '{Colour.Berry}' } } } },
{ modeName: 'LightSky', tree: { Canvas: { Default: { $value: '{Colour.Pink}' } } } },
expect(
outputs.filter((o) => o.collectionName === 'Backpack'),
).toMatchObject([
{
modeName: 'DarkSky',
tree: { Canvas: { Default: { $value: '{Colour.Berry}' } } },
},
{
modeName: 'LightSky',
tree: { Canvas: { Default: { $value: '{Colour.Pink}' } } },
},
]);
});
});
Expand Down Expand Up @@ -199,15 +218,21 @@ describe('formatBuildSummary', () => {
}

// Minimal fake — we only exercise the formatter, not the writer.
function makeResult(overrides: Partial<BuildDTCGResult> = {}): BuildDTCGResult {
function makeResult(
overrides: Partial<BuildDTCGResult> = {},
): BuildDTCGResult {
return {
classified: [
{
collection: { name: 'Backpack' } as BuildDTCGResult['classified'][number]['collection'],
collection: {
name: 'Backpack',
} as BuildDTCGResult['classified'][number]['collection'],
role: 'semantic',
},
{
collection: { name: 'Primitives' } as BuildDTCGResult['classified'][number]['collection'],
collection: {
name: 'Primitives',
} as BuildDTCGResult['classified'][number]['collection'],
role: 'primitive',
},
],
Expand Down Expand Up @@ -244,7 +269,9 @@ describe('formatBuildSummary', () => {

it('includes a warning when target collections are missing', () => {
const lines = formatBuildSummary(makeResult({ missingNames: ['VDL'] }));
expect(lines.some((l) => l.includes('Warning') && l.includes('VDL'))).toBe(true);
expect(lines.some((l) => l.includes('Warning') && l.includes('VDL'))).toBe(
true,
);
});

it('groups unresolved aliases by missing alias id and merges Light/Dark duplicates', () => {
Expand Down Expand Up @@ -326,9 +353,15 @@ describe('formatBuildSummary', () => {
makeResult({ outputs: [makeOutput(BACKPACK_MODE_LIGHT, [varA, varB])] }),
);

expect(lines).toContain(' - missing VariableID:9999:0000 (2 variable(s)):');
expect(lines).toContain(' • [Backpack] Component/Button/bg-default (modes: Light)');
expect(lines).toContain(' • [Backpack] Component/Card/bg-default (modes: Light)');
expect(lines).toContain(
' - missing VariableID:9999:0000 (2 variable(s)):',
);
expect(lines).toContain(
' • [Backpack] Component/Button/bg-default (modes: Light)',
);
expect(lines).toContain(
' • [Backpack] Component/Card/bg-default (modes: Light)',
);
});

it('expands into bullet list when multiple FLOAT variables share the same scope key', () => {
Expand All @@ -353,8 +386,12 @@ describe('formatBuildSummary', () => {
const lines = formatBuildSummary(makeResult({ outputs: [lightOutput] }));

expect(lines).toContain(' - scope=[ALL_SCOPES] (2 variable(s)):');
expect(lines).toContain(' • [Backpack] Typography/Weight/Bold (modes: Light)');
expect(lines).toContain(' • [Backpack] Typography/Weight/Regular (modes: Light)');
expect(lines).toContain(
' • [Backpack] Typography/Weight/Bold (modes: Light)',
);
expect(lines).toContain(
' • [Backpack] Typography/Weight/Regular (modes: Light)',
);
});

it('warns about FLOAT variables with unconstrained scopes and merges Light/Dark duplicates', () => {
Expand Down
8 changes: 6 additions & 2 deletions token-sync/src/classify-release-label-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import process from 'node:process';
import { fileURLToPath } from 'node:url';

import {
formatAddedTokensMarkdown,
formatChangedTokenValuesMarkdown,
formatDeletedOrRenamedTokensMarkdown,
formatDeletedTokensMarkdown,
formatRenamedTokensMarkdown,
summariseTokenReleaseChangesFromGit,
} from './classify-release-label';
import { formatFatalError } from './sync-helpers';
Expand Down Expand Up @@ -58,8 +60,10 @@ function main(): void {
writeLabelOutput(summary.label);

const sections = [
formatDeletedOrRenamedTokensMarkdown(summary.deletedOrRenamedTokens),
formatRenamedTokensMarkdown(summary.renamedTokens),
formatChangedTokenValuesMarkdown(summary.changedTokens),
formatDeletedTokensMarkdown(summary.deletedTokens),
formatAddedTokensMarkdown(summary.addedTokens),
].filter(Boolean);

writeTokenReleaseSummary(sections.join('\n\n'));
Expand Down
Loading
Loading