From 5544f1a6add7f0d179fd54f2d746e3705d15025d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 09:40:54 +0000 Subject: [PATCH] Push the tap and bucket with deploy keys, not a PAT Homebrew and Scoop are the two channels we own outright, so they are the two we can actually turn on without waiting for anyone. Both repos already existed (profullstack/homebrew-tap, profullstack/scoop-bucket) and both now carry a write deploy key titled "tronbrowser release automation", with the private half stored as HOMEBREW_TAP_SSH_KEY and SCOOP_BUCKET_SSH_KEY. A deploy key rather than a PAT. A PAT carries the whole account, so any token able to push to the tap could also push to every other repo; GitHub has no API to mint one, so it would have to be pasted in by hand and rotated by hand. A deploy key is scoped to one repository, can be created through the API, and is revoked by deleting it from that repo. Two details the first real push turned up. The tap keeps Casks/ for nightcell7 and had no Formula/ at all, and distribution/homebrew/tronbrowser.rb is a Formula, so the push has to create the directory; a tap carries both quite happily. And the bucket needed the same mkdir -p treatment for its first entry. Verified by pushing for real: tronbrowser 3.15.1 is live in both repos, and the Formula's linux sha256 matches the published v3.15.1 tarball byte for byte. `brew install profullstack/tap/tronbrowser` now resolves, which it never has before. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/submit-packages.yml | 7 +++-- scripts/submit-packages.mjs | 45 ++++++++++++++++++++------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/.github/workflows/submit-packages.yml b/.github/workflows/submit-packages.yml index 1282da4..6e888a8 100644 --- a/.github/workflows/submit-packages.yml +++ b/.github/workflows/submit-packages.yml @@ -77,8 +77,11 @@ jobs: GH_TOKEN: ${{ github.token }} AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} - HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} + # Per-repo ssh deploy keys, not PATs: scoped to the single repository + # they push to, creatable through the API, revoked by deleting them + # from that repo. + HOMEBREW_TAP_SSH_KEY: ${{ secrets.HOMEBREW_TAP_SSH_KEY }} + SCOOP_BUCKET_SSH_KEY: ${{ secrets.SCOOP_BUCKET_SSH_KEY }} SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} run: | ARGS="-v ${{ steps.v.outputs.version }}" diff --git a/scripts/submit-packages.mjs b/scripts/submit-packages.mjs index 8f89d05..158d61e 100644 --- a/scripts/submit-packages.mjs +++ b/scripts/submit-packages.mjs @@ -255,11 +255,31 @@ function tmp(prefix) { return mkdtempSync(join(tmpdir(), `tron-${prefix}-`)); } -/** Clone a repo we own, drop one file in, and push it back. */ -function pushFileToRepo({ repo, token, file, dest, message }) { +/** + * Write one file into a repo we own and push it. + * + * Authenticated with a per-repo ssh deploy key rather than a PAT. A PAT carries + * the whole account: any token able to push to the tap could also push here, and + * GitHub has no API to mint one, so it would have to be pasted in by hand and + * rotated by hand. A deploy key is scoped to exactly one repository, can be + * created through the API, and is revoked by deleting it from that repo. + */ +function pushFileToRepo({ repo, sshKey, file, dest, message }) { const dir = tmp(dest.replace(/[^a-z0-9]+/gi, "-")); - const url = `https://x-access-token:${token}@github.com/${repo}.git`; - run("git", ["clone", "--depth", "1", url, dir], { cwd: ROOT }); + const keyfile = join(dir, "..", `key-${repo.replace(/\W+/g, "-")}`); + writeFileSync(keyfile, sshKey.endsWith("\n") ? sshKey : `${sshKey}\n`, { + mode: 0o600, + }); + const env = { + GIT_SSH_COMMAND: `ssh -i ${keyfile} -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes`, + }; + run("git", ["clone", "--depth", "1", `ssh://git@github.com/${repo}.git`, dir], { + cwd: ROOT, + env, + }); + // The tap holds Casks/ and Formula/ side by side and the bucket holds bucket/; + // a first push into a directory that does not exist yet has to create it. + run("mkdir", ["-p", dirname(join(dir, dest))]); run("cp", [join(ROOT, file), join(dir, dest)], { cwd: ROOT }); run("git", ["config", "user.name", "github-actions[bot]"], { cwd: dir }); run("git", [ @@ -277,7 +297,7 @@ function pushFileToRepo({ repo, token, file, dest, message }) { } run("git", ["add", dest], { cwd: dir }); run("git", ["commit", "-m", message], { cwd: dir }); - run("git", ["push"], { cwd: dir }); + run("git", ["push"], { cwd: dir, env }); console.log(` pushed ${dest} to ${repo}`); } @@ -291,11 +311,14 @@ function upstreamPr(pm, repo, doc) { const SUBMITTERS = { homebrew: () => { - const token = process.env.HOMEBREW_TAP_TOKEN; - if (!token) return skip("homebrew", "HOMEBREW_TAP_TOKEN"); + const sshKey = process.env.HOMEBREW_TAP_SSH_KEY; + if (!sshKey) return skip("homebrew", "HOMEBREW_TAP_SSH_KEY"); + // distribution/homebrew/tronbrowser.rb is a Formula, not a Cask, so it goes + // in Formula/ — the tap's existing Casks/nightcell7.rb is a separate thing + // and a tap carries both directories quite happily. pushFileToRepo({ repo: TAP_REPO, - token, + sshKey, file: "distribution/homebrew/tronbrowser.rb", dest: "Formula/tronbrowser.rb", message: `tronbrowser ${version}`, @@ -303,11 +326,11 @@ const SUBMITTERS = { }, scoop: () => { - const token = process.env.SCOOP_BUCKET_TOKEN; - if (!token) return skip("scoop", "SCOOP_BUCKET_TOKEN"); + const sshKey = process.env.SCOOP_BUCKET_SSH_KEY; + if (!sshKey) return skip("scoop", "SCOOP_BUCKET_SSH_KEY"); pushFileToRepo({ repo: SCOOP_REPO, - token, + sshKey, file: "distribution/scoop/tronbrowser.json", dest: "bucket/tronbrowser.json", message: `tronbrowser ${version}`,