diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2a3a063 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ + + +## What and why + + + +## How to test + + + +## Checklist + +- [ ] Tests included, and `npm test` / `npm run format:check` pass +- [ ] Player-visible change: notes added under `## [Unreleased]` in `CHANGELOG.md` diff --git a/.github/scripts/changelog.sh b/.github/scripts/changelog.sh index 39b5582..8bf8bb1 100755 --- a/.github/scripts/changelog.sh +++ b/.github/scripts/changelog.sh @@ -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 print the section of `## []`, 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 @@ -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 diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index df46049..f284cae 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -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 # @@ -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` @@ -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 @@ -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 @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9645b88..5b03ff6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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. @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69f46ac..5d860ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/test/release-workflows.test.ts b/test/release-workflows.test.ts new file mode 100644 index 0000000..be574dc --- /dev/null +++ b/test/release-workflows.test.ts @@ -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')) + }) +})