From 6c35b58cf3c19df4e5c3005156da54a2ec115ab0 Mon Sep 17 00:00:00 2001 From: Beyond <46542494+crypticpy@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:01:05 -0500 Subject: [PATCH 1/2] feat: release pipeline, palette PNGs, screenshot script, publishing guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .github/workflows/release.yml: v* tag → version check → check+test → vsce package → GitHub Release → vsce publish (VSCE_PAT) → ovsx publish (OVSX_PAT); publish steps skip without secrets; workflow_dispatch dry run. - CI actions bumped to checkout/setup-node/upload-artifact v7 (supersedes Dependabot #11–13); palette-PNG freshness checked in CI and npm run check. - scripts/render-palette.mjs: dependency-free PNG palette strips per variant (images/palette-*.png) shown in README; tests/render-palette.test.mjs. - scripts/capture-screenshots.sh: reproducible macOS editor screenshots. - docs/PUBLISHING.md: publisher/PAT/Open VSX/secrets/first publish/badges. - README images excluded from the VSIX (served from the repo by vsce). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 88 ++++++++++++++++ .vscodeignore | 3 + CHANGELOG.md | 7 ++ CONTRIBUTING.md | 10 +- README.md | 9 +- docs/PUBLISHING.md | 124 ++++++++++++++++++++++ images/palette-alone-focused.png | Bin 0 -> 10369 bytes images/palette-alone-roman.png | Bin 0 -> 10201 bytes images/palette-alone-soft.png | Bin 0 -> 10487 bytes images/palette-alone.png | Bin 0 -> 10280 bytes package.json | 5 +- scripts/capture-screenshots.sh | 89 ++++++++++++++++ scripts/render-palette.mjs | 170 +++++++++++++++++++++++++++++++ tests/render-palette.test.mjs | 41 ++++++++ 15 files changed, 547 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/PUBLISHING.md create mode 100644 images/palette-alone-focused.png create mode 100644 images/palette-alone-roman.png create mode 100644 images/palette-alone-soft.png create mode 100644 images/palette-alone.png create mode 100755 scripts/capture-screenshots.sh create mode 100644 scripts/render-palette.mjs create mode 100644 tests/render-palette.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 096ce07..4e2289b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,9 @@ jobs: matrix: node: [22, 24] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node }} cache: npm @@ -38,12 +38,15 @@ jobs: - name: Unit + pipeline tests run: npm test + - name: Palette PNGs must be current + run: node scripts/render-palette.mjs --check + - name: Package VSIX run: npx vsce package --out alone.vsix - name: Upload VSIX if: matrix.node == 24 - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: alone-vsix path: alone.vsix diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..befda2d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +# Tag-driven release: `git tag v2.0.0 && git push origin v2.0.0` +# 1. build → verify → test (same gate as CI) +# 2. package the VSIX and attach it to a GitHub Release +# 3. publish to the VS Code Marketplace (VSCE_PAT) and Open VSX (OVSX_PAT) +# Publish steps are skipped when the corresponding secret is absent, so a +# workflow_dispatch run on a fork or before the accounts exist still produces +# a Release + VSIX. See docs/PUBLISHING.md for the account/PAT setup. + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + publish: + description: 'Publish to Marketplace / Open VSX (needs secrets)' + type: boolean + default: false + +permissions: + contents: write + +jobs: + release: + name: package · release · publish + runs-on: ubuntu-latest + env: + # `secrets` isn't available in step-level `if`, so surface presence here. + HAS_VSCE_PAT: ${{ secrets.VSCE_PAT != '' }} + HAS_OVSX_PAT: ${{ secrets.OVSX_PAT != '' }} + DO_PUBLISH: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish == true }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + + - run: npm ci + + - name: Tag must match package.json version + if: startsWith(github.ref, 'refs/tags/v') + run: | + tag="${GITHUB_REF_NAME#v}" + pkg="$(node -p "require('./package.json').version")" + if [ "$tag" != "$pkg" ]; then + echo "::error::tag v$tag does not match package.json version $pkg"; exit 1 + fi + + - name: Build · verify · palette PNGs · test + run: npm run check && npm test + + - name: Package VSIX + id: pkg + run: | + npx vsce package --out "alone-$(node -p "require('./package.json').version").vsix" + echo "vsix=$(ls alone-*.vsix)" >> "$GITHUB_OUTPUT" + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.pkg.outputs.vsix }} + path: ${{ steps.pkg.outputs.vsix }} + if-no-files-found: error + + - name: GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" "${{ steps.pkg.outputs.vsix }}" \ + --title "Alone $GITHUB_REF_NAME" \ + --notes "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/main/CHANGELOG.md) for details." \ + --verify-tag + + - name: Publish to VS Code Marketplace + if: env.HAS_VSCE_PAT == 'true' && env.DO_PUBLISH == 'true' + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: npx vsce publish --packagePath "${{ steps.pkg.outputs.vsix }}" + + - name: Publish to Open VSX + if: env.HAS_OVSX_PAT == 'true' && env.DO_PUBLISH == 'true' + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: npx --yes ovsx@1 publish "${{ steps.pkg.outputs.vsix }}" --pat "$OVSX_PAT" diff --git a/.vscodeignore b/.vscodeignore index eba21d3..9d50b4f 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -12,6 +12,9 @@ themes/_src/** themes/_snapshot/** samples/** images/icon.svg +# README images are served from the GitHub repo (vsce rewrites relative URLs); only the icon ships +images/palette-*.png +images/screenshot-*.png *.vsix package-lock.json *.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 12697f8..ec50059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,13 @@ New `tokenColors` rules and `semanticTokenColors` selectors in `themes/_src/base - **FAQ** rewritten ("Why no blue or cyan?", new "Does it protect my night vision?" and "Is it colour-blind safe?"). - **`CONTRIBUTING.md`** added; the "Building the Themes (Contributors)" section moves there and grows into layout, verifier contract, palette-change and new-variant procedures. `package.json` description no longer claims dark-adaptation protection. +### Added — Release pipeline, palette images, publishing guide + +- **`.github/workflows/release.yml`**: on a `v*` tag — tag/version match check → `npm run check && npm test` → `vsce package` → GitHub Release with the VSIX → `vsce publish` (`VSCE_PAT`) → `ovsx publish` (`OVSX_PAT`); publish steps skip when a secret is absent, and `workflow_dispatch` gives a dry run. CI actions bumped to `checkout@v7` / `setup-node@v7` / `upload-artifact@v7`. +- **`scripts/render-palette.mjs`** (`npm run render:palette`): dependency-free PNG palette strips per variant (`images/palette-*.png` — ladder with hex + L\*, bracket pairs, ANSI slots) shown in the README; `--check` runs in `npm run check`, CI and a new test. README images are excluded from the VSIX (`vsce` serves them from the repository). +- **`scripts/capture-screenshots.sh`**: reproducible macOS editor screenshots per variant (throwaway VS Code profile, recommended settings, `samples/demo.tsx`); README carries a labelled slot until they are captured. +- **`docs/PUBLISHING.md`**: Marketplace publisher + Azure DevOps PAT, Open VSX namespace/token, repository secrets, first-publish smoke test, screenshots, badges, troubleshooting. + ### Changed — Internal: verifier v2, tests, CI - **Verifier v2** (`scripts/verify-palette.mjs`). New checks alongside the L\* ladder, wavelength band, and key parity: an **APCA Lc** column and per-role floors (body 60 / syntax 40 / special 37 / punctuation 28 / comments 22, overridable per variant via `verify.apcaFloors` — Alone Soft declares a scaled set); **ΔE2000** on every tight ladder gap; a **colour-vision-deficiency** table (Viénot protan/deutan/tritan simulation) over the ten most confusable role pairs, passing on ΔE2000 ≥ 5 or a font-style difference; **ANSI** pairwise ΔE2000 among the eight normal terminal slots (≥ 10); and a **whole-theme wavelength scan** — every chromatic hex in every variant, not just the ten headline roles, must have a dominant wavelength ≥ 575 nm. The perceptual checks were introduced as warnings against the 1.3.1 palette (which missed several — comments Lc 16, ANSI blue/cyan ΔE 5.2, Variables/Parameter under CVD) and are hard failures from 2.0.0. Each check's severity is a one-line `POLICY` entry. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c1aef6..7e1d42d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,8 @@ The shipped `themes/*.json` files are **generated**. Don't edit them directly npm ci npm run build:themes # regenerate themes/*.json from _src/ npm run verify # L* ladder, APCA floors, CVD + ANSI ΔE2000, wavelength scan, key parity, README tables -npm run check # both, in sequence +npm run check # build + verify + palette-PNG freshness +npm run render:palette # regenerate images/palette-*.png after a palette change npm test # node --test tests/ node scripts/verify-palette.mjs --write-readme # re-render the verifier-owned README tables after a palette change npx @vscode/vsce package # build the VSIX locally @@ -45,6 +46,7 @@ npx @vscode/vsce package # build the VSIX locally - **ANSI** — the eight normal terminal slots pairwise ΔE2000 ≥ 10. - **Wavelength** — two parts. The ten ladder roles must pass the variant's declared band (`warm` = no blue/cyan hex). Separately, *every* chromatic hex in the theme — UI chrome, terminal, everything — must have a dominant wavelength ≥ 575 nm (`SCAN_MIN_NM`); neutrals below the chroma threshold are skipped. Note the whole-theme scan does not apply the declared band: a narrower band (a future `red-only` variant, say) constrains the ten roles, not the rest of the theme. - **Parity** — every variant has exactly the same keys, rules and selectors as every other. +- **Palette PNGs** — `images/palette-*.png` must match what `scripts/render-palette.mjs` produces (`--check`). - **README** — the tables between `` markers must match what the palette produces. The header of the script explains the policy: since 2.0.0 every check is a hard failure. If you are deliberately moving the palette, demote the affected check to `warn` in the same PR that changes the palette and restore it before merge — do not add environment overrides. @@ -53,7 +55,7 @@ The header of the script explains the policy: since 2.0.0 every check is a hard 1. Edit the hex in `themes/_src/variants/.yaml` (or `base.yaml` if it is shared by every variant). 2. `npm run check` — read the verifier output; fix any failure rather than relaxing the threshold. -3. `node scripts/verify-palette.mjs --write-readme` and commit the README diff along with the regenerated `themes/*.json`. +3. `node scripts/verify-palette.mjs --write-readme` and `npm run render:palette`; commit the README diff and `images/palette-*.png` along with the regenerated `themes/*.json`. 4. If you touched an ANSI slot, mirror it in the four `terminal/` files (the README ANSI table is the reference). 5. Note the change under `## [Unreleased]` in `CHANGELOG.md`. Visible look changes are a minor/major bump; fixes that don't change rendered colours are patch. @@ -74,3 +76,7 @@ Edit `base.yaml` only. Bind the new rule to an existing role token (`${tokenColo - Conventional commit prefixes: `feat:`, `fix:`, `docs:`, `test:`, `chore:`, `refactor:`. - `npm run check && npm test` green locally; CI runs the same plus `git diff --exit-code themes/` (committed JSON must be what the build produces) and a `vsce package` dry run on Node 22 and 24. - Keep generated JSON, README tables and CHANGELOG in the same PR as the palette change that caused them. + +## Releasing + +See [docs/PUBLISHING.md](docs/PUBLISHING.md): bump `version`, move CHANGELOG entries out of `[Unreleased]`, merge, tag `vX.Y.Z` — the release workflow packages, creates the GitHub Release and publishes to the Marketplace and Open VSX. diff --git a/README.md b/README.md index b1d9044..a48c96e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ **A theme for those who thrive coding alone in the dark.** - + +![Alone palette — syntax ladder, bracket pairs, ANSI slots](images/palette-alone.png) Alone is a warm, low-luminance dark theme for people who code in dark rooms for hours. Every colour in it — syntax, UI chrome, terminal — has a dominant wavelength between 575 and 611 nm: gold, amber, olive, terracotta, dusty rose. There is no blue, no cyan, no purple, and no white text. Foregrounds sit well below the brightness of a typical dark theme, contrast is tuned with APCA rather than maxed out, and a verifier script fails the build if any of that drifts. @@ -263,14 +264,20 @@ The full-featured theme with balanced contrast for extended coding sessions. Def For pitch-black rooms and light-sensitive eyes. All syntax colours reduced ~20 % in brightness, backgrounds slightly lifted to reduce contrast. Verified against its own, lower APCA floors. +![Alone Soft palette](images/palette-alone-soft.png) + ### Alone Focused For concentration. UI chrome is muted — activity-bar badges dimmed, sidebar de-emphasised, borders hidden. Syntax highlighting unchanged. Your code takes centre stage. +![Alone Focused palette](images/palette-alone-focused.png) + ### Alone Roman The Standard palette with **no italics** — for astigmatic readers who find slanted monospace edges fringe, or anyone who simply dislikes italic code. Every rule that Standard sets in italic (comments, strings, docstrings, regex, decorators, interfaces, type parameters, namespaces, `*.defaultLibrary`, `*.async`, `this`/`self`) is upright here; the only italic left is Markdown `*emphasis*`, which is the document's own formatting. Bold keywords/escapes/errors are unchanged. Two hexes differ from Standard so the pairs that Standard tells apart with italics stay separable on colour alone under red-green colour-vision deficiency: strings `#9A8B60 → #9C8B4A` and `*.defaultLibrary` `#B08C50 → #AA884C` (both verified ΔE2000 ≥ 5 after protan/deutan simulation). What you give up is the italic-only distinctions — interfaces look like classes, library calls like local ones, async like sync. +![Alone Roman palette](images/palette-alone-roman.png) + --- ## Terminal Themes diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md new file mode 100644 index 0000000..8c12828 --- /dev/null +++ b/docs/PUBLISHING.md @@ -0,0 +1,124 @@ +# Publishing Alone + +Everything below the "Once" line is done once by the repository owner. After that a release is `git tag vX.Y.Z && git push origin vX.Y.Z` and the [release workflow](../.github/workflows/release.yml) does the rest. + +## How a release works + +1. Bump `version` in `package.json` (and `package-lock.json`, `npm version --no-git-tag-version X.Y.Z` does both), move the `[Unreleased]` CHANGELOG entries under `## [X.Y.Z] - YYYY-MM-DD`, commit on `main` through a PR. +2. `git tag vX.Y.Z && git push origin vX.Y.Z`. +3. The workflow: checks the tag matches `package.json`, runs `npm run check && npm test`, packages `alone-X.Y.Z.vsix`, creates a GitHub Release with the VSIX attached, then publishes the same VSIX to the VS Code Marketplace (`VSCE_PAT`) and Open VSX (`OVSX_PAT`). Either publish step is skipped if its secret is missing, so the workflow is safe to run before the accounts exist. +4. Verify on the Marketplace page (a few minutes) and Open VSX (usually seconds). + +A dry run without publishing: **Actions → Release → Run workflow** with *publish* unchecked — produces the VSIX artifact and exercises everything except the two publish steps. + +--- + +## Once: accounts, tokens, secrets + +### 1. VS Code Marketplace publisher `crypticpy` + +`package.json` already declares `"publisher": "crypticpy"`; the publisher ID on the Marketplace must match exactly. + +1. Sign in at with a Microsoft account (create one if needed — it can be tied to any email). +2. **Create publisher** → ID `crypticpy`, display name of your choice. If `crypticpy` is taken, pick another ID and change `"publisher"` in `package.json` before the first publish. + +### 2. Azure DevOps Personal Access Token (for `vsce`) + +The Marketplace authenticates `vsce` with an Azure DevOps PAT, not a Marketplace-side key. + +1. Go to with the same Microsoft account. If you have no organisation, create one (any name; it's only a container for the token). +2. User settings (top-right) → **Personal access tokens** → **New Token**: + - Name: `vsce-alone` + - Organization: **All accessible organizations** (required — a single-org token cannot publish) + - Expiration: custom, up to 1 year (put the expiry in your calendar) + - Scopes: **Custom defined** → *Show all scopes* → **Marketplace: Manage** (nothing else) +3. Copy the token now; it is shown once. +4. Sanity check locally (also verifies the publisher exists and the token has the right scope): + ```bash + npx vsce login crypticpy # paste the PAT + npx vsce ls-publishers + ``` + +### 3. Open VSX namespace + token (for `ovsx`) + +Open VSX is what VSCodium, Gitpod, Theia and Cursor-style forks read. + +1. Sign in at with GitHub. +2. Profile → **Access Tokens** → *Generate New Token* (name `ovsx-alone`). Copy it. +3. Claim the namespace once (it must equal the `publisher` field): + ```bash + npx --yes ovsx@1 create-namespace crypticpy --pat + ``` + Optionally file the [publisher-agreement / namespace ownership](https://github.com/EclipseFdn/open-vsx.org/wiki/Namespace-Access) issue so the namespace shows as verified. + +### 4. Repository secrets + +GitHub → repo → **Settings → Secrets and variables → Actions → New repository secret**: + +| Secret | Value | +| ---------- | ---------------------------------- | +| `VSCE_PAT` | the Azure DevOps PAT from step 2 | +| `OVSX_PAT` | the Open VSX access token from step 3 | + +The workflow only reads them on `v*` tags (or a manual run with *publish* checked). + +### 5. First publish (smoke test) + +Do the very first publish by hand so any Marketplace-side rejection (icon size, README links, publisher mismatch) is visible immediately: + +```bash +npm ci && npm run check && npm test +npx vsce package # alone-2.0.0.vsix +npx vsce publish --packagePath alone-2.0.0.vsix # uses the PAT from `vsce login` +npx --yes ovsx@1 publish alone-2.0.0.vsix --pat +``` + +Then check: + +- renders README, icon, palette PNGs; the four themes are listed under *Contributions*. +- exists. +- `code --install-extension crypticpy.alone` installs it and **Preferences: Color Theme** offers all four variants. + +From the next version on, just tag. + +--- + +## Screenshots + +`images/palette-.png` are generated (`npm run render:palette`, checked in CI). Real editor screenshots are captured on macOS with: + +```bash +scripts/capture-screenshots.sh # → images/screenshot-{alone,alone-soft,alone-focused,alone-roman}.png +``` + +The terminal you run it from needs **Screen Recording** and **Accessibility** permission (System Settings → Privacy & Security); without them the output is the desktop wallpaper. Install JetBrains Mono first so the shot matches the README's recommended settings. Then reference the images from README: + +```markdown + ← replace this comment with: +![Alone — samples/demo.tsx](images/screenshot-alone.png) +``` + +and add the Soft / Focused / Roman shots under **Theme Variants**. Keep each PNG under ~500 KB. README images are not packed into the VSIX (`.vscodeignore` excludes `images/palette-*.png` and `images/screenshot-*.png`); `vsce` rewrites relative README URLs to the GitHub repository, so the Marketplace page fetches them from `main`. That means images must be committed *before* the tag that publishes the README referencing them. + +Capture checklist if you prefer to do it by hand: window ~1440×900, `samples/demo.tsx` open, minimap on, no sidebar/panel, cursor on a bracket so the active-guide shows, one hover tooltip visible if you like; save as PNG, no window shadow (`screencapture -o`). + +--- + +## Badges (once live) + +Paste under the README title: + +```markdown +[![Marketplace](https://img.shields.io/visual-studio-marketplace/v/crypticpy.alone?label=Marketplace&color=D4A048)](https://marketplace.visualstudio.com/items?itemName=crypticpy.alone) +[![Installs](https://img.shields.io/visual-studio-marketplace/i/crypticpy.alone?color=C08868)](https://marketplace.visualstudio.com/items?itemName=crypticpy.alone) +[![Open VSX](https://img.shields.io/open-vsx/v/crypticpy/alone?label=Open%20VSX&color=9A8B60)](https://open-vsx.org/extension/crypticpy/alone) +[![CI](https://github.com/crypticpy/alone/actions/workflows/ci.yml/badge.svg)](https://github.com/crypticpy/alone/actions/workflows/ci.yml) +``` + +## Troubleshooting + +- `vsce publish` → *401 / The Personal Access Token used has expired or is invalid*: token scope must be **Marketplace: Manage** with **All accessible organizations**. +- *Publisher 'crypticpy' not found*: create it at the manage URL above (the ID must match `package.json`). +- *Extension version already exists*: bump `version`; the Marketplace never accepts re-publishing a version. +- Release workflow fails at *Tag must match*: `package.json` version and the tag differ — bump and re-tag (`git tag -d vX.Y.Z && git push --delete origin vX.Y.Z` first). +- Open VSX *namespace not found*: run `create-namespace` (step 3) once. diff --git a/images/palette-alone-focused.png b/images/palette-alone-focused.png new file mode 100644 index 0000000000000000000000000000000000000000..abb0c0761d5d91e810bf49f960aaf430f206d608 GIT binary patch literal 10369 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 literal 0 HcmV?d00001 diff --git a/images/palette-alone-soft.png b/images/palette-alone-soft.png new file mode 100644 index 0000000000000000000000000000000000000000..841bdac50a658ff83423f87ec780118ce04333c3 GIT binary patch literal 10487 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 M-G;Eb5ZZzN0Sj`ZSpWb4 literal 0 HcmV?d00001 diff --git a/images/palette-alone.png b/images/palette-alone.png new file mode 100644 index 0000000000000000000000000000000000000000..8f436f1fc085942d6df87c048e574f4a393c9bd8 GIT binary patch literal 10280 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 6645165..a218893 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,9 @@ "scripts": { "build:themes": "node scripts/build-themes.mjs", "verify": "node scripts/verify-palette.mjs", - "check": "npm run build:themes && npm run verify", + "check": "npm run build:themes && npm run verify && node scripts/render-palette.mjs --check", "test": "node --test tests/*.test.mjs", - "package": "vsce package" + "package": "vsce package", + "render:palette": "node scripts/render-palette.mjs" } } diff --git a/scripts/capture-screenshots.sh b/scripts/capture-screenshots.sh new file mode 100755 index 0000000..1f4a489 --- /dev/null +++ b/scripts/capture-screenshots.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Capture README/Marketplace screenshots of every variant on macOS. +# +# scripts/capture-screenshots.sh # all four variants → images/screenshot-.png +# scripts/capture-screenshots.sh "Alone Soft" +# +# What it does: packages the current tree into a VSIX, installs it into a +# throwaway VS Code profile under /tmp (short path — VS Code's IPC socket +# path limit rejects long --user-data-dir values), opens samples/demo.tsx +# with the README's recommended editor settings and the given theme, waits +# for the window, and captures it with `screencapture`. +# +# Requirements (one-time, macOS): the terminal you run this from needs +# System Settings → Privacy & Security → Screen Recording (for screencapture) +# and → Accessibility (so osascript can read the window bounds). Without them +# the capture is the desktop wallpaper — that's how you know they're missing. +# +# JetBrains Mono (or the Nerd Font build) should be installed for the shot to +# match the README's recommended settings; the fallback is the system mono. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROFILE=/tmp/alone-shot +CODE_BIN="${CODE_BIN:-/Applications/Visual Studio Code.app/Contents/MacOS/Code}" +SAMPLE="${SAMPLE:-$ROOT/samples/demo.tsx}" +WAIT="${WAIT:-10}" +VARIANTS=("$@") +if [ ${#VARIANTS[@]} -eq 0 ]; then VARIANTS=("Alone" "Alone Soft" "Alone Focused" "Alone Roman"); fi + +[ -x "$CODE_BIN" ] || { echo "VS Code binary not found at $CODE_BIN (set CODE_BIN)"; exit 3; } +command -v screencapture >/dev/null || { echo "screencapture not found — this script is macOS-only"; exit 3; } + +rm -rf "$PROFILE" && mkdir -p "$PROFILE/user/User" "$PROFILE/ext" +(cd "$ROOT" && npx vsce package --out "$PROFILE/alone.vsix" >/dev/null) +"$CODE_BIN" --user-data-dir "$PROFILE/user" --extensions-dir "$PROFILE/ext" --install-extension "$PROFILE/alone.vsix" >/dev/null 2>&1 + +slug() { echo "$1" | tr '[:upper:] ' '[:lower:]-'; } + +for variant in "${VARIANTS[@]}"; do + cat > "$PROFILE/user/User/settings.json" </dev/null 2>&1 & + pid=$! + sleep "$WAIT" + out="$ROOT/images/screenshot-$(slug "$variant").png" + bounds="$(osascript -e 'tell application "System Events" to tell (first process whose name is "Code" and background only is false) to get {position, size} of window 1' 2>/dev/null || true)" + if [ -n "$bounds" ]; then + # "x, y, w, h" → screencapture -R x,y,w,h + rect="$(echo "$bounds" | tr -d ' ')" + screencapture -x -R "$rect" "$out" + else + echo " (no window bounds via osascript — capturing the whole main display; crop by hand)" + screencapture -x -m "$out" + fi + echo "wrote ${out#$ROOT/}" + kill "$pid" 2>/dev/null || true + pkill -f "$PROFILE" 2>/dev/null || true + sleep 2 +done + +rm -rf "$PROFILE" +echo "Done. Review images/screenshot-*.png, then reference them from README.md (see docs/PUBLISHING.md § Screenshots)." diff --git a/scripts/render-palette.mjs b/scripts/render-palette.mjs new file mode 100644 index 0000000..e38317d --- /dev/null +++ b/scripts/render-palette.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/** + * Render a palette strip PNG for every variant → images/palette-.png + * + * node scripts/render-palette.mjs # write all + * node scripts/render-palette.mjs --check # exit 1 if any PNG differs from what the palette produces + * + * Dependency-free: raw RGBA buffer + node:zlib deflate + hand-rolled PNG + * chunks, with a tiny 5×7 bitmap font for the labels. The strip shows the + * ten-rung syntax ladder (swatch, role, hex, L*), the six bracket colours and + * the sixteen ANSI slots, on the variant's own editor background. Used by the + * README / Marketplace page as an always-in-sync visual of the palette. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { fileURLToPath } from 'node:url'; +import { hexToRgb255, cielabL } from './lib/color.mjs'; +import { LADDER_ROLES, resolveRole, bracketColors, ansiColors } from './lib/theme-roles.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const THEMES_DIR = path.join(ROOT, 'themes'); +const OUT_DIR = path.join(ROOT, 'images'); +const CHECK = process.argv.includes('--check'); + +// ─── 5×7 bitmap font (uppercase, digits, a few marks) ───────────────────── +// Each glyph: 7 rows of 5 bits, MSB = leftmost pixel. +const FONT = { + A: [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], B: [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e], + C: [0x0e, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0e], D: [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e], + E: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f], F: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10], + G: [0x0e, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0f], H: [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], + I: [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e], J: [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c], + K: [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], L: [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f], + M: [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11], N: [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11], + O: [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], P: [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10], + Q: [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d], R: [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11], + S: [0x0f, 0x10, 0x10, 0x0e, 0x01, 0x01, 0x1e], T: [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], + U: [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], V: [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04], + W: [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11], X: [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11], + Y: [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04], Z: [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f], + 0: [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e], 1: [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e], + 2: [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f], 3: [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e], + 4: [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02], 5: [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e], + 6: [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e], 7: [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], + 8: [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e], 9: [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c], + '#': [0x0a, 0x0a, 0x1f, 0x0a, 0x1f, 0x0a, 0x0a], '*': [0x00, 0x04, 0x15, 0x0e, 0x15, 0x04, 0x00], + '-': [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00], '.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c], + '/': [0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10], ' ': [0, 0, 0, 0, 0, 0, 0], + '(': [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02], ')': [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08], + ',': [0x00, 0x00, 0x00, 0x00, 0x0c, 0x04, 0x08], +}; + +// ─── Canvas ───────────────────────────────────────────────────────────── +class Canvas { + constructor(w, h, bg) { + this.w = w; this.h = h; this.px = Buffer.alloc(w * h * 4); + this.rect(0, 0, w, h, bg); + } + set(x, y, [r, g, b]) { + if (x < 0 || y < 0 || x >= this.w || y >= this.h) return; + const i = (y * this.w + x) * 4; + this.px[i] = r; this.px[i + 1] = g; this.px[i + 2] = b; this.px[i + 3] = 255; + } + rect(x, y, w, h, rgb) { + for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) this.set(i, j, rgb); + } + /** Draw `text` (uppercased; unknown glyphs render as space) at (x,y), pixel scale `s`. Returns advance width. */ + text(x, y, str, rgb, s = 2) { + let cx = x; + for (const ch of String(str).toUpperCase()) { + const g = FONT[ch] ?? FONT[' ']; + for (let row = 0; row < 7; row++) for (let col = 0; col < 5; col++) { + if (g[row] & (0x10 >> col)) this.rect(cx + col * s, y + row * s, s, s, rgb); + } + cx += 6 * s; + } + return cx - x; + } + png() { + const stride = this.w * 4; + const raw = Buffer.alloc((stride + 1) * this.h); + for (let y = 0; y < this.h; y++) { + raw[y * (stride + 1)] = 0; // filter: none + this.px.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(this.w, 0); ihdr.writeUInt32BE(this.h, 4); + ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; // 8-bit RGBA + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); + } +} + +const CRC_TABLE = new Uint32Array(256).map((_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0); + const td = Buffer.concat([Buffer.from(type, 'latin1'), data]); + const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td), 0); + return Buffer.concat([len, td, crc]); +} + +// ─── Layout ───────────────────────────────────────────────────────────── +const W = 1200, PAD = 32, ROW = 40, SW = 28; + +export function renderVariant(theme) { + const bg = hexToRgb255(theme.colors['editor.background']); + const fg = hexToRgb255(theme.colors['editor.foreground']); + const dim = hexToRgb255(theme.colors['editorLineNumber.foreground'] ?? theme.colors['editor.foreground']); + const ladder = LADDER_ROLES.map((r) => resolveRole(theme, r)); + const brackets = bracketColors(theme); + const ansi = ansiColors(theme); + const H = PAD + 24 + ladder.length * ROW + 24 + 60 + 24 + 60 + PAD; + const c = new Canvas(W, H, bg); + let y = PAD; + c.text(PAD, y, `${theme.name} - syntax ladder, brackets, ANSI`, fg, 2); y += 24; + for (const r of ladder) { + c.rect(PAD, y, SW * 2, SW, hexToRgb255(r.hex)); + const label = `${r.name}${r.style.italic ? ' (italic)' : ''}${r.style.bold ? ' (bold)' : ''}`; + c.text(PAD + SW * 2 + 16, y + 6, label.padEnd(22), hexToRgb255(r.hex), 2); + c.text(PAD + SW * 2 + 16 + 22 * 12 + 16, y + 6, r.hex, hexToRgb255(r.hex), 2); + c.text(PAD + SW * 2 + 16 + 22 * 12 + 16 + 8 * 12 + 16, y + 6, `L* ${cielabL(r.hex).toFixed(0)}`, dim, 2); + // a bar proportional to L*, like the README ladder + const barX = PAD + SW * 2 + 16 + 22 * 12 + 16 + 8 * 12 + 16 + 6 * 12 + 16; + c.rect(barX, y + 8, Math.round((W - PAD - barX) * cielabL(r.hex) / 100), SW - 16, hexToRgb255(r.hex)); + y += ROW; + } + y += 8; + c.text(PAD, y, 'bracket pairs (depth 1-6)', dim, 2); y += 20; + brackets.forEach((b, i) => c.rect(PAD + i * (SW * 2 + 8), y, SW * 2, SW, hexToRgb255(b.hex))); + y += SW + 28; + c.text(PAD, y, 'ANSI 0-7 / 8-15', dim, 2); y += 20; + ansi.forEach((a, i) => c.rect(PAD + (i % 8) * (SW * 2 + 8), y + Math.floor(i / 8) * (SW + 6), SW * 2, SW, hexToRgb255(a.hex))); + return c.png(); +} + +function main() { + const files = fs.readdirSync(THEMES_DIR).filter((f) => f.endsWith('-color-theme.json')).sort(); + if (!CHECK) fs.mkdirSync(OUT_DIR, { recursive: true }); + let drift = 0; + for (const f of files) { + const theme = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, f), 'utf8')); + const out = path.join(OUT_DIR, `palette-${f.replace('-color-theme.json', '')}.png`); + const png = renderVariant(theme); + if (CHECK) { + const cur = fs.existsSync(out) ? fs.readFileSync(out) : null; + if (!cur || !cur.equals(png)) { console.error(`✗ ${path.relative(ROOT, out)} is stale — run: node scripts/render-palette.mjs`); drift++; } + else console.log(`✓ ${path.relative(ROOT, out)} current`); + } else { + fs.writeFileSync(out, png); + console.log(`wrote ${path.relative(ROOT, out)} (${png.length} bytes)`); + } + } + if (drift) process.exit(1); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/tests/render-palette.test.mjs b/tests/render-palette.test.mjs new file mode 100644 index 0000000..4ab9852 --- /dev/null +++ b/tests/render-palette.test.mjs @@ -0,0 +1,41 @@ +/** + * The palette-strip renderer must produce a well-formed, deterministic PNG + * for every committed theme, and the committed PNGs must be current. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { fileURLToPath } from 'node:url'; +import { renderVariant } from '../scripts/render-palette.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const themes = fs.readdirSync(path.join(ROOT, 'themes')).filter((f) => f.endsWith('-color-theme.json')).sort(); + +test('renderVariant emits a valid RGBA PNG whose IDAT inflates to width*height*4 (+filter bytes)', () => { + for (const f of themes) { + const theme = JSON.parse(fs.readFileSync(path.join(ROOT, 'themes', f), 'utf8')); + const png = renderVariant(theme); + assert.deepEqual([...png.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], `${f}: PNG signature`); + assert.equal(png.toString('latin1', 12, 16), 'IHDR'); + const w = png.readUInt32BE(16), h = png.readUInt32BE(20); + assert.equal(png[24], 8); assert.equal(png[25], 6); // 8-bit RGBA + const idatLen = png.readUInt32BE(33); + assert.equal(png.toString('latin1', 37, 41), 'IDAT'); + const raw = zlib.inflateSync(png.subarray(41, 41 + idatLen)); + assert.equal(raw.length, (w * 4 + 1) * h, `${f}: decoded size`); + assert.equal(png.toString('latin1', png.length - 8, png.length - 4), 'IEND'); + } +}); + +test('renderVariant is deterministic and images/palette-*.png are current', () => { + for (const f of themes) { + const theme = JSON.parse(fs.readFileSync(path.join(ROOT, 'themes', f), 'utf8')); + const a = renderVariant(theme), b = renderVariant(theme); + assert.ok(a.equals(b), `${f}: two renders differ`); + const out = path.join(ROOT, 'images', `palette-${f.replace('-color-theme.json', '')}.png`); + assert.ok(fs.existsSync(out), `${path.relative(ROOT, out)} missing — run npm run render:palette`); + assert.ok(fs.readFileSync(out).equals(a), `${path.relative(ROOT, out)} stale — run npm run render:palette`); + } +}); From 276fa0798a1deedaea095aede65407f3a0985ba6 Mon Sep 17 00:00:00 2001 From: Beyond <46542494+crypticpy@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:47:59 -0500 Subject: [PATCH 2/2] fix(release): independent Marketplace / Open VSX publish jobs, idempotent GitHub Release Addresses Codex review on PR #18: a combined job re-ran `vsce publish` before reaching Open VSX, and the Marketplace rejects an already-published version, so a partial release could not be repaired. Publishing is now two jobs that consume the VSIX artifact from `build`; "Re-run failed jobs" retries only the one that failed. `gh release create` falls back to `upload --clobber` when the release already exists. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 96 ++++++++++++++++++++++++++--------- CHANGELOG.md | 2 +- docs/PUBLISHING.md | 4 +- 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index befda2d..984991c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,15 @@ name: Release # Tag-driven release: `git tag v2.0.0 && git push origin v2.0.0` -# 1. build → verify → test (same gate as CI) -# 2. package the VSIX and attach it to a GitHub Release -# 3. publish to the VS Code Marketplace (VSCE_PAT) and Open VSX (OVSX_PAT) -# Publish steps are skipped when the corresponding secret is absent, so a -# workflow_dispatch run on a fork or before the accounts exist still produces -# a Release + VSIX. See docs/PUBLISHING.md for the account/PAT setup. +# build → verify → test → package the VSIX → GitHub Release (tags only) +# marketplace → publish that VSIX to the VS Code Marketplace (VSCE_PAT) +# open-vsx → publish the same VSIX to Open VSX (OVSX_PAT) +# The two registry jobs are independent, so if one fails (outage, expired +# token) "Re-run failed jobs" retries only that one — the Marketplace rejects +# a version that is already published, so re-running a combined job could +# never repair a half-finished release. A publish job is skipped when its +# secret is absent, so a workflow_dispatch run on a fork or before the +# accounts exist still produces a Release + VSIX. See docs/PUBLISHING.md. on: push: @@ -22,14 +25,14 @@ permissions: contents: write jobs: - release: - name: package · release · publish + build: + name: verify · package · release runs-on: ubuntu-latest - env: - # `secrets` isn't available in step-level `if`, so surface presence here. - HAS_VSCE_PAT: ${{ secrets.VSCE_PAT != '' }} - HAS_OVSX_PAT: ${{ secrets.OVSX_PAT != '' }} - DO_PUBLISH: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish == true }} + outputs: + vsix: ${{ steps.pkg.outputs.vsix }} + publish: ${{ steps.flags.outputs.publish }} + has_vsce_pat: ${{ steps.flags.outputs.has_vsce_pat }} + has_ovsx_pat: ${{ steps.flags.outputs.has_ovsx_pat }} steps: - uses: actions/checkout@v7 @@ -61,7 +64,7 @@ jobs: - name: Upload VSIX artifact uses: actions/upload-artifact@v7 with: - name: ${{ steps.pkg.outputs.vsix }} + name: vsix path: ${{ steps.pkg.outputs.vsix }} if-no-files-found: error @@ -70,19 +73,64 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" "${{ steps.pkg.outputs.vsix }}" \ - --title "Alone $GITHUB_REF_NAME" \ - --notes "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/main/CHANGELOG.md) for details." \ - --verify-tag + # Idempotent so a re-run of this job after a downstream failure succeeds. + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" "${{ steps.pkg.outputs.vsix }}" --clobber + else + gh release create "$GITHUB_REF_NAME" "${{ steps.pkg.outputs.vsix }}" \ + --title "Alone $GITHUB_REF_NAME" \ + --notes "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/main/CHANGELOG.md) for details." \ + --verify-tag + fi - - name: Publish to VS Code Marketplace - if: env.HAS_VSCE_PAT == 'true' && env.DO_PUBLISH == 'true' + - name: Publish flags + id: flags + # `secrets` is not available in a job-level `if`, so surface presence as + # outputs (booleans only — no secret material crosses the job boundary). env: VSCE_PAT: ${{ secrets.VSCE_PAT }} - run: npx vsce publish --packagePath "${{ steps.pkg.outputs.vsix }}" + OVSX_PAT: ${{ secrets.OVSX_PAT }} + DO_PUBLISH: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish == true }} + run: | + { + echo "publish=$DO_PUBLISH" + echo "has_vsce_pat=$([ -n "$VSCE_PAT" ] && echo true || echo false)" + echo "has_ovsx_pat=$([ -n "$OVSX_PAT" ] && echo true || echo false)" + } >> "$GITHUB_OUTPUT" - - name: Publish to Open VSX - if: env.HAS_OVSX_PAT == 'true' && env.DO_PUBLISH == 'true' + marketplace: + name: publish · VS Code Marketplace + needs: build + if: needs.build.outputs.publish == 'true' && needs.build.outputs.has_vsce_pat == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - run: npm ci + - uses: actions/download-artifact@v8 + with: + name: vsix + - name: vsce publish + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: npx vsce publish --packagePath "${{ needs.build.outputs.vsix }}" + + open-vsx: + name: publish · Open VSX + needs: build + if: needs.build.outputs.publish == 'true' && needs.build.outputs.has_ovsx_pat == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/setup-node@v7 + with: + node-version: 24 + - uses: actions/download-artifact@v8 + with: + name: vsix + - name: ovsx publish env: OVSX_PAT: ${{ secrets.OVSX_PAT }} - run: npx --yes ovsx@1 publish "${{ steps.pkg.outputs.vsix }}" --pat "$OVSX_PAT" + run: npx --yes ovsx@1 publish "${{ needs.build.outputs.vsix }}" --pat "$OVSX_PAT" diff --git a/CHANGELOG.md b/CHANGELOG.md index ec50059..320436f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,7 @@ New `tokenColors` rules and `semanticTokenColors` selectors in `themes/_src/base ### Added — Release pipeline, palette images, publishing guide -- **`.github/workflows/release.yml`**: on a `v*` tag — tag/version match check → `npm run check && npm test` → `vsce package` → GitHub Release with the VSIX → `vsce publish` (`VSCE_PAT`) → `ovsx publish` (`OVSX_PAT`); publish steps skip when a secret is absent, and `workflow_dispatch` gives a dry run. CI actions bumped to `checkout@v7` / `setup-node@v7` / `upload-artifact@v7`. +- **`.github/workflows/release.yml`**: on a `v*` tag — tag/version match check → `npm run check && npm test` → `vsce package` → GitHub Release with the VSIX, then two independent publish jobs, `vsce publish` (`VSCE_PAT`) and `ovsx publish` (`OVSX_PAT`), so a failed registry can be re-run alone; a publish job skips when its secret is absent, and `workflow_dispatch` gives a dry run. CI actions bumped to `checkout@v7` / `setup-node@v7` / `upload-artifact@v7`. - **`scripts/render-palette.mjs`** (`npm run render:palette`): dependency-free PNG palette strips per variant (`images/palette-*.png` — ladder with hex + L\*, bracket pairs, ANSI slots) shown in the README; `--check` runs in `npm run check`, CI and a new test. README images are excluded from the VSIX (`vsce` serves them from the repository). - **`scripts/capture-screenshots.sh`**: reproducible macOS editor screenshots per variant (throwaway VS Code profile, recommended settings, `samples/demo.tsx`); README carries a labelled slot until they are captured. - **`docs/PUBLISHING.md`**: Marketplace publisher + Azure DevOps PAT, Open VSX namespace/token, repository secrets, first-publish smoke test, screenshots, badges, troubleshooting. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 8c12828..68eaa4a 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -6,10 +6,10 @@ Everything below the "Once" line is done once by the repository owner. After tha 1. Bump `version` in `package.json` (and `package-lock.json`, `npm version --no-git-tag-version X.Y.Z` does both), move the `[Unreleased]` CHANGELOG entries under `## [X.Y.Z] - YYYY-MM-DD`, commit on `main` through a PR. 2. `git tag vX.Y.Z && git push origin vX.Y.Z`. -3. The workflow: checks the tag matches `package.json`, runs `npm run check && npm test`, packages `alone-X.Y.Z.vsix`, creates a GitHub Release with the VSIX attached, then publishes the same VSIX to the VS Code Marketplace (`VSCE_PAT`) and Open VSX (`OVSX_PAT`). Either publish step is skipped if its secret is missing, so the workflow is safe to run before the accounts exist. +3. The workflow runs three jobs. `build` checks the tag matches `package.json`, runs `npm run check && npm test`, packages `alone-X.Y.Z.vsix` and creates a GitHub Release with the VSIX attached. `marketplace` and `open-vsx` then each publish that same VSIX to their registry (`VSCE_PAT` / `OVSX_PAT`). The two publish jobs are independent: if one fails (outage, expired token) use **Re-run failed jobs** and only that one runs again — the other registry, which already has the version, is left alone. A publish job is skipped when its secret is missing, so the workflow is safe to run before the accounts exist. 4. Verify on the Marketplace page (a few minutes) and Open VSX (usually seconds). -A dry run without publishing: **Actions → Release → Run workflow** with *publish* unchecked — produces the VSIX artifact and exercises everything except the two publish steps. +A dry run without publishing: **Actions → Release → Run workflow** with *publish* unchecked — produces the VSIX artifact and exercises everything except the two publish jobs. ---