Skip to content

fork: release workflow for v<upstream>-fork.N on GitHub-hosted runners - #2

Merged
scuttletoby merged 3 commits into
mainfrom
fork/release-workflow
Nov 12, 2026
Merged

scuttletoby merged 3 commits into
mainfrom
fork/release-workflow

Conversation

@scuttletoby

@scuttletoby scuttletoby commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Builds this fork's releases on GitHub-hosted runners. Upstream's release.yml only runs in the upstream repository and uses runners a fork does not have.

What it adds

  • fork-release.yml (run by hand, or called by another workflow) builds a commit as v<upstream>-fork.<n>:
    • <upstream> is the newest stable upstream tag the commit contains, read from the fork's parent repository.
    • <n> counts the fork's earlier builds of that tag.
  • The guards are in .github/scripts/fork-release.sh, one subcommand each. Preflight runs its tests (fork-release.test.sh and fork-release-workflow.test.py) before it resolves the version. Each refusal stops the run with an ::error:: line.
    • Only released upstream code. It refuses any commit between the base tag and the build commit that is on upstream's default branch, so building the fork's main while it still equals upstream's main fails. This is the same rule as fork: follow each stable upstream release with the patch series #4's guard.
    • It refuses a base tag that sorts highest but is not the newest stable tag in the history (a stray v1.0.0 on an old commit). It also refuses a base above the release upstream marks latest.
    • It never publishes below a published release. The server's updater (cliRelease.ts newestCliReleaseVersion) takes the newest-created release on its channel, not the highest version, so an older build published later would show every server a downgrade. Resolve refuses such a build, and publish checks again. To go back, install the older release with t3 update --version X --allow-downgrade.
    • It never reuses a published version. Before creating the draft, and again right before publishing it, it refuses when the tag or a published release with it exists. "Re-run failed jobs" keeps the preflight's version, so after another run has published that version, the re-run stops and says to start a new run.
    • A failed release listing fails the step; it does not count as "no release". Each leftover draft it deletes is logged.
  • fork-tests.yml runs upstream's release checks and tests (release.yml quality, test, test_web and the 8 test_server shards) on ubuntu-latest. Only the runners and time limits differ.
  • The release builds:
    • The JS bundle, with T3CODE_DESKTOP_UPDATE_REPOSITORY (the setting fork: the server and CLI update from the desktop app's repository #1 reads) set to this repository. A step fails the build if the server bundle outside dist/client does not name it. The web client names this repository on every Actions build anyway (upstream's apps/web/vite.config.ts), so it does not count.
    • Desktop apps through upstream's own release-desktop.yml: Linux x64 (AppImage plus the server archive), Windows x64 (embeds the Linux archive for WSL) and macOS arm64 and x64. Each call gets only the 13 signing secrets release-desktop.yml declares (no secrets: inherit). fork-release.yml declares the same secrets for callers.
    • Builds are unsigned unless signing secrets exist. The macOS runner label can be overridden with the FORK_MACOS_RUNNER variable (default macos-26).
  • An assemble job (read-only token) collects the files:
    • It writes SHA256SUMS and merges the updater manifests.
    • It checks that latest.yml, latest-mac.yml and latest-linux.yml exist.
    • It checks that the t3 program (t3.exe in the zip) at the top of every CLI archive names this repository; client/ does not count. A missing program fails. The program bundles the server again, so this is the code that updates.
  • Publishing is off unless publish: true is passed. The publish job is the only one with contents: write. It checks out only the guard script, from the workflow's own commit (github.workflow_sha), and runs no package code. Only its three release steps get the token.
    • It creates a draft with every file (fail_on_unmatched_files: true) and only then publishes that draft by id as a normal release.
    • Every published release is above every earlier one (see above), so it is marked latest.
    • A draft left behind by an interrupted run of the same version is removed first.
  • Release notes list upstream's tag and the fork's commits on top of it.
  • Every external action is pinned by commit SHA, with a version comment. Every checkout uses persist-credentials: false.

Depends on #1. Without it, builds still name upstream as their release repository, and the bundle and archive checks fail.

Tests on pull requests: not run yet. No PR workflow runs these two test files; only preflight does. fork-tests.yml belongs to #6 and fork-checks.yml to #3/#4, so this PR does not touch them. #6 (or whichever lands the fork's PR checks) must run bash .github/scripts/fork-release.test.sh and python3 .github/scripts/fork-release-workflow.test.py.

Tested (locally; no workflow run yet)

  • bash .github/scripts/fork-release.test.sh: 34 cases against local git repositories and a fake gh (resolve, re-run refusal, drafts, older-than-published refusal, archives, server bundle, version order). All pass on this head. Against the 0da2ccd script, 12 fail, including the review's repro (t3 names upstream, client/ names the fork).
  • python3 .github/scripts/fork-release-workflow.test.py: 22 checks on the workflow's shape (pins, secrets, checkouts, write job, step order, pipefail, bundle check, setting name, guard script commit). All pass; 5 fail on 0da2ccd.
  • actionlint 1.7.12 (with shellcheck 0.10.0) and shellcheck on the scripts: clean. vp fmt --check on the workflow: clean. actionlint also found that the old published output read jobs.publish.result, which workflow_call outputs cannot read; it now comes from the publish step.

Not tested

  • No run yet: a workflow_dispatch workflow only runs once it is on the default branch.
  • The archive and bundle checks on real builds. The tests use small fixtures; whether the t3 program stores the repository name as plain quoted text is shown only by a real run. If it does not, the check fails the run; it does not pass quietly.
  • github.workflow_sha under workflow_call is the caller's commit. A caller in this repository has the guard script there; a caller elsewhere is not supported.
  • Whether the desktop jobs fit release-desktop.yml's fixed 30-minute limit on 4-vCPU hosted runners. Upstream uses larger runners.
  • Whether the macos-26 label is available.
  • Whether unsigned macOS builds can update themselves. Squirrel.Mac normally needs a stable signature.
  • The desktop apps' own updater config (app-update.yml inside the AppImage, dmg and exe) is not checked for the repository name; only the JS bundle and the CLI archives are.

🤖 Generated with Claude Code

fork-release.yml builds a fork release from a commit: upstream's tests
(fork-tests.yml, the release.yml test jobs on hosted runners), the JS bundle
with this repository as the release repository, the Linux x64 server archive
and the desktop apps through upstream's release-desktop.yml, unsigned unless
signing secrets exist. Publishing is opt-in, makes a normal release marked
latest, and stays a draft until every file is uploaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL labels Nov 12, 2026
@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: ops+security DI-1 · reviewer: opus high (t3lens-opsDI1) · read at: 48526ae
covered: .github/workflows/fork-release.yml and fork-tests.yml in full; the release-desktop.yml jobs they call (inputs, permissions, CLI build-exe at :376, CLI archive, Collect release assets :770-812, artifact names); release.yml's release job :789-1041 for comparison; PR #1's diff (scripts/lib/release-repository.ts, cliRelease.ts, the three vite configs, build-desktop-artifact.ts resolveGitHubPublishConfig); apps/desktop DesktopUpdates.ts applyAutoUpdaterChannel :517-532; app-builder-lib 26.16.1 PublishManager.getResolvedPublishConfig and updateInfoBuilder; electron-updater 6.8.9 GitHubProvider.getLatestVersion and AppUpdater.isUpdateAvailable; softprops/action-gh-release v3 src/github.ts (existing-release path :650-720, asset upload :420-450).
checks:

  • Version logic, run on this head: upstream tags (lightweight today) resolve to v0.1.9 = merge-base 24e7747 and give 0.1.9-fork.1. I also checked that the refs/tags/X^{} lookup returns the peeled commit for annotated tags (git/git v2.43.0), so a switch upstream to annotated tags still works. A failed ls-remote inside the loop exits under set -e/pipefail. A failed tag listing leaves base_tag empty and fails loudly at :90. A fork with no fork tags starts at .1. A repo that is not a fork fails at :76.
  • Half-failed run: the tag is created only by gh release edit --draft=false (:401), after every upload, so a run that stops earlier leaves no tag and at most a draft. publish needs every desktop job and tests with the implicit success(). Linux archive (:312) and latest.yml/latest-mac.yml/latest-linux.yml (:342) are checked. So no tag without assets and no release without a desktop build, except as noted in the findings below.
  • Updater fit: electron-builder applies the version's prerelease channel ("fork") only to the generic provider. With provider github and no channel (build-desktop-artifact.ts resolveGitHubPublishConfig), the manifests stay latest*.yml. The desktop app sets channel "latest" and allowPrerelease=false (DesktopUpdates.ts:521-523), so electron-updater reads /releases/latest (needs the --latest at :401) and updates on semver gt: 0.1.9-fork.2 > 0.1.9-fork.1 works. The server updater wants t3--linux-x64.tar.gz + SHA256SUMS (cliRelease.ts:41), and both are produced. The macOS CLI archive is not built (upstream builds darwin-arm64). That is fine for the owner: desktop-managed servers update through the app (selfUpdate.ts). Unverified, as the PR says: unsigned macOS builds and Squirrel.Mac self-update, so the owner's MacBook may not update itself.
  • Tokens: workflow default is actions:read + contents:read; only publish has contents:write. No pull_request/pull_request_target triggers; dispatch needs write access. Every ${{ }} in run: lines is digits-only (version/tag) or one of two literal script names, and inputs.ref only reaches with:/env. Tags made with GITHUB_TOKEN do not trigger upstream's release.yml v*.*.* push trigger.
  • State question: rerun of all jobs gives a new .N (fine). Re-run failed jobs reuses preflight's version, and that is where the first finding is. Two runs at once are serialised by concurrency: fork-release. An upstream release with no fork patches gives a fork release that only renames the release repository. On a rebase conflict, an upstream change to release-desktop.yml's inputs makes the workflow invalid (loud).
  • Owner rules: no personal data, no DB schema change, new files only (rebase-friendly). It depends on release-desktop.yml's input interface staying stable.
    finding: major .github/workflows/fork-release.yml:368-401 Run A computes 0.1.9-fork.1 and fails in a desktop job (for example the 30-min macOS limit the PR names). Meanwhile run B (a new dispatch or the planned daily follow job) also computes 0.1.9-fork.1 and publishes it. The owner then clicks "Re-run failed jobs" on A, which reuses preflight's fork.1. "Remove a draft" finds no draft. softprops v3 finds the published release, keeps draft=false (src/github.ts:702-710), rewrites name/body/target_commitish to A's, and deletes and re-uploads each same-named asset on the live release (:435-445). Result: the v0.1.9-fork.1 tag stays on B's commit, the assets and notes are A's, one version names two different builds, and clients that already took B's build never get A's. During the upload window, SHA256SUMS and latest*.yml do not match the binaries. Fix: in publish, fail when git ls-remote --exit-code --tags origin refs/tags/$TAG succeeds or a non-draft release has that tag ("already published; start a new run"). (high on the softprops behaviour from its source, medium on how often it happens)
    finding: major .github/workflows/fork-release.yml:372-375 "Remove a draft left by an earlier run" runs under the default bash -e without pipefail. If gh api --paginate fails (5xx, rate limit), the pipeline still exits 0 through the while loop. The step goes green and deletes nothing. softprops then falls back to listing drafts (src/github.ts:853-873) and reuses the stale draft. Any old asset this run does not overwrite gets published, and nothing reports the skip. That is a silent fallback, which the lens rules count as a major. Fix: set -euo pipefail (as the other steps have). (high)
    finding: minor .github/workflows/fork-release.yml:153-160 The guard checks apps/server/dist from build_bundle. The t3 binary inside the Linux archive that VM106 installs is rebuilt by vp pack in release-desktop.yml:376, and the guard never looks at it. That binary names the fork only through PR fork: the server and CLI update from the desktop app's repository #1's GITHUB_ACTIONS/GITHUB_REPOSITORY fallback (scripts/lib/release-repository.ts:21), because the caller's T3CODE_RELEASE_REPOSITORY does not reach the reusable workflow. If that fallback regresses, an archive that updates from upstream ships with a green guard. Fix: also grep the unpacked t3--linux-x64.tar.gz in publish. (high)
    finding: minor .github/workflows/fork-release.yml:401 --latest is unconditional. Dispatching an older ref (on an earlier upstream base) after 0.1.10-fork.1 publishes 0.1.9-fork.N as "latest". The desktop app reads /releases/latest and sees a lower version, so it offers nothing until the next build. Fresh installs from the releases page get the old build. Fix: mark latest only when the version is above the current latest. (high)
    finding: minor .github/workflows/fork-release.yml:82-97 The base is the highest-sorting upstream vX.Y.Z tag that is an ancestor. A stray or retargeted upstream tag on an old commit (for example v1.0.0, by mistake or with malicious intent) yields 1.0.0-fork.1. From then on it outranks every real build for both the server updater (newest semver) and the desktop app (gt), so updates stall until someone deletes the release. Fix: cross-check the base against the package version at that tag, or refuse a base above the last fork base plus one minor. (medium)
    finding: minor .github/workflows/fork-release.yml:386 fail_on_unmatched_files: false (upstream's release.yml:1013 uses true). A pattern with no files, such as no *.exe or *.blockmap, publishes without an error. Fix: true. (high)
    finding: minor .github/workflows/fork-release.yml:62,163,292,298,378 These actions are pinned by tag, not SHA. release-desktop.yml pins SHAs. softprops/action-gh-release@v3 runs with contents:write. Also, the publish checkout (:279-284) keeps the default persist-credentials, so the contents:write token sits in .git/config while vp install (:286-289) and upstream scripts run third-party package code. Fix: SHA pins plus persist-credentials: false (this job never pushes). (high)
    finding: minor .github/workflows/fork-release.yml:176,200,225,249 secrets: inherit hands every repo secret to upstream's release-desktop.yml, a file each rebase may change. That includes the FORK_PUSH_TOKEN from the owner's round-2 answers. Fix: pass the named signing secrets only. (medium)
    nits (optional): the guard comment at :152 reads as if it covers the archive; the concurrency group has no explicit pending-cancel behaviour (GitHub keeps only one pending run and cancels the older one, which is visible but easy to miss).

@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: ops+security DI-2 · reviewer: opus high (opsdi2) · read at: 48526ae
covered: .github/workflows/fork-release.yml and fork-tests.yml in full; release-desktop.yml in full (as called at v0.1.9); release.yml jobs quality/test/test_web/test_server (234-388) and release (840-1041); scripts/build-desktop-artifact.ts resolveGitHubPublishConfig/resolveDesktopUpdateChannel/createBuildConfig (2691-2830); packages/shared/src/cliRelease.ts (1-160); apps/desktop/src/updates/DesktopUpdates.ts applyAutoUpdaterChannel and configure (516-531, 1110-1160); PR #1's scripts/lib/release-repository.ts and its define wiring; softprops/action-gh-release v3 (40c2ca6d) src/github.ts, the existing-release and asset-overwrite paths; PR #4's fork-follow.yml release job (84-95).
checks:

  • At v0.1.9: I ran the preflight script locally at this head (v0.1.9 + this commit, with GITHUB_REPOSITORY set to the fork). It returns base_tag=v0.1.9, version=0.1.9-fork.1, tag=v0.1.9-fork.1. v0.1.9 is a lightweight tag; the ls-remote, sort and tail sequence also resolves annotated tags to their commit. Every release-desktop.yml input this PR passes exists at v0.1.9. The Windows "Wait for Linux CLI archive" step matches by EndsWith("Build Linux x64"), so it still works when the job is nested under fork-follow.
  • Half-failed or cancelled run: publish needs tests and all four desktop jobs, with an implicit success(). The release stays a draft until the last step, and the tag only exists once the draft is published. Both updaters skip drafts (cliRelease.ts:148; electron-updater reads /releases/latest). So a run that stops part-way publishes nothing visible. A leftover draft gets the same N on the next run and is deleted (368-375). No tag is left without assets, and no release is marked latest early. The one exception is the first finding below.
  • Numbering: N is the highest digits-only v-fork.N tag plus one. The concurrency group serializes runs, and PR fork: follow each stable upstream release with the patch series #4 uses a different group (fork-follow), so there is no deadlock. Collision only through the re-run path below. Stepping backwards only through an older ref (minor below).
  • Manifests: Windows latest-win-x64.yml is renamed to latest.yml (333-335). macOS arm64 and x64 are merged into latest-mac.yml. latest-linux.yml comes straight from the Linux job. The desktop app pins channel "latest" with allowPrerelease=false (DesktopUpdates.ts:520-523). electron-updater then reads /releases/latest plus latest*.yml, so the names match. Server: newestCliReleaseVersion accepts v0.1.9-fork.1 as stable (cliRelease.ts:89,149) and downloads t3--linux-x64.tar.gz (cliRelease.ts:41) plus SHA256SUMS. That matches build-cli-archive.ts:102 and the check at 312. There is no darwin CLI archive (cli_archive is false on both Macs), so a Mac-hosted server finds no asset. That fails safe.
  • Tokens and injection: the workflow-wide token is actions:read and contents:read; only publish gets contents:write. Tokens are passed only through env. There is no pull_request_target and no PR trigger. Every value interpolated into a run: line is a version or tag that sed (89, 96) restricts to digits. inputs.ref only goes to with:. No personal data in the diff. No schema change. New files only, no hot upstream files.
  • fork-tests.yml matches release.yml at v0.1.9 except for runners, time limits and --concurrency-limit 2. I did not run any workflow: dispatch needs the workflow on the default branch.
  • Not in this lens: the macOS builds are unsigned, so Squirrel.Mac self-update on the owner's MacBook is unproven. The PR already lists this as not tested.
    finding: major .github/workflows/fork-release.yml:95-97,377-401 "Re-run failed jobs" keeps preflight's old version. Trace: run A computes 0.1.9-fork.1 and its mac job fails. Run B then publishes v0.1.9-fork.1. Someone re-runs A's failed jobs. A's publish finds no draft to delete (B's release is published), and softprops v3 then takes the existing-release path (github.ts findTagFromReleases, then updateRelease with draft: existingRelease.draft=false). It deletes and re-uploads each asset on the published latest release. Result: the v0.1.9-fork.1 tag points at B's commit while the release serves A's builds and A's SHA256SUMS. During the upload, the latest release is missing files, or holds a new archive next to the old SHA256SUMS, so a server updating then fails its checksum. The same happens if the final gh release edit succeeds on GitHub but the step errors and is re-run. Fix: before creating the draft, fail if refs/tags/$TAG or a non-draft release with that tag already exists, with a message to re-run all jobs. (high)
    finding: major .github/workflows/fork-release.yml:17,80-97 Nothing checks that base_tag..HEAD holds only fork commits. Trace: I ran the preflight at the fork's current main dc032d7, which is upstream main and contains 470 commits after v0.1.9. It returns 0.1.9-fork.1. A dispatch with the default ref=main would publish unreleased upstream code (including any unreleased migrations) as 0.1.9-fork.1, mark it latest, and list 470 upstream commits under "this fork's changes". This breaks the owner rule "releases only, no unreleased upstream commits", and the rule rests on main being reset first. Fix: fetch upstream's default branch (blob:none) and fail if any commit in $base_tag..HEAD is reachable from it, naming the count. (high)
    finding: major .github/workflows/fork-release.yml:62,120,163,280,292,299,378 and fork-tests.yml:30,56,82,114 Actions are pinned by tag, not by SHA. The worst case is softprops/action-gh-release@v3, a third-party action, which runs in the contents:write publish job. Fork installs check only SHA256SUMS from the same release, and the macOS and Windows builds are unsigned. So a moved v3 tag could replace the assets and checksums that every fork install updates to. release-desktop.yml in the same repo already pins by SHA (for example :160 download-artifact@3e5f45b2…). v3 is currently 40c2ca6ddd77ba2a30bbea8f67409c0f76868d67. (high)
    finding: minor .github/workflows/fork-release.yml:401 --latest is set on every publish. Trace: a dispatch on a ref based on v0.1.8, after 0.1.9-fork.1 exists, publishes v0.1.8-fork.1 as latest. The desktop app's /releases/latest then steps back, so no update is offered. t3 update takes the newest-created release, 0.1.8-fork.1, and refuses it as a downgrade (update.ts:449). Update stalls until the next newer build. Upstream computes make_latest (release.yml:160-215). Fix: refuse, or skip --latest, when a higher fork version is already published. (high)
    finding: minor .github/workflows/fork-release.yml:368-375 The step has no shell: bash, so it runs as bash -e without pipefail, and a failing gh api list passes silently. A deleted leftover draft is also not reported. Add pipefail and log "removed leftover draft for ". (high)
    finding: minor .github/workflows/fork-release.yml:176,200,225,249 secrets: inherit, together with PR fork: follow each stable upstream release with the patch series #4's fork-follow.yml:93 secrets: inherit, passes FORK_PUSH_TOKEN (contents and workflows write) into upstream-authored release-desktop.yml, which the follow job updates from upstream. Today it only references its declared secrets. Pass the 13 named secrets explicitly instead. (medium)
    finding: minor .github/workflows/fork-release.yml:280 The publish checkout keeps persist-credentials (the default), so the contents:write token sits in .git/config while vp install and upstream's scripts/merge-update-manifests.ts run. This job pushes nothing over git, and the upstream fetch at 358 is anonymous. Set persist-credentials: false. (high)
    finding: minor .github/workflows/fork-tests.yml:1-132 This is a hand copy of release.yml's test jobs with no drift check. It matches at v0.1.9, but when a rebased upstream release adds or changes a test job, fork releases skip it and nothing reports that. A check that diffs the job and step commands against release.yml would make the skip loud. (medium)
    optional nits:
  • fork-release.yml:8-9: the header says release-repository.ts reads GITHUB_REPOSITORY. Line 149 sets T3CODE_RELEASE_REPOSITORY. Both work; the comment names only one.
  • fork-release.yml:157: the bundle check greps apps/server/dist. The shipped archive re-bundles the server in release-desktop.yml:376 (build-exe). That bundle is covered by PR fork: the server and CLI update from the desktop app's repository #1's GITHUB_ACTIONS fallback, but grepping the extracted archive would check what actually ships.

…blishes

- A version that already has a tag or a published release is refused before the
  draft is created and again right before it is published, so "Re-run failed
  jobs" cannot replace a live release's files.
- Leftover drafts are removed under pipefail and each removal is logged; a failed
  release listing fails the step instead of reading as "no drafts".
- Only released upstream code: refuses any commit between the base tag and the
  build that is on upstream's default branch, a base tag that is not the newest
  stable tag in the history (a stray tag on an old commit), and a base above
  upstream's latest release.
- Marked latest only when above every published fork release.
- The CLI archives (the `t3` executable bundles the server again) must name this
  repository, not only apps/server/dist.
- Actions pinned by commit; fail_on_unmatched_files; named signing secrets
  instead of secrets: inherit; persist-credentials: false. Files are collected
  in a read-only assemble job; the publish job holds the write token, runs no
  package code, and only its three release steps see the token.
- The workflow's `published` output used jobs.publish.result, which
  workflow_call outputs cannot read; it now comes from the publish step.

Tests: .github/scripts/fork-release.test.sh (local git repos, fake gh; also
run in preflight) and fork-release-workflow.test.py (workflow shape).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

fix round 1: every major and minor from DI-1 and DI-2, at head 0da2ccd

The guards now live in .github/scripts/fork-release.sh (one subcommand each). Its tests are fork-release.test.sh (local git repos and a fake gh, 24 cases), which preflight also runs, and fork-release-workflow.test.py (17 checks on the workflow's shape). To get "red on 48526ae", I ran the same tests against 48526ae: its step scripts verbatim behind the same subcommands, and its workflow file. 18 of the 24 and 12 of the 17 fail there; all pass on 0da2ccd.

Majors

  1. Re-run failed jobs overwrites a published release → remove-drafts (before the draft is created) and publish (right before un-drafting) both refuse when refs/tags/$TAG exists on origin or a non-draft release has the tag. A failed ls-remote (exit code other than 2) or a failed release listing is an error, never "free". The draft is published by the id softprops returns, not looked up by tag. → tests remove-drafts: refuses a version whose tag exists (re-run failed jobs), …with a published release, publish: re-checks the tag right before un-drafting (+ nothing written).
  2. Draft removal without pipefail → the listing is captured under set -euo pipefail, and a failed listing stops with "Could not list the releases". Each removal logs Removed leftover draft <id> for <tag>. Every run: step now has shell: bash, and multi-line ones start with set -euo pipefail. → tests remove-drafts: a failed release listing fails the step (+ nothing deleted), …deletes only that tag's draft and says so, workflow check run steps use bash….
  3. Unreleased upstream code ships → resolve fetches upstream's tags and HEAD read-only (anonymous) and refuses when rev-list $base..HEAD --not refs/upstream-head is shorter than $base..HEAD. This is fork: follow each stable upstream release with the patch series #4's rule. → tests resolve: refuses unreleased upstream commits (upstream main) and …under fork commits.
  4. Tag pins → every external action is pinned by commit with a version comment: checkout d23441a4 v6.1.0, upload-artifact 043fb46d v7.0.1 and download-artifact 3e5f45b2 v8.0.1 (the same pins as release-desktop.yml), softprops 8a0cd651 v3.0.4. On softprops: 40c2ca6d, the SHA DI-2 named, is the annotated v3 tag object, and 8a0cd651 is the commit it points to, so this is the code both reviewers read. → workflow check external actions are pinned by commit SHA….

Minors

  • The repo-name check covers what ships → the assemble job runs check-archives release-assets. It unpacks every t3-*.tar.gz and t3-*.zip and requires "$REPOSITORY" in them, so the t3 executable built by build-exe is covered. A missing archive is also an error. The apps/server/dist check stays. → tests check-archives: an archive naming upstream fails, no archive at all fails.
  • --latest only when higher → publish sets make_latest=false when any published, non-prerelease fork release is not below this version. This compares against every published fork release, not only the one GitHub marks latest, which is the stricter form. → test publish: an older version is published without becoming latest.
  • Stray base tag → resolve refuses a highest-sorting contained tag when another contained stable tag is not in its history. It also refuses a base above the release upstream marks latest (repos/<upstream>/releases/latest). That is how I read "above the fork's newest release by more than upstream's own ordering". → tests resolve: refuses a stray higher tag on an old commit, refuses a base above upstream's latest release, accepts it once upstream marks it latest.
  • fail_on_unmatched_files: true → done; the patterns are upstream's minus *.deb. → workflow check.
  • secrets: inherit → each release-desktop.yml call passes exactly the 13 secrets it declares, and fork-release.yml declares the same 13 for callers. FORK_PUSH_TOKEN appears nowhere. fork: follow each stable upstream release with the patch series #4's fork-follow.yml still calls this workflow with secrets: inherit. The token can no longer travel past fork-release.yml, but fork: follow each stable upstream release with the patch series #4 should pass the named secrets too (I did not touch fork: follow each stable upstream release with the patch series #4). → workflow checks no job passes secrets: inherit, release-desktop.yml gets exactly the secrets it declares.
  • persist-credentials: false and the write token only where it publishes → set on every checkout. Collecting and checking files moved to a new assemble job with a read-only token (vp install, manifest merge, checksums, notes). The release notes now take upstream from preflight, so that step has no token. publish is the only contents: write job. It sparse-checks out .github/scripts, runs no package code, and only its three release steps (refuse and remove drafts, create the draft, publish it) see the token. → workflow checks only the publish job has contents: write, publish: runs no package code, the token reaches only…, refuse/remove drafts, then create…, then publish it.
  • Drift check for fork-tests.yml (DI-2) → not here. fork-tests.yml is unchanged because the fork/ci PR takes it over.

Also found: actionlint reported that the published output read jobs.publish.result, which workflow_call outputs cannot read. It now comes from the publish step (published=true).

Run: both test files, actionlint 1.7.12 with shellcheck 0.10.0, shellcheck on the scripts, vp fmt --check on the workflow: all clean.

Unproven: no workflow run (dispatch needs the default branch). Whether the real t3 executable holds the repository name as plain text: if it does not, check-archives fails the run. The guard and API calls are tested only against a fake gh. The desktop apps' own app-update.yml is still not checked for the repository name. Also still open, as before: the 30-minute desktop limit on hosted runners, the macos-26 label, and unsigned macOS self-update.

@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: ops+security r2 · reviewer: opus high (t3lens-2-r2) · read at: 0da2ccd
covered: git diff 48526ae..0da2ccd in full: .github/workflows/fork-release.yml, .github/scripts/fork-release.sh, fork-release.test.sh, fork-release-workflow.test.py. Also the code they call into: release-desktop.yml build-exe (:376) and the dist/client branding (:298-307); scripts/build-cli-archive.ts:615-638 (archive layout: t3 executable + client/); apps/web/vite.config.ts:48-51,317 and desktopUpdate.logic.ts:31-39 with its importers; packages/shared/src/cliRelease.ts:139-152; PR #1's scripts/lib/release-repository.ts; PR #6's file list (fork-tests.yml takeover).
checks:

  • Head re-checked with gh pr view: still 0da2ccd, one fix commit on top of 48526ae.
  • Tests run at this head: fork-release.test.sh 24/24 ok and fork-release-workflow.test.py 17/17 ok. Red without the fix: I removed each guard from fork-release.sh in turn and ran the test file against it (FORK_RELEASE_SH). Every mutation goes red: no refuse_published in remove-drafts (2 FAIL), no re-check in publish (2), no unreleased-commit check (2), no stray-tag check (1), a release listing failure swallowed with || true (3), always make_latest=true (1), and check-archives that only warns (1).
  • Real data: resolve on this head against pingdotgg/t3code gives base_tag=v0.1.9, version=0.1.9-fork.1. Every upstream stable tag is an ancestor of v0.1.9, so the stray-tag check does not misfire on real history. Resolve on fork main dc032d7 is refused: "holds 470 unreleased pingdotgg/t3code commits after v0.1.9". That fixes DI-2's major at its root.
  • r1 majors: (1) re-run of failed jobs over a published version is fixed. remove-drafts and publish both refuse an existing tag or a non-draft release. ls-remote exit 2 is the only "free" answer, and the draft is published by the id softprops returns. (2) The pipefail/silent draft-removal problem is fixed: set -euo pipefail, die on a failed listing, and a "Removed leftover draft" log line. Every run: step is bash, and multi-line steps start with pipefail. (3) Unreleased upstream code is fixed (see above). (4) SHA pins are fixed in fork-release.yml. I peeled them through the GitHub API: softprops v3.0.4 tag object 28ab305d -> commit 8a0cd651; checkout v6.1.0 = d23441a4; upload v7.0.1 = 043fb46d; download v8.0.1 = 3e5f45b2. fork-tests.yml is still on @v6 and has no drift check, but PR fork: fast CI on GitHub-hosted runners (fork-tests.yml) #6 takes that file over with SHA pins and fork-tests-drift.cjs, so neither counts as a finding here.
  • r1 minors fixed: fail_on_unmatched_files is true. Each desktop call gets exactly the 13 named secrets, with no inherit; PR fork: follow each stable upstream release with the patch series #4's own inherit is left to fork: follow each stable upstream release with the patch series #4. persist-credentials is false on every checkout. A read-only assemble job runs all package code, and publish is the only contents:write job. The stray tag is guarded twice: an ancestry check plus "not above upstream's /releases/latest". make_latest is set only above every published fork version, which fixes the desktop side. Two minors are only partly fixed: the archive check (major below) and the server side of latest (minor below).
  • State question. Rerun all jobs: a new N from the tags. Drafts carry no tag, so a leftover draft with the same N is deleted and logged. Re-run of failed jobs after another run published that N: refused loudly at remove-drafts, and again at publish. Stop half-way through the softprops upload: a draft is left, and the next attempt deletes it and starts over. The PATCH succeeds on GitHub but the step errors: the re-run is refused with "Tag ... already exists". The release is live and the run is red, which is loud and correct. A failed ls-remote, release listing or upstream /releases/latest stops the step under pipefail, and nothing is written. Empty state: no fork tags gives .1, no contained upstream tag fails, and a non-fork repo fails. An upstream release with no fork commits passes (total=own=0) and builds a rename-only release. Two dispatches are serialised by concurrency fork-release.
  • Security: no PR or pull_request_target triggers, and every ${{ }} in run: lines is gone (values arrive through env). The write token reaches only remove-drafts, softprops and publish. Nit: the publish job runs .github/scripts/fork-release.sh from the build ref (inputs.ref), not from the workflow's own commit (github.workflow_sha). Today that only matters for someone who can already dispatch.
  • Known follow-up T3CODE_RELEASE_REPOSITORY (fork-release.yml:166): neither renamed nor dropped. With PR fork: the server and CLI update from the desktop app's repository #1 it is harmless, because PR fork: the server and CLI update from the desktop app's repository #1 accepts the "former" name when it equals GITHUB_REPOSITORY. Without PR fork: the server and CLI update from the desktop app's repository #1, nothing reads it. The header comment at :8-9 still says release-repository.ts reads T3CODE_RELEASE_REPOSITORY. PR fork: the server and CLI update from the desktop app's repository #1 reads T3CODE_DESKTOP_UPDATE_REPOSITORY.
    finding: major .github/scripts/fork-release.sh:181 (and fork-release.yml:175) The fixed check passes when the server in the archive still names upstream, so the r1 archive minor is not fixed at its root. Why: check-archives greps the whole unpacked archive for "". The archive holds client/ (build-cli-archive.ts:637, from apps/server/dist/client). Upstream's own apps/web/vite.config.ts:48-51,317 bakes GITHUB_REPOSITORY into import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY, and desktopUpdate.logic.ts:31-39 uses it from the sidebar. So every Actions build's web client names the fork, whatever the t3 executable says. The build_bundle grep over apps/server/dist (:175) has the same hole, because dist/client is inside it. Repro: I built an archive where t3/t3 holds "pingdotgg/t3code" and t3/client/assets/index.js holds "scuttletoby/t3code". REPOSITORY=scuttletoby/t3code fork-release.sh check-archives prints "names scuttletoby/t3code" and exits 0. Concrete case today: PR fork: release workflow for v<upstream>-fork.N on GitHub-hosted runners #2 merges and is dispatched before PR fork: the server and CLI update from the desktop app's repository #1. cliRelease.ts still hard-codes pingdotgg/t3code, both guards are green, and the published fork build's server Update lists upstream releases, where 0.1.9 > 0.1.9-fork.1, so it installs upstream over the fork. Fix: grep only the executable at the archive root (t3 / t3.exe), and exclude dist/client in the bundle check. Add a test with exactly this fixture. (high on the grep logic, reproduced; medium-high that the minified client keeps the quoted string: source chain traced, not built)
    finding: minor .github/scripts/fork-release.sh:149-161 make_latest=false fixes only the desktop app. The server's newestCliReleaseVersion (packages/shared/src/cliRelease.ts:147-152) takes the first non-draft release in the API list, which is the newest created. So publishing an older ref after 0.1.10-fork.1 still puts 0.1.9-fork.N first. t3 update then refuses it as a downgrade (update.ts:449), and server updates stall until a newer build. Fix: refuse publish when a higher fork version is already published, unless an explicit input allows it. (high, from source)
    finding: minor .github/scripts/fork-release-workflow.test.py No workflow runs it. Preflight runs only fork-release.test.sh (fork-release.yml:105), and grep finds no other caller. So the SHA-pin, no-inherit, persist-credentials and write-token-scope checks it adds can regress without anything going red. Fix: run it in preflight or in Fork checks. (high)
    nits (optional): the fork-release.yml:8-9 header (see checks); take the guard script in publish from github.workflow_sha.

…blished release

- check-archives reads only <stem>/t3 (t3.exe in the zip), not client/, and fails when the
  program is missing. The web client names this repository on every Actions build, so it
  passed while the server still named upstream.
- check-bundle (new) checks apps/server/dist without dist/client, for the same reason.
- resolve and publish refuse a version that is not above every published release on the
  server's stable channel: the server updater takes the newest-created release, so an older
  build published later would read as a downgrade. Published releases are always latest.
- Preflight runs fork-release-workflow.test.py too.
- The build sets T3CODE_DESKTOP_UPDATE_REPOSITORY (the name release-repository.ts reads); the
  former T3CODE_RELEASE_REPOSITORY is gone. The publish job takes its guard script from
  github.workflow_sha, not from the commit being built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

fix round 2: head a1bc1cd, on top of 0da2ccd. Answers the r2 lens (comment 6822149545).

  • major, archive/bundle check passed on the web client -> check-archives reads only <stem>/t3 (t3.exe in the zip), never client/, and fails when the program is missing. New check-bundle reads apps/server/dist without dist/client; the build step now calls it. -> tests: "t3 names upstream, client/ names the fork -> fails" (the review's repro), "a missing program fails", "check-bundle: server names upstream, client/ names the fork -> fails", plus only-client and missing-bundle cases. All red on 0da2ccd, green now.
  • minor, server takes the newest-created release -> fixed in the release flow. cliRelease.ts is not touched. resolve and publish refuse a version that is not above every published release on the server's stable channel. That covers prereleases, which the server does not skip. Nightly/preview and non-version tags are ignored, and a stable-channel tag that can't be ordered fails loudly. A published release is therefore always the highest, and it is marked latest. -> tests: resolve refuses below a published release (and a draft does not block). Publish refuses an older version, refuses below a higher prerelease, ignores nightly/notes tags, and fails loudly on v0.1.8-rc.1. Red on 0da2ccd, green now.
  • minor, nothing ran fork-release-workflow.test.py -> preflight runs it (installs python3-yaml only if it is missing). PR checks are not wired: fork-tests.yml is fork: fast CI on GitHub-hosted runners (fork-tests.yml) #6's and fork-checks.yml is fork: CI check that refuses database schema and migration changes #3/fork: follow each stable upstream release with the patch series #4's, so the PR body now says fork: fast CI on GitHub-hosted runners (fork-tests.yml) #6 must run both test files. -> test: "preflight runs this shape test" (red on 0da2ccd).
  • nit, header at :8-9 -> it now names T3CODE_DESKTOP_UPDATE_REPOSITORY and the newest-created rule.
  • nit, publish guard script from the build ref -> the checkout now uses github.workflow_sha. -> test "publish checks out its guard script at github.workflow_sha" (red on 0da2ccd).
  • follow-up T3CODE_RELEASE_REPOSITORY -> renamed to T3CODE_DESKTOP_UPDATE_REPOSITORY, the name fork: the server and CLI update from the desktop app's repository #1's release-repository.ts reads (fork: the server and CLI update from the desktop app's repository #1 head c798cf3). -> tests: the build sets it to ${{ github.repository }}, and the former name appears nowhere.

Results: fork-release.test.sh 34/34 ok (12 fail against the 0da2ccd script). fork-release-workflow.test.py 22/22 ok (5 fail on the 0da2ccd workflow). actionlint 1.7.12 with shellcheck 0.10.0 and shellcheck on both scripts: clean.
Unproven: no workflow run. Whether the real t3 program and server bundle hold the quoted repository name (if not, the run fails loudly). github.workflow_sha under workflow_call is the caller's commit. vp fmt --check was not run this round.

@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: ops+security r3 · reviewer: opus high (t3lens-1-2-r) · read at: a1bc1cd
covered: git diff 0da2ccd..a1bc1cd in full: .github/scripts/fork-release.sh (refuse_older, cmd_publish, cmd_check_bundle, cmd_check_archives), fork-release.test.sh, fork-release-workflow.test.py, .github/workflows/fork-release.yml. Also read: fork-release.sh releases/refuse_published/cmd_resolve; packages/shared/src/cliRelease.ts:87-152 (channel and newestCliReleaseVersion); scripts/build-cli-archive.ts:93-105,595-705 (archive stem and layout); apps/server/vite.config.ts:33-131 and apps/server/scripts/cli.ts build-exe (SEA build); release-desktop.yml:375-376,460-470; the caller in #4 (fork-follow.yml uses: ./.github/workflows/fork-release.yml).
checks:

  • Head re-checked with gh pr view: a1bc1cd, one fix commit on top of 0da2ccd.
  • Tests on both heads. 0da2ccd: fork-release.test.sh 23 ok, fork-release-workflow.test.py all passed. a1bc1cd: 34 ok, 0 FAIL, and the .py file all passed (22). Red without the fix: the new fork-release.test.sh run against the 0da2ccd fork-release.sh (FORK_RELEASE_SH) gives 12 FAIL, covering refuse-older (resolve and publish), higher prerelease, unorderable tag, the check-archives client/ repro, missing program, and every check-bundle case.
  • r2 MAJOR (the check found the fork name in client/) is fixed at its root, and checked on the real binary, not a fixture. I built the real t3 with node apps/server/scripts/cli.ts build-exe (Node 26.12.1, Node SEA, useCodeCache false, binMain.mjs embedded unminified) and packed it into the build-cli-archive layout t3-0.1.9-fork.1-linux-x64/{t3,client/}, with client/index.js naming the fork:
  • r2 minor (the server takes the newest-created release) is fixed at its root, in the release flow. refuse_older (fork-release.sh:72-83) runs in resolve and again in publish, right before the PATCH. It walks every non-draft release with --paginate, including prereleases, which newestCliReleaseVersion does not skip. It skips -nightly./-preview. and non-version tags, the same channels as cliReleaseChannelOf. A stable-channel tag it cannot order fails loudly. So every published release is the highest, the server's first-listed release and the desktop's /releases/latest agree, and make_latest=true is now correct.
  • Against the fork's real state: gh api repos/scuttletoby/t3code/releases --paginate returns 0 releases, and git ls-remote --tags returns 0 tags. The first run gives 0.1.9-fork.1 and refuse_older passes on the empty list (the tests cover empty history). Later builds of the same base get N+1 and pass. An older base after a newer one is refused before anything is built, with a message that says how to go back (--allow-downgrade). Tags without a release only raise N. Drafts never block.
  • r2 minor (nothing ran the .py file): preflight now runs it (fork-release.yml:110-115), and the test "preflight runs this shape test" guards that. It installs python3-yaml only when it is missing.
  • github.workflow_sha under workflow_call is the caller's workflow commit (the github context belongs to the caller). The only caller, fork: follow each stable upstream release with the patch series #4's fork-follow.yml, uses a local ./.github/workflows/fork-release.yml, which loads from that same commit. So the publish job's guard script and the workflow file come from the same commit, never from the built ref. A cross-repo caller would check out its own repository at its own commit: a missing script fails with exit 127, and an existing one runs with the caller's own token, so no privilege crosses. A dispatch on branch X takes X's script, which needs dispatch rights anyway.
  • r2 follow-up: fork-release.yml:176 sets T3CODE_DESKTOP_UPDATE_REPOSITORY=${{ github.repository }}, the name and first-priority setting fork: the server and CLI update from the desktop app's repository #1 reads (release-repository.ts:22, head c798cf3). The old name appears nowhere (shape test). Merge order: fork: release workflow for v<upstream>-fork.N on GitHub-hosted runners #2 first without fork: the server and CLI update from the desktop app's repository #1 makes the build fail loudly at check-bundle (shown above), so nothing is published and Update cannot follow upstream. fork: the server and CLI update from the desktop app's repository #1 first publishes nothing until fork: release workflow for v<upstream>-fork.N on GitHub-hosted runners #2. Together, desktop feed, web links and server agree.
  • State question. Rerun all jobs: a new N, and refuse_older passes because the same base sits below N+1. Re-run of failed jobs after another run published: refused at remove-drafts and at publish, as in r2. Stop half-way: the leftover draft is removed and the removal is logged. A failed release listing dies (pipefail plus releases()). An upstream release with no fork patches yet: it builds a rename-only release, but only with fork: the server and CLI update from the desktop app's repository #1 in the tree. Without fork: the server and CLI update from the desktop app's repository #1 the bundle check now refuses it, which is the correct loud failure. A rebase conflict is fork: follow each stable upstream release with the patch series #4's.
  • Security: no new triggers. Every new run: line takes its values through env or fixed paths. The write token is still only in publish, whose script now comes from workflow_sha. Action SHA pins are unchanged.
    findings: none
    optional nits:
  • version_gt ranks X.Y.Z-fork.N above plain vX.Y.Z (fork-release.sh:29-31), but the server's semver ranks it below. A plain vX.Y.Z release published in this fork would therefore pass refuse_older yet read as a downgrade on servers. The fork publishes no plain tags, so this is theory only. A one-line comment there would do.
  • The author's own "unproven" item (whether the real t3 program holds the quoted name) is now proven; see checks.

@scuttletoby
scuttletoby merged commit ee69d98 into main Nov 12, 2026
2 checks passed
scuttletoby pushed a commit that referenced this pull request Nov 12, 2026
main now carries #1, #2 and a newer upstream. fork-tests.yml keeps this
branch's version (it replaces #2's copy) and ports what the drift check
listed: test-delta.test.cjs in the release checks, Vitest's module cache
on the server shards (saved only by pushes), and no test-cache report
upload. Web was already split in two; the built-client boot stays in the
Build job.

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

Labels

size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants