Skip to content

feat: monorepo release pipeline (release-it + GitHub Release assets) - #25

Open
nsheaps-oura wants to merge 9 commits into
mainfrom
nate-ai/monorepo-release-pipeline
Open

feat: monorepo release pipeline (release-it + GitHub Release assets)#25
nsheaps-oura wants to merge 9 commits into
mainfrom
nate-ai/monorepo-release-pipeline

Conversation

@nsheaps-oura

@nsheaps-oura nsheaps-oura commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Supersedes PR #7 (ignored per direct instruction — see below for what wasn't lost). Builds the release/publish pipeline the task actually asked for: single repo-wide release-it versioning, a GitHub Release per version, and each opted-in package's compiled userscript uploaded as a release asset that GreasyFork-independent auto-update can point at.

Fork 1 resolution (versioning): single repo-wide version

Researched live (gh search code across the org, an existing internal patterns doc, and direct inspection of nsheaps/agents, the org's actual Nx/yarn monorepo) — every nsheaps repo using release-it uses ONE repo-wide .release-it.json/tag/changelog/release, including agents itself. There's no established independent-per-package pattern anywhere in the org to copy, so this repo now matches that convention. Removed the unused @changesets/cli scaffold (no .changeset/ config ever existed) in favor of it.

Fork 2 resolution (distribution): GitHub Releases directly, skip GreasyFork's own update infra

Confirmed live via GreasyFork's own docs/discussions and our existing listings: GreasyFork forcibly rewrites @downloadURL/@updateURL for any script actually listed on its site, so pointing those at a GitHub Releases URL only works for scripts not relying on GreasyFork's own update mechanism. Both public packages now point @downloadURL/@updateURL at https://github.com/nsheaps/greasemonkey-scripts/releases/latest/download/<package>.user.js — verified live that this exact URL shape 302-redirects to the correct latest-tagged asset (tested against a real existing nsheaps release). The existing GreasyFork listings (509840, 509841) are untouched by this — no retroactive effect on anyone who already installed from there.

Per-package opt-in

A package sets "greasyforkPublish": true in its own package.json to get its dist/script.user.js uploaded to releases. github-actions-grafana-jump does not set it — its header already documents it as intentionally internal-only, and this preserves that.

Pipeline

New .github/workflows/release.yaml, triggered on push to main: build all packages → commit regenerated dist/*.user.js if changed (same idea as #58, redesigned into one job instead of a separate workflow to avoid racing release-it's own commit+tag) → release-it --ci (no-ops if there are no releasable commits since the last tag) → if a release was actually cut, upload each opted-in package's asset.

ci.yml is functionally unchanged (yarn run build is still the required correctness gate for every push/PR) — pinned actions/checkout/actions/setup-node to hashes while touching the file, since they were on floating major-version tags.

Gaps formalized while verifying current repo state

  • github-to-graphite-button never had the copy-and-strip-use-strict build step graphite-to-github-button already had, so it never actually produced a dist/script.user.js — added the same step + matching tsconfig.json comments.
  • github-actions-grafana-jump's "types": ["node", "greasemonkey"] override was silently relying on @types/node being present only as an accidental transitive dependency of the now-removed @changesets/cli — added it as an explicit devDependency.
  • Pinned a freshly-resolved transitive dep (yoctocolors, via release-it) to 2.1.2, matching what's already vetted/used elsewhere in the org, after a fresh resolution pulled 2.2.0 and got blocked by a local supply-chain minimum-package-age check.

On PR #7

Ignored its approach per instruction, but checked what it actually contained first: the one fix not already superseded elsewhere — graphite-to-github-button's TypeScript source restoration — already landed on main via #23's merge, so nothing real is lost.

#7 will need to be closed manually alongside this PR's merge — checked live via GitHub's GraphQL API (willCloseTarget on the cross-reference event this PR's #7 mention creates) rather than trusting GitHub's own prose docs, which read as if a closing keyword works the same for a referenced PR as for a referenced Issue. It doesn't: willCloseTarget came back false for this exact PR-to-PR reference, confirming closing keywords (Closes/Fixes/Resolves) only auto-close Issues, not Pull Requests, regardless of wording. No keyword added here since one wouldn't do anything — close #7 by hand once this PR merges.

Known open risk (not resolved here — task #45)

main's live require-pr ruleset hasn't been confirmed to actually grant the automation app's settings.yml-declared bypass yet. release.yaml's pushes (the dist-regen commit and release-it's own commit+tag) depend on that bypass working. If it doesn't, this pipeline's pushes will fail the same way the pre-checkout-as-app version of the old workflow did. No branch/PR fallback is implemented here (unlike the narrower workflow this supersedes) since a release run that can't land on main isn't meaningfully retryable via a side branch.

Test plan

  • Full clean nx run-many --target=build --all succeeds for all 5 packages under the current toolchain (TS 6.0.3, nx 23.0.2)
  • Both public packages' dist/script.user.js start with the UserScript header (use-strict-stripping still works) and carry the new @downloadURL/@updateURL
  • oxlint passes (exit 0) for all 5 packages; github-actions-grafana-jump's 14 existing unit tests pass
  • release-it --ci --dry-run runs non-interactively end-to-end: correctly computes v0.1.0, generates a real conventional-commits CHANGELOG.md from actual repo history, and shows the exact add/commit/tag/push sequence + GitHub Release URL it would hit — this is what release.yaml actually runs, so it's a genuine dry run of the real pipeline
  • Verified live against a real nsheaps GitHub release that .../releases/latest/download/<asset> 302-redirects correctly
  • CI green on this PR (will confirm once checks run)
  • A real release actually landing on main (can't verify pre-merge — see known risk above)

🤖 Generated with Claude Code

Ignores PR #7's approach per direct instruction; replaces it with a
release-based publish pipeline instead of a bot-commit-only one.
PR #7's one fix not already on main (graphite-to-github-button's
TypeScript source restoration) landed separately via #23's merge, so
nothing from it is lost.

Versioning (fork 1, per team-lead's resolution): single repo-wide
release-it version/tag/changelog/GitHub-Release, matching the
convention used by every other nsheaps repo (dotfiles, git-wt,
claude-utils, op-exec, agents, ...) - researched live via
`gh search code`, an existing internal patterns doc, and direct
inspection of nsheaps/agents (the org's actual Nx/yarn monorepo).
No nsheaps repo has genuinely independent per-package release
tags/changelogs to copy, so this is the established pattern, not a
per-package one. Removes the unused, unconfigured @changesets/cli
scaffold (no .changeset/ dir ever existed) in favor of release-it.

Distribution (fork 2, per team-lead's resolution): skips GreasyFork's
own hosting/update infra (confirmed live via GreasyFork's own docs and
our existing listing that GreasyFork forcibly rewrites @downloadURL/
@updateURL for any script listed on its site, so pointing those at
GitHub directly only works for scripts NOT relying on GreasyFork's own
update mechanism). Both public packages' @downloadURL/@updateURL now
point at `.../releases/latest/download/<package>.user.js` - verified
live against a real nsheaps release (git-wt) that this URL shape
302-redirects to the actual latest-tagged asset. Existing GreasyFork
listings (509840, 509841) are untouched; this doesn't retroactively
affect users who already installed from there.

Per-package opt-in: `"greasyforkPublish": true` in a package's own
package.json controls whether its dist/script.user.js gets uploaded to
the GitHub Release. github-actions-grafana-jump does NOT set it - its
header already says it's intentionally internal-only, and this
preserves that.

Pipeline (.github/workflows/release.yaml, triggered on push to main):
build all packages -> commit regenerated dist/*.user.js if changed
(generalizes the same idea #58 built, redesigned as one job instead of
a separate workflow to avoid racing release-it's own commit+tag) ->
release-it --ci (no-ops if there are no releasable commits since the
last tag) -> if a release was actually cut, upload each opted-in
package's dist/script.user.js, renamed to <package>.user.js, to that
release.

ci.yml unchanged in spirit (yarn run build is still the required
per-PR/per-push correctness gate - a build/typecheck failure already
fails this check) - pinned actions/checkout and actions/setup-node to
hashes while touching this file, since they were on floating major-
version tags.

Formalized gaps found in the existing packages while verifying current
state per the task's own instruction:
- github-to-graphite-button never had the copy-and-strip-use-strict
  build step graphite-to-github-button already had, so it never
  produced a dist/script.user.js at all - added the same step.
- Both packages' tsconfig.json gained the same sourceMap:false +
  explanatory comments graphite-to-github-button already carried from
  earlier work, now that github-to-graphite-button needs the same
  reasoning documented.
- github-actions-grafana-jump's "types": ["node", "greasemonkey"]
  override (needed under this repo's moduleResolution: bundler
  setting) was silently relying on @types/node being present only as
  an accidental transitive dependency of the now-removed
  @changesets/cli - added @types/node as an explicit devDependency.
- Pinned a freshly-resolved transitive dependency (yoctocolors, pulled
  in by release-it) to the same 2.1.2 already vetted/used elsewhere in
  the org, after a fresh resolution pulled in 2.2.0 and got blocked by
  a local supply-chain safe-chain minimum-package-age check.

Verified live, not from memory:
- Full clean `nx run-many --target=build --all` succeeds for all 5
  packages under the current toolchain (TS 6.0.3, nx 23.0.2).
- Both public packages' dist/script.user.js start with the UserScript
  header (use-strict-stripping still works) and carry the new
  @downloadURL/@updateURL.
- oxlint passes (exit 0) for all 5 packages; github-actions-grafana-
  jump's 14 existing unit tests pass.
- `release-it --ci --dry-run` runs non-interactively end-to-end:
  correctly computes v0.1.0 (no existing tags), generates a real
  conventional-commits CHANGELOG.md grouped by type from actual repo
  history, and shows the exact git add/commit/tag/push sequence and
  GitHub Release URL it would execute - this is what release.yaml
  actually runs in CI (release:ci), so this is a genuine dry run of the
  real pipeline, not a hand-simulated approximation.

Known open risk, not resolved here (task #45): main's live `require-pr`
ruleset hasn't been confirmed to actually grant the automation app's
settings.yml-declared bypass yet. release.yaml's pushes (both the
dist-regen commit and release-it's own commit+tag) depend on that
bypass actually working; if it doesn't, this pipeline's pushes to main
will fail the same way regen-dist.yaml's did before checkout-as-app
was added. No branch/PR fallback is implemented for this workflow
(unlike the narrower one this supersedes) since a release run that
can't land on main isn't meaningfully retryable via a side branch.

Not done here, left for a human call per the task's own instruction:
formally closing PR #7 - it should be closed with an explanatory
comment once this PR is confirmed to actually supersede it, not
unilaterally before that's certain.
User's explicit call: dist/ should never be committed to the repo at
all - it's a purely transient build artifact from now on, attached
directly to GitHub Releases as its only distribution mechanism.

release.yaml changes:
- Removed the "stage regenerated userscripts if any changed" step
  (git add/commit/push of dist/*.user.js) entirely.
- Reordered: release-it now runs FIRST (deciding whether there's
  anything releasable at all), and the build only happens
  conditionally, after, only when a release was actually cut - no
  wasted build when there's nothing to release.
- The asset-upload step already read from the job's own freshly-built
  dist/ (never a committed file), so no change needed there beyond the
  reordering.

Also untracked the two dist/script.user.js files this repo had
force-committed historically (graphite-to-github-button, and
github-to-graphite-button as added by this same PR) - both are now
build-only like every other package's dist output, consistent with
the release-only model. Nothing regenerates or re-commits them going
forward.

This removes the need for bypass-actor coverage on a dist-commit step
entirely. release-it's own version-bump commit+tag still needs to push
straight to protected main as the automation bot - that bypass
question is being handled separately at the org level (task #103,
fixing it at the nsheaps/.github settings-sync source rather than
patching the live ruleset directly), not re-litigated in this PR.

Verified: full clean build still succeeds (Nx cache correctly restores
identical output after deleting dist/ locally - checked content, not
just cache-hit claim); `git status` confirms dist/ stays untracked
after a fresh build; `release-it --ci --dry-run` re-run shows the
changeset now contains only the intended file changes (workflow +
the two dist untrackings), with no dist noise going forward once this
commit lands.
…plate

packages/_template and packages/template were near-identical scaffolds, both
introduced by the same initial commit (7ff42bb) and both undocumented. Keep
packages/template: ci.yml already refers to it by that exact name as the
package whose placeholder `exit 1` test script keeps a repo-wide test gate
from being wired up yet.

Refs #25
typescript was pinned identically (~6.0.3) in every package as well as the
root. Yarn workspace hoisting already makes the root copy resolvable from
every package, so the per-package entries were redundant duplication that had
to be bumped in lockstep. Genuinely package-specific devDependencies
(@types/greasemonkey, @types/node) are left alone.

The two greasyforkPublish packages' copies are removed in the following
commit, which rewrites those same package.json files' build scripts.

Refs #25
…"use strict"

Each userscript's metadata block moves out of the top of src/index.ts and into
a per-package src/meta.json. scripts/build-userscript.mjs renders the
`// ==UserScript==` block from it - taking @Version from the package's own
package.json - and prepends it to the tsc-compiled dist/index.js to produce
dist/script.user.js.

This replaces the Node one-liner that string-stripped tsc's leading
`"use strict";` line. That prologue only needed removing because the header
was committed inside the TS source and so ended up one line below the top of
the file; real Greasemonkey silently ignores a metablock that isn't the
literal first thing in the file. Generating the header post-compile makes it
unconditionally first and lets tsc's prologue simply land underneath it.
Switching tsc to module mode was the other option and is not viable: it emits
a bare `export {};` marker, a SyntaxError once a userscript manager injects
the file as a classic script.

package.json is the only source of the version, so the builder rejects a
meta.json that declares one. nx's build inputs gain package.json and the
builder itself, so a version bump or a change to the renderer invalidates the
cached build rather than re-emitting a stale header.

Also drops the now-redundant per-package typescript devDependency from these
two packages (see previous commit) and rewrites the tsconfig comments, whose
"use strict"-stripping rationale no longer applies; the sourceMap: false
rationale still does and is kept.

Refs #25
…release-it

Replaces the single repo-wide release-it config with a shared
.release-it.base.json plus a per-package .release-it.js in each
greasyforkPublish package, so each script owns its own version and its own
CHANGELOG.md instead of sharing one repo-wide version and changelog.

Each package's package.json is now the single source of truth for its version.
The base config uses release-it's native npm plugin with
`npm: { publish: false }` rather than @release-it/bumper - bumper is only
needed when the version lives in a non-package.json manifest, which is why the
marketplace repos this pattern came from need it and we don't. Note the old
root config set `npm: false`, which disables the npm plugin outright including
version bumping; that is why the repo-wide version was never actually bumped
and releases were driven purely by git tags.

The conventional-changelog preset carries over unchanged, with
gitRawCommitsOpts.path scoping each changelog to commits that touched that
package - otherwise every package's CHANGELOG would list the whole monorepo's
history. Committing, tagging and pushing are disabled per package; the release
workflow aggregates them into one commit and one push.

github-actions-grafana-jump is internal-only (no greasyforkPublish field) and
deliberately gets none of this.

Refs #25
Adds a root mise.toml pinning node 24.18.0 (matching .nvmrc and
package.json's engines.node) and yarn 4.17.0 (matching package.json's
packageManager), so a local checkout and CI resolve the same toolchain from
one file. jq is pinned too - scripts/auto-bump-packages.sh uses it to build
its JSON summary, and pinning it here means local runs match the runners'
preinstalled copy.

CI swaps actions/setup-node for jdx/mise-action, which also removes the
separate corepack enable/install step since mise provisions yarn directly.

Refs #25
…on PRs

Rewrites release.yaml around scripts/auto-bump-packages.sh, which patch-bumps
each greasyforkPublish package whose own files changed since a base ref and
preserves any package a human already bumped higher. Change detection excludes
each package's package.json and CHANGELOG.md so a bump commit cannot
re-trigger a bump for itself.

On a pull request the script runs with --preview (arithmetic next-patch, no
release-it invocation, no writes) and posts a sticky Package/Base/New/Action
table. Nothing is committed to the PR branch.

On push to main a single job bumps, lints, commits once, builds, and does one
atomic push of the bump commit plus the release tags. Doing all of it in one
push is what prevents a bump from triggering a follow-up lint commit that
would trigger another bump. Change detection uses a moving release/last-run
tag, which replaces the old before/after `git describe` comparison - that only
worked when a single repo-wide tag was cut per run.

Releases stay one-per-run rather than one-per-package. Every script's
@downloadurl points at the stable releases/latest/download/<name>.user.js
path, so per-package tags would make "latest" ambiguous and leave some
scripts' URLs resolving to a release with no asset for them; one release per
run carries every bumped script's asset. Per-package provenance lives in each
package's own version and CHANGELOG.md. This judgement call is documented
inline in the workflow.

Refs #25
The README still documented Changesets, which this repo has not used since it
moved to release-it, and an nvm/corepack setup that mise now replaces.
Documents how to add a new script (copy packages/template, which was
previously undocumented), where a script's UserScript metadata now lives, and
how per-package versioning and releases work.

Refs #25
@github-actions

Copy link
Copy Markdown

Userscript Version Preview

Preview only — versions and CHANGELOGs are bumped automatically on merge to main, not in this PR. Manual bumps to a higher version are preserved.

Package Base New Action
github-to-graphite-button 0.3.1 0.3.2 will-bump
graphite-to-github-button 0.3.3 0.3.4 will-bump

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.

1 participant