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
19 changes: 19 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!--
Title: <emoji> <Imperative sentence>, in English, under 72 characters, e.g.
✨ Add the Grok CLI provider · 🐛 Stop a copied conversation reading as fresh spend
The emoji sets the version: 💥 major · ✨ 🏗️ minor · 🐛 ♻️ 🎨 🔧 … patch · 📝 ✅ 👷 🔖 none
(full table in CONTRIBUTING.md). Trivial fixes may drop the sections they don't need.
-->

## What and why

<!-- What changes, and the problem or reason behind it. -->

## How to test

<!-- What you ran or checked. For UI changes, describe before and after. -->

## Checklist

- [ ] Tests included, and `npm test` / `npm run format:check` pass
- [ ] Player-visible change: notes added under `## [Unreleased]` in `CHANGELOG.md`
17 changes: 17 additions & 0 deletions .github/scripts/changelog.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
# is about to be tagged and the next contributor still has a place to
# write. This is exactly what the hand-cut releases did (the 0.3.0
# release commit changed CHANGELOG.md by one line), plus the re-opening.
# notes <version> print the section of `## [<version>]`, without its heading or the blank
# lines around it — the GitHub release's notes (`release.yml`). Fails on a
# version with no section, or an empty one: never a release with no notes.
#
# The date is HEAD's *author* date, `--date=short`, which carries the author's own timezone.
# UTC would be wrong here often enough to matter: 0.3.0 was released at 00:54 +0200 and is
Expand Down Expand Up @@ -84,6 +87,20 @@ case "${1:-}" in
echo "stamped ## [$VERSION] - $DATE"
;;

notes)
VERSION="${2:-}"
[ -n "$VERSION" ] || die 'notes needs a version: changelog.sh notes 1.0.0'
BODY="$(awk -v heading="## [$VERSION]" '
index($0, heading) == 1 { inside = 1; next }
/^## / { if (inside) exit }
inside { print }
' "$FILE")"
# `$(…)` already dropped the trailing blank lines; the leading ones go here.
BODY="$(printf '%s\n' "$BODY" | sed '/[^[:space:]]/,$!d')"
[ -n "$BODY" ] || die "no notes for $VERSION in $FILE"
printf '%s\n' "$BODY"
;;

*)
sed -n '2,/^set -euo/p' "$0" | sed 's/^# \{0,1\}//;$d'
exit 2
Expand Down
80 changes: 28 additions & 52 deletions .github/workflows/bump.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Moves `package.json` and the lockfile to the version the next release will carry, every time
# something lands on `main` — and, when the notes for it are written, stamps the CHANGELOG and
# pushes the `vX.Y.Z` tag that `release.yml` has always listened for. `release.yml` is
# unchanged: it still runs the full gates, packages the `.vsix` and cuts the GitHub release.
# something lands on `main` — and, when the notes for it are written, stamps the CHANGELOG,
# pushes the `vX.Y.Z` tag and starts `release.yml` on it, which runs the full gates, packages
# the `.vsix` and cuts the GitHub release with the CHANGELOG's notes.
#
# ## The CHANGELOG is the release gate
#
Expand All @@ -18,39 +18,27 @@
# the merge that brings the notes may itself be 📝-only, so nothing bumps, and 0.3.1 would
# never go out.
#
# ## Which token pushes what, and why
# ## One token, and an explicit dispatch
#
# the bump/release commit → GITHUB_TOKEN (the credential actions/checkout persists)
# the vX.Y.Z tag → RELEASE_TOKEN (a PAT, from the secret of that name)
# the bump/release commit → pushed with GITHUB_TOKEN
# the vX.Y.Z tag → pushed with GITHUB_TOKEN
# release.yml → dispatched on that tag with GITHUB_TOKEN (`gh workflow run`)
#
# This split is the whole safety design, and it is worth not collapsing. GitHub deliberately
# starts no workflow run for a push made with `GITHUB_TOKEN`, so the commit this workflow
# makes on `main` cannot re-trigger this workflow — the loop cannot begin at its only possible
# source. The tag is the exception that has to be made with a PAT, precisely because it *must*
# trigger something: `release.yml`. That is safe because a tag push matches no trigger here —
# both `bump.yml` and `ci.yml` filter on `branches:`, which `refs/tags/…` never satisfies — so
# the one workflow the PAT can start is `release.yml`, and `release.yml` pushes nothing.
# `[skip ci]` in the commit body is the third layer, load-bearing only for whoever later
# decides the commit should also go up with the PAT. Do not make that change casually.
# GitHub starts no workflow run for a push made with `GITHUB_TOKEN`, so nothing this workflow
# pushes can start it again — the loop cannot begin. Releasing used to lean on the opposite: the
# tag went up with a PAT (`RELEASE_TOKEN`) precisely so that its push would start `release.yml`.
# It did not, for 0.5.0 nor for 1.0.0 — the tag arrived and no run followed — and a release that
# depends on a second credential staying valid fails silently the day it does not. So the tag
# push starts nothing, by design, and the release is started explicitly instead: a
# `workflow_dispatch` made with `GITHUB_TOKEN` is GitHub's documented exception and does start a
# run. Exactly one release run per tag, and no secret to keep alive. `release.yml` pushes
# nothing, so it starts nothing either.
#
# The commit is pushed *before* the tag, deliberately: `release.yml` re-checks the tag against
# `package.json` and refuses a mismatch, so the tagged commit has to be on `main` first. If the
# branch push loses a race to a merge that landed mid-run, it fails there and no tag is
# created — a failed run, never a half-released one, and the next merge recomputes and heals.
#
# ## The secret
#
# RELEASE_TOKEN A fine-grained PAT on this repository with **Contents: Read and write** —
# that single permission is all a tag push needs. (A classic PAT with `repo`
# works too; a GitHub App installation token is the tidier long-term answer,
# since it does not expire with the maintainer's account.) Settings → Secrets
# and variables → Actions → New repository secret.
#
# Unlike the publish steps in `release.yml`, an absent secret here is a hard failure rather
# than a skip, and only on the path that needs it: a run that bumped the version, stamped the
# changelog and then quietly declined to tag would leave the repository looking released when
# nothing was. Bumping without the secret is fine and keeps working.
#
# ## Which bump, from what
#
# `.github/scripts/next-version.sh` reads the gitmoji of every commit since the last `vX.Y.Z`
Expand Down Expand Up @@ -113,10 +101,8 @@ jobs:
!startsWith(github.event.head_commit.message, '🔖 Release '))
permissions:
contents: write
# Hoisted to job env for the same reason release.yml hoists its publish secrets: the
# `secrets` context in a step-level `if` is a workflow *parse* error, not a failed step.
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
# To dispatch release.yml on the tag it pushes.
actions: write
steps:
# The base is the last tag, so the tags have to be here — a shallow clone has none.
- uses: actions/checkout@v4
Expand Down Expand Up @@ -161,19 +147,6 @@ jobs:
release) echo "Releasing $NEXT: the notes are written." ;;
esac

# Before anything is written, not after: a run that bumped, stamped, and then declined to
# tag would leave the repository looking released when nothing was.
- name: Refuse to release without RELEASE_TOKEN
if: steps.plan.outputs.action == 'release' && inputs.dry_run != true
run: |
if [ -z "${RELEASE_TOKEN:-}" ]; then
echo "::error::The CHANGELOG says this is a release, but the RELEASE_TOKEN secret" \
"is not set, so the vX.Y.Z tag cannot be pushed with a token that triggers" \
"release.yml. Add a fine-grained PAT with Contents: Read and write as" \
"RELEASE_TOKEN, or empty ## [Unreleased] to hold the release back."
exit 1
fi

# `npm version` rewrites both manifests. The check below is what makes that a promise
# rather than an assumption: a release commit here has always carried both.
- name: Bump package.json and package-lock.json
Expand Down Expand Up @@ -242,19 +215,22 @@ jobs:
BRANCH: ${{ github.ref_name }}
run: git push origin "HEAD:$BRANCH"

# RELEASE_TOKEN, and only here. The empty `extraheader` resets the credential checkout
# persisted for github.com — git documents an empty value as the way to override an
# inherited header — so this one push authenticates as the PAT and therefore does what
# GITHUB_TOKEN cannot: trigger release.yml.
# GITHUB_TOKEN, like the commit: this push starts no run. The release is started on the
# next step, explicitly, so there is exactly one (see the header).
- name: Tag the release
if: steps.plan.outputs.action == 'release' && inputs.dry_run != true
env:
NEXT: ${{ steps.next.outputs.next }}
run: |
git tag --annotate "v$NEXT" --message "Release $NEXT"
git -c http.https://github.com/.extraheader= \
push "https://x-access-token:$RELEASE_TOKEN@github.com/$GITHUB_REPOSITORY.git" \
"refs/tags/v$NEXT"
git push origin "refs/tags/v$NEXT"

- name: Start the release on the tag
if: steps.plan.outputs.action == 'release' && inputs.dry_run != true
env:
GH_TOKEN: ${{ github.token }}
NEXT: ${{ steps.next.outputs.next }}
run: gh workflow run release.yml --ref "v$NEXT"

- name: Say what happened
env:
Expand Down
23 changes: 21 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,23 @@
# Eclipse Foundation Publisher Agreement). Open VSX serves VSCodium, Cursor,
# Windsurf and Gitpod — worth publishing to when releases are frequent.
#
# Run it by pushing a tag: `git tag v0.1.0 && git push origin v0.1.0`.
# `bump.yml` starts it: it pushes the `vX.Y.Z` tag and then dispatches this workflow on that tag
# (`workflow_dispatch`, ref = the tag). The dispatch is what starts it, not the tag push: a push
# made with the workflow's own `GITHUB_TOKEN` starts no run — that is how 0.5.0 and 1.0.0 were
# tagged and never released — but a `workflow_dispatch` made with it does, by GitHub's own
# exception. By hand, either works: `git push origin v0.1.0` (a push of yours starts it), or
# Actions → release → Run workflow, choosing the tag.
#
# The release notes are the CHANGELOG's section for the version (`changelog.sh notes`), the
# hand-written ones the release was gated on — never GitHub's generated list of pull requests.

name: release

on:
push:
tags: ['v*']
# Run on a tag ref: the tag check below refuses any other.
workflow_dispatch:

jobs:
publish:
Expand Down Expand Up @@ -57,6 +67,11 @@ jobs:
- name: Package
run: npm run package

# Before any publish, not after: a version with no notes must stop here, not once the
# Marketplace already carries it.
- name: Write the release notes from the CHANGELOG
run: .github/scripts/changelog.sh notes "${GITHUB_REF_NAME#v}" >release-notes.md

# Skipped, not failed, while the secret is absent — publishing is manual until then (see
# the header). A skip keeps the GitHub release below happening; `continue-on-error` here
# would instead hide a real publish failure on the day the secret exists.
Expand All @@ -74,5 +89,9 @@ jobs:
- name: Attach the .vsix to the GitHub release
uses: softprops/action-gh-release@v2
with:
# Explicit, because a dispatched run's ref is the tag but softprops reads the tag name
# from a push event's ref.
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
files: '*.vsix'
generate_release_notes: true
body_path: release-notes.md
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ idea; spanning several files and layers is normal when they only make sense toge
**UI changes** (anything under `src/webview/`) should describe the before/after in the PR.
Screenshots or GIFs are welcome but optional — a clear text description is fine.

**Every PR starts from the template** (`.github/pull_request_template.md`): what and why, how
to test, and a two-line checklist, with the title rule as a reminder at the top.

## Code conventions

The extension is provider-agnostic by design. When extending it, follow these rules; several
Expand Down
83 changes: 83 additions & 0 deletions test/release-workflows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'

// 0.5.0 and 1.0.0 were tagged by `bump.yml` and never released: the tag push did not start
// `release.yml` (a push GitHub attributes to the workflow's own token starts no run), and the
// release, when cut by hand, had GitHub's generated notes instead of the changelog's. These pin
// the fix where it lives: the release is dispatched explicitly, and its notes are the
// changelog's section for the version.

const root = join(__dirname, '..')
const script = join(root, '.github', 'scripts', 'changelog.sh')
const workflow = (name: string) => readFileSync(join(root, '.github', 'workflows', name), 'utf8')

const CHANGELOG = `# Changelog

## [Unreleased]

## [1.0.0] - 2026-10-21

### Added

- One thing.

### Fixed

- Another.

## [0.5.0] - 2026-10-20

- Older.
`

function notes(version: string): string {
const dir = mkdtempSync(join(tmpdir(), 'ptb-changelog-'))
const file = join(dir, 'CHANGELOG.md')
writeFileSync(file, CHANGELOG, 'utf8')
return execFileSync('bash', [script, 'notes', version], {
env: { ...process.env, CHANGELOG_FILE: file },
encoding: 'utf8',
})
}

describe('the release notes', () => {
it('are the changelog section of that version, and nothing of the next', () => {
expect(notes('1.0.0')).toBe('### Added\n\n- One thing.\n\n### Fixed\n\n- Another.\n')
expect(notes('0.5.0')).toBe('- Older.\n')
})

// [trigger branch] A version with no section must fail the release, not publish empty notes.
it('refuse a version the changelog does not hold', () => {
expect(() => notes('9.9.9')).toThrow()
})
})

describe('cutting a release', () => {
it('dispatches release.yml on the tag instead of relying on the tag push', () => {
const bump = workflow('bump.yml')
expect(bump).toMatch(/gh workflow run release\.yml --ref "v\$NEXT"/)
expect(bump).toMatch(/actions: write/)
// No second token to keep alive: the workflow's own pushes the tag and dispatches the run.
expect(bump).not.toMatch(/secrets\.RELEASE_TOKEN/)
})

it('can be dispatched, and publishes the changelog’s notes', () => {
const release = workflow('release.yml')
expect(release).toMatch(/workflow_dispatch:/)
expect(release).toMatch(/changelog\.sh notes/)
expect(release).toMatch(/body_path: release-notes\.md/)
expect(release).not.toMatch(/generate_release_notes: true/)
})

// A version with no notes must fail before anything is published, not after.
it('writes the notes before publishing anywhere', () => {
const release = workflow('release.yml')
const notesAt = release.indexOf('changelog.sh notes "')
expect(notesAt).toBeGreaterThan(-1)
expect(notesAt).toBeLessThan(release.indexOf('vsce publish'))
expect(notesAt).toBeLessThan(release.indexOf('ovsx publish'))
})
})
Loading