Publish releases from a manual pipeline - #552
Open
jgfoster wants to merge 6 commits into
Open
Conversation
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
force-pushed
the
feature/release-workflow
branch
from
September 3, 2026 17:27
edfe089 to
acfd283
Compare
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows the review suggestion on the release instructions: make publishing something a (manual) pipeline can run.
The split
The version bump and the changelog stay human, as you guessed — promoting
[Unreleased]to a dated section, and sweepingmainfor changes that never wrote an entry, are editorial calls. Everything after that is mechanical:npm version, changelog promotion and sweep. Merges through the queue like anything else.Releaseworkflow (workflow_dispatch) — verify, package, publish, wait, tag, announce. Only ever publishes a commit already onmainand already green.Inputs are
version,ref(defaultmain), anddry-run, which runs every check and builds the.vsixwithout 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.jsonmust be at the requested version;CHANGELOG.mdmust have a dated## [X.Y.Z] - YYYY-MM-DDsection; novX.Y.Ztag may exist; and the newestci-completecheck run on that exact commit must have concludedsuccess.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 rannpm 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
.vsixis 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.vsixis 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.shpolls 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-duplicateon both commands makes that safe.scripts/changelog-section.shis 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:
releaseenvironment (Settings → Environments).VSCE_PATandOVSX_PATas 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
.vsixattached. 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(withshellcheckon everyrun:block and both new scripts), andzizmor— no${{ }}reaches arun:script, actions are pinned to SHAs,persist-credentials: falsethroughout (the tag is created through the API rather than pushed), and permissions arecontents: readat the top withcontents: writeonly on the publish job.Both scripts were exercised against the live registries and the real changelog:
changelog-section.shextracts1.8.14, stops at the next heading, and rejects an unpromoted version;await-published-version.shconfirms1.8.14on both registries and produces the propagation guidance on timeout. Theci-completeselection was checked against a real commit carrying two runs.lint,format:check,compileandnpm testpass (client 452 files / 7294 tests, server 322, mcp-server 92).The workflow itself cannot be end-to-end tested without publishing a version;
dry-runexists so its first real exercise costs nothing.🤖 Generated with Claude Code