diff --git a/.github/workflows/regen-userscripts.yaml b/.github/workflows/regen-userscripts.yaml new file mode 100644 index 0000000..6c67f6b --- /dev/null +++ b/.github/workflows/regen-userscripts.yaml @@ -0,0 +1,144 @@ +name: Regenerate Compiled Userscripts + +# Keeps every package's committed dist/*.user.js in sync with its TypeScript +# source. dist/ is gitignored workspace-wide, but each package that publishes +# a userscript force-commits its dist/*.user.js because Greasy Fork's sync +# mechanism points at a stable raw.githubusercontent.com URL for it — see +# graphite-to-github-button's history (4fc2806 renamed the raw .ts source +# straight to this path without ever compiling it; this workflow exists so +# that class of bug can't silently recur, now for every package, not just +# that one). +# +# This repo is an Nx monorepo (yarn workspaces "packages/*", nx.json's +# "build" target cached per-project). Rather than hardcoding one package's +# build command, this always builds every project (`yarn run build`, i.e. +# `nx run-many --target=build --all` — the same command ci.yml already +# runs) and then stages whatever dist/*.user.js artifacts actually changed. +# We intentionally skip `nx affected` here: computing a correct base/head +# for a push-triggered affected run adds real complexity (fetch-depth, +# first-push-to-a-branch edge cases), and with only a handful of packages a +# full build is cheap. Revisit if the package count grows enough that this +# stops being true. +# +# Loop safety (same pattern as nsheaps/github-actions' sync-plugin-specs +# reusable workflow): two independent layers — +# 1. The `paths` filter below matches any package's source/config files +# but excludes packages/**/dist/**, so the bot's own commit (which +# only touches dist/*.user.js files) can't re-trigger this workflow. +# 2. The staging step only commits/pushes when the rebuild actually +# changed a tracked dist/*.user.js. Rebuilding from unchanged source +# is deterministic, so even a manual workflow_dispatch re-run right +# after a bot commit is a no-op. +# +# Only packages that actually produce a dist/*.user.js are ever staged — +# packages/_template and packages/template build fine (same as in ci.yml) +# but produce no *.user.js output, so they're naturally never touched by +# the staging step below; no special-casing needed. Likewise, +# github-actions-grafana-jump builds fine but has no *.user.js output of +# its own yet either. +# +# main is protected by an active `require-pr` ruleset. This repo's +# settings.yml grants the org automation GitHub App an always-exemption +# (bypass_mode: always) on that ruleset — same as repo admins — so +# authenticating as that app via checkout-as-app (the same action +# apply-repo-settings.yaml uses) should let this push straight to main. +# The branch+PR fallback below is kept as defense-in-depth: the live +# ruleset hasn't been confirmed to actually reflect that settings.yml +# exemption yet (see task #45's bypass_actors sync-gap investigation), +# so if the push is still rejected, this falls back to the same +# "try direct push, fall back to PR" idiom as nsheaps/github-actions' +# sync-main-to-edge reusable workflow, opening a PR via that repo's +# open-pr-if-needed composite action (idempotent — reuses an already-open PR +# instead of piling up duplicates). + +on: + push: + branches: [main] + paths: + - "packages/**" + - "!packages/**/dist/**" + - "tsconfig.base.json" + - ".github/workflows/regen-userscripts.yaml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # Serialize per-ref so a fast follow-up push waits for the in-flight run + # instead of racing it on the same branch. + group: regen-userscripts-${{ github.ref }} + cancel-in-progress: false + +jobs: + regen: + name: Rebuild compiled userscripts + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout as automation bot + uses: nsheaps/github-actions/.github/actions/checkout-as-app@main + with: + app-id: ${{ secrets.AUTOMATION_GITHUB_APP_ID }} + private-key: ${{ secrets.AUTOMATION_GITHUB_APP_PRIVATE_KEY }} + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: ".nvmrc" + + - name: Set up corepack + run: corepack enable && corepack install + + - name: Install dependencies + run: yarn install --immutable + + - name: Build all packages + run: yarn run build + + - name: Stage regenerated userscripts if any changed + id: stage + run: | + mapfile -t USER_SCRIPTS < <(find packages -maxdepth 3 -type f -path "*/dist/*.user.js") + if [ "${#USER_SCRIPTS[@]}" -eq 0 ]; then + echo "No packages/*/dist/*.user.js build artifacts found; nothing to stage." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git add -f "${USER_SCRIPTS[@]}" + if git diff --cached --quiet; then + echo "Compiled userscripts already up to date; nothing to commit." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git commit -m "chore: regenerate compiled userscripts" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Try direct push to main + if: steps.stage.outputs.changed == 'true' + id: push + continue-on-error: true + run: git push origin HEAD:main + + - name: Push to a branch if direct push was blocked + if: steps.stage.outputs.changed == 'true' && steps.push.outcome == 'failure' + id: branch + env: + BRANCH: bot/regen-userscripts + run: | + git push -f origin "HEAD:refs/heads/${BRANCH}" + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + + - name: Open PR if direct push was blocked + if: steps.stage.outputs.changed == 'true' && steps.push.outcome == 'failure' + uses: nsheaps/github-actions/.github/actions/open-pr-if-needed@main + with: + title: "chore: regenerate compiled userscripts" + body: | + Direct push to `main` was blocked, so the regenerated + compiled userscript(s) are going through a PR instead. + base: main + head: ${{ steps.branch.outputs.branch }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/github-actions-grafana-jump/package.json b/packages/github-actions-grafana-jump/package.json index b8d3a9b..5381507 100644 --- a/packages/github-actions-grafana-jump/package.json +++ b/packages/github-actions-grafana-jump/package.json @@ -10,6 +10,6 @@ }, "devDependencies": { "@types/greasemonkey": "~4.0.7", - "typescript": "~5.8.3" + "typescript": "~6.0.3" } } diff --git a/packages/github-actions-grafana-jump/tsconfig.json b/packages/github-actions-grafana-jump/tsconfig.json index b813d42..7dc0f4f 100644 --- a/packages/github-actions-grafana-jump/tsconfig.json +++ b/packages/github-actions-grafana-jump/tsconfig.json @@ -2,7 +2,13 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + // This is the only package that references ambient globals from + // @types/node (the `module` test-export guard) and @types/greasemonkey + // (`GM`). Under moduleResolution: bundler, tsc does not auto-include + // these from node_modules/@types the way it does under "node"/"node10" - + // list them explicitly rather than relying on that auto-discovery. + "types": ["node", "greasemonkey"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/packages/graphite-to-github-button/dist/script.user.js b/packages/graphite-to-github-button/dist/script.user.js index cdcdbff..abbb1f8 100644 --- a/packages/graphite-to-github-button/dist/script.user.js +++ b/packages/graphite-to-github-button/dist/script.user.js @@ -11,57 +11,43 @@ // @downloadURL https://update.greasyfork.org/scripts/509841/Graphite%20%3D%3E%20GitHub%20button.user.js // @updateURL https://update.greasyfork.org/scripts/509841/Graphite%20%3D%3E%20GitHub%20button.meta.js // ==/UserScript== - const PATH_REGEX = /^\/github\/pr\/([^\/]+)\/([^\/]+)\/([^\/]+).*$/; -const SELECTOR = - '[class^="PullRequestTitleBar_container_"] > div:nth-child(1) > div:nth-child(2)'; - -const addButton = (toolbar: HTMLElement) => { - const match = window.location.pathname.match(PATH_REGEX); - if (!match) return; - - const [_, org, repo, pr] = match; - const gitHubLink = `https://github.com/${org}/${repo}/pull/${pr}`; - - if (document.getElementById("gitHubLink") != null) { - return; - } - - const anchorEl = document.createElement("a"); - anchorEl.setAttribute("id", "gitHubLink"); - anchorEl.setAttribute("href", gitHubLink); - anchorEl.setAttribute("target", "_blank"); - anchorEl.setAttribute( - "style", - "background: #f0f0f333; padding: 6px; border-radius: 4px; flex-shrink: 0;" - ); - anchorEl.appendChild(document.createTextNode("GitHub ↗️")); - - toolbar.appendChild(anchorEl); +const SELECTOR = '[class^="PullRequestTitleBar_container_"] > div:nth-child(1) > div:nth-child(2)'; +const addButton = (toolbar) => { + const match = window.location.pathname.match(PATH_REGEX); + if (!match) + return; + const [_, org, repo, pr] = match; + const gitHubLink = `https://github.com/${org}/${repo}/pull/${pr}`; + if (document.getElementById("gitHubLink") != null) { + return; + } + const anchorEl = document.createElement("a"); + anchorEl.setAttribute("id", "gitHubLink"); + anchorEl.setAttribute("href", gitHubLink); + anchorEl.setAttribute("target", "_blank"); + anchorEl.setAttribute("style", "background: #f0f0f333; padding: 6px; border-radius: 4px; flex-shrink: 0;"); + anchorEl.appendChild(document.createTextNode("GitHub ↗️")); + toolbar.appendChild(anchorEl); }; - const toolbarObserver = new MutationObserver((_, observer) => { - const toolbar = document.querySelector(SELECTOR) as HTMLElement; - if (toolbar) { - observer.disconnect(); - addButton(toolbar); - } + const toolbar = document.querySelector(SELECTOR); + if (toolbar) { + observer.disconnect(); + addButton(toolbar); + } }); - -let lastPathname: string | undefined; +let lastPathname; const routeChangeObserver = new MutationObserver(() => { - const { pathname } = window.location; - - if (pathname !== lastPathname) { - lastPathname = pathname; - - if (pathname.match(PATH_REGEX)) { - toolbarObserver.observe(document.body, { - childList: true, - subtree: true, - }); + const { pathname } = window.location; + if (pathname !== lastPathname) { + lastPathname = pathname; + if (pathname.match(PATH_REGEX)) { + toolbarObserver.observe(document.body, { + childList: true, + subtree: true, + }); + } } - } }); - routeChangeObserver.observe(document.body, { childList: true, subtree: true }); diff --git a/packages/graphite-to-github-button/package.json b/packages/graphite-to-github-button/package.json index b2800b7..aa17929 100644 --- a/packages/graphite-to-github-button/package.json +++ b/packages/graphite-to-github-button/package.json @@ -3,7 +3,7 @@ "version": "0.3.3", "main": "dist/index.js", "scripts": { - "build": "tsc --build", + "build": "tsc --build && node -e \"const fs=require('fs');const c=fs.readFileSync('dist/index.js','utf8').replace(/^[\\\"']use strict[\\\"'];?\\r?\\n/,'');fs.writeFileSync('dist/script.user.js',c)\"", "lint": "oxlint" }, "devDependencies": { diff --git a/packages/graphite-to-github-button/src/index.ts b/packages/graphite-to-github-button/src/index.ts new file mode 100644 index 0000000..cdcdbff --- /dev/null +++ b/packages/graphite-to-github-button/src/index.ts @@ -0,0 +1,67 @@ +// ==UserScript== +// @name Graphite => GitHub button +// @description Add a button to go from app.graphite.dev to github.com +// @match https://app.graphite.dev/* +// @version 0.3.3 +// @run-at document-start +// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== +// @grant none +// @license MIT +// @namespace https://app.graphite.dev +// @downloadURL https://update.greasyfork.org/scripts/509841/Graphite%20%3D%3E%20GitHub%20button.user.js +// @updateURL https://update.greasyfork.org/scripts/509841/Graphite%20%3D%3E%20GitHub%20button.meta.js +// ==/UserScript== + +const PATH_REGEX = /^\/github\/pr\/([^\/]+)\/([^\/]+)\/([^\/]+).*$/; +const SELECTOR = + '[class^="PullRequestTitleBar_container_"] > div:nth-child(1) > div:nth-child(2)'; + +const addButton = (toolbar: HTMLElement) => { + const match = window.location.pathname.match(PATH_REGEX); + if (!match) return; + + const [_, org, repo, pr] = match; + const gitHubLink = `https://github.com/${org}/${repo}/pull/${pr}`; + + if (document.getElementById("gitHubLink") != null) { + return; + } + + const anchorEl = document.createElement("a"); + anchorEl.setAttribute("id", "gitHubLink"); + anchorEl.setAttribute("href", gitHubLink); + anchorEl.setAttribute("target", "_blank"); + anchorEl.setAttribute( + "style", + "background: #f0f0f333; padding: 6px; border-radius: 4px; flex-shrink: 0;" + ); + anchorEl.appendChild(document.createTextNode("GitHub ↗️")); + + toolbar.appendChild(anchorEl); +}; + +const toolbarObserver = new MutationObserver((_, observer) => { + const toolbar = document.querySelector(SELECTOR) as HTMLElement; + if (toolbar) { + observer.disconnect(); + addButton(toolbar); + } +}); + +let lastPathname: string | undefined; +const routeChangeObserver = new MutationObserver(() => { + const { pathname } = window.location; + + if (pathname !== lastPathname) { + lastPathname = pathname; + + if (pathname.match(PATH_REGEX)) { + toolbarObserver.observe(document.body, { + childList: true, + subtree: true, + }); + } + } +}); + +routeChangeObserver.observe(document.body, { childList: true, subtree: true }); diff --git a/packages/graphite-to-github-button/tsconfig.json b/packages/graphite-to-github-button/tsconfig.json index 33034f9..f2b90f6 100644 --- a/packages/graphite-to-github-button/tsconfig.json +++ b/packages/graphite-to-github-button/tsconfig.json @@ -2,7 +2,28 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + // This package's src/index.ts has no import/export statements, so tsc + // treats it as a global script rather than a module and (because + // "strict" implies "alwaysStrict") prepends a `"use strict";` line to + // the compiled output. That line would sit above the + // `// ==UserScript==` header block that Greasy Fork/userscript managers + // scan for, which is meant to be the first thing in the file. + // + // We used to disable this via `"alwaysStrict": false`, but that value + // is being deprecated/removed upstream, so instead we let tsc emit the + // line as normal and strip it in this package's own `build` script + // (see package.json) when copying dist/index.js to dist/script.user.js + // — that's resilient regardless of whether a future TypeScript keeps + // an escape hatch for this at all. + // + // dist/script.user.js is the only build artifact committed and served + // (via a stable raw.githubusercontent.com URL) to Greasy Fork; the + // sibling index.js.map is not shipped alongside it. Skip emitting a + // sourceMap for this package so the compiled output doesn't end with a + // `//# sourceMappingURL=...` comment pointing at a file that won't be + // there for end users. + "sourceMap": false }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/tsconfig.base.json b/tsconfig.base.json index 1173bc3..9bd8dca 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "ESNext", "lib": ["ES2020", "DOM"], - "moduleResolution": "node", + "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/yarn.lock b/yarn.lock index b0e4be6..e7cab71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -410,7 +410,7 @@ __metadata: resolution: "@nsheaps/gm-github-actions-grafana-jump@workspace:packages/github-actions-grafana-jump" dependencies: "@types/greasemonkey": "npm:~4.0.7" - typescript: "npm:~5.8.3" + typescript: "npm:~6.0.3" languageName: unknown linkType: soft