Repository navigation
Conversation
There was a problem hiding this comment.
I reviewed this by reading it; I couldn't run it. The release pipeline needs GitHub runners, the release environment, Apple/Azure signing, npm and R2. The update-server tests pass (17/17). The type-check scripts don't run in this environment.
The build/sign split, the manifest digest pinning and the package allowlist in publish-npm look solid. Three concerns, details inline:
- Every push to
devnow waits on thereleaseenvironment. Before, only versioned releases and beta did. Each push's sign-cli and npm/CLI publish jobs hit that gate at different stages, and the update server now accepts tokens only fromrelease. If that environment has required reviewers, one dev preview could need several separate approvals. The description doesn't mention this. - The update server allows
latestfor anyworkflow_dispatchondev, including dispatches without a bump or version, so it doesn't fully matchplan's rule. - The release commit's changelog includes today's date. A new dispatch after a failed release, on a later UTC day, can't reuse the existing tag and fails.
Public API: no package APIs change. Script.release is removed from the internal @opencode/script, and the update server's publish endpoint now requires the environment: release and event_name OIDC claims.
.github/workflows/publish.yml:654: Ondevpushes (dry_run=false) this and the other sign/publish jobs now run inrelease. The base workflow said dev previews don't wait for approval, and the update server now accepts onlyenvironment: releasetokens, so dev can't simply drop it. Ifreleasehas required reviewers, each dev push stops at several gates reached at different times (sign-cli-macos/windows, then publish-npm, then publish-cli). Is that intended? If not, a separate environment for dev previews, accepted by the update server for thedevchannel only, would keeplatestbehind approval without gating every push..github/workflows/publish.yml:164: The entry's date comes fromdate -uat run time. If a versioned release fails after the tag is pushed and is dispatched again on a later UTC day, the tree changes and the check below stops with "Tag already exists with different contents". So the advertised tag reuse only works within the same day, or when "Re-run failed jobs" keepssource's output. Taking the date from the existing tag's commit, or leaving it out of the tree check, would make retries reliable.
Related: #56297
| if (ref === "refs/heads/dev") return ["dev", "latest"] | ||
| if (ref === "refs/heads/beta") return ["beta"] | ||
| // Pushes to dev ship dev builds. Only a manual dispatch of a versioned release may ship to latest. | ||
| export function allowedChannels(claims: Pick<GitHubClaims, "ref" | "event_name">) { |
There was a problem hiding this comment.
This allows latest for any workflow_dispatch on dev, including dispatches with no bump or version, which plan treats as the dev channel. The description says the server enforces the same rule as plan, but only plan checks for the versioned inputs. If the server should enforce it on its own, it needs something that identifies a versioned run, for example a separate environment for versioned releases.
There was a problem hiding this comment.
The new commit only moves the logic in packages/ui/script/pack.ts into an exported pack() to satisfy lint. The output is the same: the UI package's exports are all plain strings, and nothing else imports the script. oxlint passes on it. The earlier point, that the update server accepts latest for unversioned dispatches, isn't addressed by this commit. Public API: unchanged by this commit.
There was a problem hiding this comment.
The new commit fixes a real problem: the redirect created an empty SHA256SUMS before find ran, so each manifest listed itself with a stale hash, and sha256sum -c --strict in the sign/publish jobs would have failed. I reproduced the failure with the old command and checked that the new one passes both the checksum check and the file-set diff. The remaining manifest writers name their files explicitly or use sha256sum -- *, so they never had the problem. Public API: unchanged by this commit.
There was a problem hiding this comment.
The new commit fixes a real problem. Every library tarball sets publishConfig: {"access": "public"}, so the earlier has("publishConfig") check would have made publish-npm reject all of them. I ran the new filter on every workspace package.json with a publishConfig: all pass. A publishConfig with another key such as registry is still rejected, and a malformed value makes jq fail, which stops the job before publishing. Public API: unchanged by this commit.
There was a problem hiding this comment.
The new commit updates the desktop packaging test to match the config change that removed signing. It now checks that mac.sign, mac.notarize and win.signtoolOptions are unset, where it used to expect a sign function, which the earlier removal would have broken. bun test electron-builder.config.test.ts passes (7/7). Public API: unchanged by this commit.
Supersedes #59028: this PR also includes its changes (every secret-using job scoped to the
releaseenvironment, CODEOWNERS, least-privilege permissions, and removal of the VS Code publish workflow).What changes
publish.ymlis split into three kinds of job:github.refonly. It is inline bash with no checkout.bun install, the build and pack scripts, electron-builder). They have no environment, no secrets and noid-token. Every artifact carries aSHA256SUMSmanifest.release. They never check out or execute repository code. They download artifacts, verify them, treat them as data, and run short inline steps with pinned actions and runner tools (codesign,notarytool,hdiutil,ditto, Azure Trusted Signing,npm publish <tgz>,aws s3 cp,skopeo,gh,git,curl,jq).latestand the stable desktop feed can only be reached throughworkflow_dispatchwithbumporversionondev.planrejects every other combination, and the update server now enforces the same rule (see below).Job graph
What runs per channel:
devdev--tag dev, R2, update feeddev(cli,cli-node)betabetabetalatestdevwithbump/versionv*tag, npm--tag latest, R2, update feedslatest, Homebrew, AUR, GHCR, GitHub Release, WinGet manifests, PR back todevprpull_requestonpublish.ymlSecrets and repo code per job
planpermissions: {})sourceGITHUB_TOKENcontents: write(pushes the tag)gitonly, hooks disabled)build-cli,build-node-*,build-npm,build-docker,build-desktop,package-desktop-windowsGITHUB_TOKENattestid-token,attestations: writesign-cli-macosreleaseAPPLE_CERTIFICATE,APPLE_CERTIFICATE_PASSWORDsign-cli-windowsreleaseAZURE_*sign-desktop-macosreleaseAPPLE_API_KEY_PATH,APPLE_API_KEY,APPLE_API_ISSUERsign-desktop-windowsreleaseAZURE_*sign-desktop-windows-installerreleaseAZURE_*publish-npmreleaseid-token)publish-clireleaseCLOUDFLARE_API_TOKEN, update-server OIDCpublish-desktopreleaseCLOUDFLARE_API_TOKEN, update-server OIDCpublish-dockerreleaseGITHUB_TOKENpackages: writepublish-homebrewreleaseOPENCODE_APP_SECRET(token scoped tohomebrew-tap), update-server OIDCpublish-aurreleaseAUR_KEY, update-server OIDCpublish-github-releasereleaseGITHUB_TOKENcontents: writewinget-manifestsrelease-prGITHUB_TOKENcontents/pull-requests: writeHow the "untrusted data" rule is enforced:
actions/checkout,setup-bun, a local composite action or a cache.SHA256SUMSand checks that the file set matches exactly. Artifacts produced by trusted sign jobs are also pinned by the manifest digest passed through job outputs, so an artifact swapped later (for example with a runtime token stolen by a build) fails verification.publish-npmalso verifies the build provenance attestations of the unsigned tarballs.planand from allowlists in the YAML.publish-npmreads each tarball'spackage.jsonwithjqand rejects any tarball with an unknown name, a wrong version, a duplicate,publishConfig, or an install script (only the two CLI wrappers may havepostinstall). It then publishes with--ignore-scripts --provenancefrom an empty directory and a controlleduserconfig.evaled. Inputs reachrun:only throughenv:..SRCINFO, WinGet manifests and electron-updater feeds are generated in the publish jobs from templates in the YAML, using hashes of the verified files. This deviates from "manifests as plain data from the build" on purpose: the build can't know the hashes of the signed archives, and a formula the build wrote would be Ruby thatbrewevaluates..SRCINFOis written directly, somakepkgand the apt install are gone. The AUR host key is pinned; there's no moressh-keyscan || true.Desktop
.app(--dir -c.mac.identity=null, tarred to keep symlinks), an unsignedwin-unpackeddirectory, and the Linux AppImage/deb/rpm. Signing, notarization and the Sentry plugin are removed fromelectron-builder.config.ts,electron.vite.config.tsandscripts/utils.ts.sign-desktop-macos): one job handles both architectures. It signs every Mach-O file and nested bundle deepest-first with hardened runtime and a timestamp; the bundledopencode-cligets its narrower entitlements. It then signs the app, notarizes both apps in one submission, staples and checks them withspctl, and only after that builds the.zip,.dmg(hdiutil) and.app.tar.gzfrom the stapled apps. The entitlements now live in the workflow, since they are security-relevant.sign-desktop-windowssigns every.exeand.nodein both unpacked apps.package-desktop-windows(no secrets) then runselectron-builder --prepackagedto wrap the signed app in the NSIS installer, andsign-desktop-windows-installersigns the installers.desktop*.yml, the update server'slatest*.yml) is computed inpublish-desktopfrom the final signed files (sha512, size), never taken from the build. File names and feed layout match today'sopencode.ai/update/api/latest/desktop/opencode/latest*.yml.What is not fully clean yet:
package-desktop-windowsruns repo config around an already-signed app. It holds no secrets; the installer it emits is signed afterwards. A malicious build could swap the app inside, but a malicious build could already ship malicious app code, so this is the same trust class.blockMapSize.Versioned release, the
v*tag, and getting back todevsource(no secrets,gitonly) checks out the dispatcheddevSHA. It sets everypackage.jsonandbun.lockworkspace version, prepends therelease_notesinput toCHANGELOG.md, commitsrelease: vX.Y.Z, and pushes only the tagvX.Y.Zat that commit. It refuses to touch anything but those files, and a rerun reuses the tag if the tree matches.release-prcreates branchrelease-vX.Y.Zat the tagged commit and opens a PR intodev.Why a PR, not a merge by the workflow. Rulesets block pushes to
dev, and letting the workflow merge would need it in thedevbypass list. That is a standing hole anydevworkflow could use. The PR costs a human click.Known trade-off. PRs opened with
GITHUB_TOKENdon't triggerpull_requestworkflows, so required checks won't start on their own. Close and reopen the PR, or push an empty commit, to start them. Or later giverelease-pra dedicated App with onlycontents+pull_requestson this repo, so the checks start by themselves. If PR creation is disabled, the job prints thegh pr createcommand instead of failing the release.v1
v1 is isolated, not migrated, in this PR:
v1runs thepublish.ymlfrom thev1branch's tree, so nothing ondevcan change it.packages/opencode, its own build and publish scripts,opencode-aion npm, GitHub Releases viagh release).Migrating it means a separate PR against
v1that applies the same split. Until then, isolate it:v1;release-v1(branchv1only) holding v1's secrets;opencode-aiand its platform packages pinned to that environment in npm. They are different npm packages, so the one-trusted-publisher-per-package limit doesn't collide with@opencode/*.planhere refusesrefs/heads/v1.Deleted
script/publish.tsscript/version.tsscript/update-artifact.tsscript/publish-winget.tsscript/sign-windows.ps1packages/cli/script/publish-aur.tspackages/cli/script/publish-homebrew.tspackages/desktop/scripts/publish.tspackages/ui/script/publish.tspackages/cli/script/entitlements.plist,packages/desktop/resources/entitlements*.plist.github/workflows/notify-discord.yml(Discord announcement; this also stops the V1 release announcements).github/workflows/publish-vscode.yml(from #59028).github/publish-python-sdk.yml(commented out, wrong directory)Also removed:
notify-discord-v2job;@sentry/vite-pluginfrom desktop,SENTRY_AUTH_TOKEN). Runtime Sentry viaVITE_SENTRY_DSNstays;opencode-cli-signed-windowsdownload;bun i -g opencode-ai;TAURI_SIGNING_*,OPENCODE_API_KEY,WINGET_GITHUB_TOKENandSENTRY_AUTH_TOKENreferences;GH_TOKEN(App token) passed into the build;@opencode/script;--sourcemapsflag, which no script read;The 14 package
publish.tsscripts becomepack.ts: same build, minusnpm view/npm publish, writing todist/npm.script/pack.tsdrives them for the build job.packages/cli/script/pack.tsreplaces the CLI publish script and only packs and archives.CODEOWNERS
Narrowed to
/.github/→@anomalyco/ci-owners, plus the existing app/desktop owners.script/andpackages/*/script/are no longer listed: those scripts only run in secretless build jobs, so changing them can't reach a secret or a publish credential. That only holds once npm, Azure and the update server pinenvironment: release(below). Until then a build job could still get an OIDC token those services accept. The release boundary is now.github/workflows/publish.yml, which only matters ifrequire_code_owner_reviewis turned on fordev.Update server (in this PR, deploys via
production)services/updatenow requiresenvironment == releaseon publish tokens, and allowslatestfromrefs/heads/devonly whenevent_name == workflow_dispatch. Both are true for today's workflow as well (#59028 putpublishinrelease), so it is safe to deploy before or after this merges.Settings Dax must change (none were changed)
@opencode/*package (the 14 libraries,@opencode/cli, the 12@opencode/cli-*and the 6@opencode/cli-node*) to repoanomalyco/opencode, workflowpublish.yml, environmentrelease. Then turn on "require 2FA and disallow tokens". Pinopencode-aiand its packages torelease-v1for v1.services/updatefrom this PR viaproduction.repo:anomalyco/opencode:environment:release, and remove any branch orpull_requestsubjects.APPLE_*into thereleaseenvironment and delete the repo-level copies. Rotate the cert and API key afterwards, since they have been readable from any branch.release:APPLE_*,AZURE_*,CLOUDFLARE_API_TOKEN,OPENCODE_APP_SECRET,AUR_KEY(remove the repo's access to the org secret). DeleteSENTRY_AUTH_TOKEN,DISCORD_WEBHOOK,TAURI_SIGNING_*,WINGET_GITHUB_TOKEN,OPENCODE_API_KEY(still used bydocs-locale-syncand v1, so move it rather than delete it if those stay), and the deadAPPLE_ID,APPLE_PASSWORD,KEYCHAIN_PASSWORD,SIGNPATH_*,BRENDAN_GITHUB_TOKEN,PORTER_*,DISCORD_ISSUES_WEBHOOK_URL.v*tags:sourcepushes the tag withGITHUB_TOKEN. Add the GitHub Actions app as a bypass actor on both overlappingv*tag rulesets, or merge them into one. Removeopencode-agentfrom the tag bypass.release-pr. Optionally restrict actions to the pinned list.opencode-agentApp: it's now only used to pushhomebrew-tap(token scoped to that repo). Dropsecrets,organization_secretsandworkflowswrite, and rotate its key. Better still, use a new App installed only onhomebrew-tap.CLOUDFLARE_API_TOKENcredentials with a bucket-scoped R2 key pair foropencode-production-files.publish-dockerusesGITHUB_TOKENpackages: write.releaseenvironment: keep branchesdevandbeta. Optionally turn onrequire_code_owner_reviewondevso changes to.github/need ci-owners.What CI on this PR proves
Run 40228493392 (channel
pr, temporarypull_requesttrigger) passed every job in the graph.Real builds:
@opencode/cli, the 12 CLI platform packages, and the 6cli-nodepackages;.appfor each macOS arch, unsignedwin-unpackedfor each Windows arch, and Linux AppImage/deb/rpm for both arches;electron-builder --prepackagedfor both Windows arches.Secret jobs in dry run (no environment, so no secrets):
sign-cli-macosandsign-desktop-macossign ad-hoc. The inside-out pass signs 25 items per app andcodesign --verify --deep --strictpasses. Then the zip, dmg and.app.tar.gzare built from the signed apps.publish-npmchecks all 34 tarballs against the allowlist and runsnpm publish --dry-runfor each.publish-cliandpublish-desktopbuild the exact update-server payloads, including thedesktop*.ymlsha512 manifests.skopeoreads the OCI image. The GitHub Release job assembles its assets.Bugs the PR run caught and this PR fixes:
SHA256SUMSlisted itself, which failed verification.publishConfig.accesswas rejected; now only keys other thanaccessare rejected.Not provable without secrets:
files-folder);v*tag push and the release PR.All of these fail closed.
Rollout
prthrough the temporarypull_requesttrigger. Build jobs run for real. Sign and publish jobs run without an environment: macOS signing is ad-hoc and notarization is skipped, Azure steps are skipped (unsigned files pass through so the packaging after them is still exercised),npm publish --dry-runruns, and uploads, pushes and update-server calls only print what they would do.production. It is compatible with today's workflow.pull_requesttrigger, or keep it with a widerpathsfilter as a permanent release-path check. Then merge. The firstdevpush exercises npmdev, R2 and thedevfeed.beta(or mergedevintobeta, which carries its own drifted copy of this workflow until then) to exercise desktop signing and thebetafeed../script/release.ts patch. Check the tag, the npmlatesttags,latest*.yml, Homebrew, AUR, GHCR, the GitHub Release, and the release PR.Residual risks
devis still built and signed with our identity and ships to the channel it was built for. This is inherent to an in-repo build. Release approval is out of scope..github/workflows/publish.ymlis the boundary. Anyone who can merge todevwithout code-owner review, or org admins who bypass the ruleset, can change what runs inrelease. Static secrets (Apple, AUR, Cloudflare, App key) can't be bound to one workflow file the way OIDC can.environment: release.attestand the dry-run jobs holdid-tokenoutside the environment.actions/download-artifact.skopeo,awsandghcome from the runner image.files-folder, npm provenance under trusted publishing (needs npm ≥ 11.5.1; the job pins Node 24.11.0),skopeo copy --allto GHCR, and immutable-release draft → publish. All have fail-closed checks, and none can publish tolatestfrom a push.