Skip to content

Publish releases from a manual pipeline - #552

Open
jgfoster wants to merge 6 commits into
mainfrom
feature/release-workflow
Open

Publish releases from a manual pipeline#552
jgfoster wants to merge 6 commits into
mainfrom
feature/release-workflow

Conversation

@jgfoster

@jgfoster jgfoster commented Sep 3, 2026

Copy link
Copy Markdown
Member

Follows the review suggestion on the release instructions: make publishing something a (manual) pipeline can run.

Stacked on #551. Based on docs/publishing-a-release because it rewrites the document that PR adds, so it targets that branch rather than main. Merge #551 first and this retargets to main cleanly.

The split

The version bump and the changelog stay human, as you guessed — promoting [Unreleased] to a dated section, and sweeping main for changes that never wrote an entry, are editorial calls. Everything after that is mechanical:

  1. Release PR (human) — npm version, changelog promotion and sweep. Merges through the queue like anything else.
  2. Release workflow (workflow_dispatch) — verify, package, publish, wait, tag, announce. Only ever publishes a commit already on main and already green.

Inputs are version, ref (default main), and dry-run, which runs every check and builds the .vsix without touching either registry.

What it refuses to do

Pre-flight, before any credential is touched or any reviewer is asked to approve: the ref must be an ancestor of main; package.json must be at the requested version; CHANGELOG.md must have a dated ## [X.Y.Z] - YYYY-MM-DD section; no vX.Y.Z tag may exist; and the newest ci-complete check run on that exact commit must have concluded success.

The release deliberately does not re-run lint/compile/test. The merge queue already ran them on that commit across both platforms and every GemStone version in .gemstone-integration-releases.json, plus the Node floor, and ran npm run package — a stronger gate than one run here could be. What matters is that it passed, so the workflow checks that instead of re-deriving it.

Two things the pipeline does that the manual process could not

One artifact, everywhere. The .vsix is built once and published as-is to both registries via --packagePath, then attached to the GitHub Release. The artifact you can download from the run, the two the registries serve, and the one on the Release are the same bytes. Run by hand, each publish command repackages from source, so the local .vsix is not what gets uploaded.

It waits. Both CLIs print success when the upload is accepted, but the version takes ~2–22 minutes to become queryable and either registry can lead — this cost a misdiagnosis during 1.8.11. scripts/await-published-version.sh polls both and states plainly that a timeout is propagation, not a failed publish, so re-publishing is not the fix.

Tagging happens only after both registries are serving the version, so a failed or half-finished publish leaves no tag to clean up and the run can just be repeated; --skip-duplicate on both commands makes that safe.

scripts/changelog-section.sh is used twice — as the pre-flight guard and as the source of the Release notes — so the check and the published text cannot disagree about which section belongs to a version.

Needs one-time setup before the first real run

The workflow is inert until this exists:

  1. A release environment (Settings → Environments).
  2. Required reviewers on it. This is what makes the approval step a real gate and where the audit trail of who released what comes from. Without it, anyone who can run a workflow can publish.
  3. VSCE_PAT and OVSX_PAT as environment secrets, not repository secrets, so no other workflow can reach them.

Worth flagging explicitly, since it's a governance change rather than a mechanical one: this repo currently stores no third-party secrets — only the automatic GITHUB_TOKEN. These would be the first, and because Azure DevOps PATs are personal and expire (a year at most), they are one person's identity acting for the org and they expire silently. The workflow verifies both before building anything, so an expired token fails in seconds rather than halfway through, but they still need an owner who rotates them.

Also new: the repo has no GitHub Releases today. This starts creating them, with the changelog section as the notes and the .vsix attached. Easy to drop that step if you'd rather not adopt the convention.

Releasing by hand stays documented as a fallback — the pipeline is the normal path, not the only one.

Verification

Checked against the same three gates CI applies, all clean on the new files: scripts/lint-workflow-timeouts.sh, actionlint (with shellcheck on every run: block and both new scripts), and zizmor — no ${{ }} reaches a run: script, actions are pinned to SHAs, persist-credentials: false throughout (the tag is created through the API rather than pushed), and permissions are contents: read at the top with contents: write only on the publish job.

Both scripts were exercised against the live registries and the real changelog: changelog-section.sh extracts 1.8.14, stops at the next heading, and rejects an unpromoted version; await-published-version.sh confirms 1.8.14 on both registries and produces the propagation guidance on timeout. The ci-complete selection was checked against a real commit carrying two runs.

lint, format:check, compile and npm test pass (client 452 files / 7294 tests, server 322, mcp-server 92).

The workflow itself cannot be end-to-end tested without publishing a version; dry-run exists so its first real exercise costs nothing.

🤖 Generated with Claude Code

Comment thread .github/workflows/release.yml Fixed
James Foster and others added 3 commits September 3, 2026 10:26
Adds a workflow_dispatch Release workflow, so publishing is a reviewed,
audited pipeline run rather than a sequence of local commands against one
maintainer's personal tokens.

The split is deliberate. The version bump and the changelog stay a
human-authored release PR: promoting [Unreleased] to a dated section, and
sweeping main for changes that never wrote an entry, are editorial calls a
pipeline cannot make. Everything after that is mechanical, and the workflow
publishes only a commit that is already on main and already green.

Pre-flight, before any credential is touched or any reviewer is asked to
approve: the ref must be an ancestor of main, package.json must be at the
requested version, CHANGELOG.md must have a dated section for it, no tag
may exist yet, and the newest ci-complete check run on that exact commit
must have concluded success. The release deliberately does not re-run
lint/compile/test -- the merge queue already ran them on this commit across
both platforms and every supported GemStone version, which is a stronger
gate than one run here could be -- so what matters is that it passed.

Publishing then runs under a `release` environment, which is what puts the
tokens out of reach of every other workflow and makes approval a real gate
with an audit trail. Requires one-time setup: the environment, its required
reviewers, and VSCE_PAT / OVSX_PAT as environment secrets.

Two things the pipeline does that the manual process could not:

- The .vsix is built once and published as-is to both registries via
  --packagePath, then attached to the GitHub Release. The artifact on the
  run, the two the registries serve, and the one on the Release are the
  same bytes. Run by hand, each publish command repackages from source, so
  the local .vsix is not what gets uploaded.

- It waits for both registries to actually serve the version. Both CLIs
  print success when the upload is accepted, but the version takes ~2-22
  minutes to become queryable and either registry can lead; this cost a
  misdiagnosis during 1.8.11. scripts/await-published-version.sh polls
  both, and says plainly that a timeout is propagation rather than a failed
  publish, so re-publishing is not the fix.

Tagging happens only once both registries are serving the version, so a
failed or half-finished publish leaves no tag to clean up and the run can
simply be repeated; --skip-duplicate on both commands makes that safe.

scripts/changelog-section.sh is used twice -- as the pre-flight guard and
as the source of the GitHub Release notes -- so the check and the published
text can never disagree about which section belongs to a version.

Releasing by hand remains documented as a fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pinned SHA is upload-artifact v7.0.1, as health-check.yml already
labels it; the comment said v5.0.0. zizmor's ref-version-mismatch audit
resolves the tag over the network, so it fails only in online mode --
which is how this passed a local `--no-online-audits` run and failed CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CodeQL flagged the pre-flight job for executing untrusted code: it checked
out `inputs.ref` and then ran scripts/changelog-section.sh from it, in a
privileged workflow. The ancestry check did run first, so the executed
script was always merged main history -- but the unverified tree was on
the runner before anything vetted it, and the ordering was the only thing
standing between a dispatch-time ref and code execution.

Move every check that an API can answer ahead of the checkout: resolve the
ref and require `compare/main...<sha>` to report identical or behind, then
require the tag to be free and ci-complete to have passed -- all over the
REST API, no working tree. Only then is the commit checked out, and only
then are package.json and CHANGELOG.md read from it. Verified against real
data: main's tip reports identical, an ancestor reports behind, and an
unmerged branch reports diverged and is refused.

Also drop `cache: 'npm'` from the publish job. A cache write from a job
that checks out a dispatch-time ref is a cache-poisoning vector against
the default branch's own builds, and a release runs a few times a month;
the saved minute is not worth the extra surface on the one job that holds
publish credentials.

The tag check no longer needs local tags and the ancestry check no longer
needs local branches, so the checkout drops to its default shallow depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jgfoster
jgfoster force-pushed the feature/release-workflow branch from edfe089 to acfd283 Compare September 3, 2026 17:27
Comment thread .github/workflows/release.yml Fixed
James Foster and others added 2 commits September 3, 2026 10:35
Verifying the ref before checking it out was not enough for CodeQL's
cache-poisoning audit, and on reflection it was not enough full stop. The
audit's point is that this workflow runs in the default branch's context,
with that context's cache and token, so *any* execution of code from a
dispatch-time ref is a cache-write vector -- declaring no cache action
does not help, because the runner's cache credentials are in the
environment regardless. The ancestry gate made the executed code merged
main history, but the gate was mine to get right, and it is not something
the audit can see or a reader can verify at a glance.

So pre-flight no longer checks anything out: it now has no `uses:` at all.
package.json and CHANGELOG.md are read through the contents API and
treated strictly as data -- jq and grep, never executed. Reading the
content of an arbitrary commit is safe; running it is not, and there is
now no repository code on this runner to run.

The changelog check narrows to "the dated heading exists", which is all a
grep can assert. scripts/changelog-section.sh still produces the release
notes in the publish job, which does check the commit out -- behind the
`release` environment's approval -- and still fails if the section is
empty, so the guard cannot pass a section the notes step would reject.

Escapes the dots in the version before it reaches grep -E, so 1.8.1
cannot match a heading for 1_8_1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base automatically changed from docs/publishing-a-release to main September 5, 2026 00:56
Brings in #551, so this branch's own commits now sit on the released
version of the publishing document they extend.

CHANGELOG.md was the only conflict, and a structural one: 33cab52
consolidated the [Unreleased] section's duplicated `### Changed` heading
by moving the top block down, while main independently added entries to
that same block and reworded others in it. Git cannot reconcile a block
moved with a block edited, so it presented the whole block as ours-empty
against theirs-entire.

Resolved as the union of both sides -- taking main's wording throughout,
since it is newer (it drops Register Local Version from the Versions
bullet because main removes that feature and documents the removal
separately, and it renames the search command), and keeping the
consolidated single `### Changed`.

Also folds the #262 and #515 breakpoint-gutter entries together. #262 put
the gutter on the one shared language and withdrew a breakpoint after the
click, withholding the debugger frame source's mime type -- and its
highlighting -- to keep the gutter away. #515 gave method editors their
own language id so the offer is never made, which let that mime type come
back; the withdrawal survives only as a backstop for
allowBreakpointsEverywhere and non-VS Code DAP clients. Since breakpoints
are new in this release, neither entry described a change anyone could
perceive: the placement rule moved into the `### Added` breakpoints entry,
and `### Changed` now carries only what genuinely breaks for an existing
user -- a `[gemstone-smalltalk]` settings block no longer reaching method
source, and the `source.gemstone-smalltalk` parent-scope caveat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants