Skip to content

ci: separate release builds from signing and publishing - #59046

Open
thdxr wants to merge 8 commits into
devfrom
release-split
Open

thdxr wants to merge 8 commits into
devfrom
release-split

Conversation

@thdxr

@thdxr thdxr commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Supersedes #59028: this PR also includes its changes (every secret-using job scoped to the release environment, CODEOWNERS, least-privilege permissions, and removal of the VS Code publish workflow).


What changes

publish.yml is split into three kinds of job:

  • plan decides the version, channel and npm dist-tag from the event, inputs and github.ref only. It is inline bash with no checkout.
  • build-* jobs run repository code (bun install, the build and pack scripts, electron-builder). They have no environment, no secrets and no id-token. Every artifact carries a SHA256SUMS manifest.
  • sign-* and publish-* jobs run in environment 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).

latest and the stable desktop feed can only be reached through workflow_dispatch with bump or version on dev. plan rejects every other combination, and the update server now enforces the same rule (see below).

Job graph

plan ─► source ─┬─► build-cli ─┬─► build-npm ─┬─► attest ─────────────────────┐
  (no secrets)  │              │  (+ node-cli) ├─► sign-cli-macos ────────────┤
                │              │               └─► sign-cli-windows ──────────┤
                │              │                                              ▼
                │              │                                      publish-npm ─► publish-cli ─► publish-homebrew
                │              │                                           └──────► publish-aur
                │              ├─► build-docker ─────────────────────────────────► publish-docker
                │              └─► build-desktop (×6) ─┬─► sign-desktop-macos ─────────────────┐
                │                                      └─► sign-desktop-windows                │
                │                                            └─► package-desktop-windows (×2)  │
                │                                                  └─► sign-desktop-windows-installer
                │                                                                              ▼
                │                                                                      publish-desktop
                │                         publish-cli + publish-desktop ─► publish-github-release
                │                         sign-cli-windows + installer   ─► winget-manifests
                └────────────────────────────────────────────────────────► release-pr (versioned only)
build-node-app-archive ─► build-node-cli (×5, dev only) ─► build-npm

What runs per channel:

Channel Trigger Builds Publishes
dev push to dev CLI, Node CLI, npm tarballs npm --tag dev, R2, update feed dev (cli, cli-node)
beta push to beta CLI, desktop desktop to R2 and update feed beta
latest dispatch on dev with bump/version CLI, npm, desktop, Docker release commit + v* tag, npm --tag latest, R2, update feeds latest, Homebrew, AUR, GHCR, GitHub Release, WinGet manifests, PR back to dev
pr temporary pull_request on publish.yml everything nothing (dry run, see below)

Secrets and repo code per job

Job Environment Secrets / tokens Runs repo code?
plan — none (permissions: {}) no
source — GITHUB_TOKEN contents: write (pushes the tag) no (git only, hooks disabled)
build-cli, build-node-*, build-npm, build-docker, build-desktop, package-desktop-windows — read-only GITHUB_TOKEN yes
attest — id-token, attestations: write no
sign-cli-macos release APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD no
sign-cli-windows release Azure OIDC + AZURE_* no
sign-desktop-macos release Apple cert, APPLE_API_KEY_PATH, APPLE_API_KEY, APPLE_API_ISSUER no
sign-desktop-windows release Azure OIDC + AZURE_* no
sign-desktop-windows-installer release Azure OIDC + AZURE_* no
publish-npm release npm OIDC (id-token) no
publish-cli release CLOUDFLARE_API_TOKEN, update-server OIDC no
publish-desktop release CLOUDFLARE_API_TOKEN, update-server OIDC no
publish-docker release GITHUB_TOKEN packages: write no
publish-homebrew release OPENCODE_APP_SECRET (token scoped to homebrew-tap), update-server OIDC no
publish-aur release AUR_KEY, update-server OIDC no
publish-github-release release GITHUB_TOKEN contents: write no
winget-manifests — none no
release-pr — GITHUB_TOKEN contents/pull-requests: write no

How the "untrusted data" rule is enforced:

  • No secret job uses actions/checkout, setup-bun, a local composite action or a cache.
  • Each job verifies its downloads against SHA256SUMS and 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-npm also verifies the build provenance attestations of the unsigned tarballs.
  • Names, destinations, dist-tags and versions come from plan and from allowlists in the YAML. publish-npm reads each tarball's package.json with jq and rejects any tarball with an unknown name, a wrong version, a duplicate, publishConfig, or an install script (only the two CLI wrappers may have postinstall). It then publishes with --ignore-scripts --provenance from an empty directory and a controlled userconfig.
  • Artifacts are never executed, and manifest contents are never evaled. Inputs reach run: only through env:.
  • The Homebrew formula, PKGBUILD, .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 that brew evaluates. .SRCINFO is written directly, so makepkg and the apt install are gone. The AUR host key is pinned; there's no more ssh-keyscan || true.

Desktop

  • Build (no secrets): electron-builder produces an unsigned .app (--dir -c.mac.identity=null, tarred to keep symlinks), an unsigned win-unpacked directory, and the Linux AppImage/deb/rpm. Signing, notarization and the Sentry plugin are removed from electron-builder.config.ts, electron.vite.config.ts and scripts/utils.ts.
  • macOS (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 bundled opencode-cli gets its narrower entitlements. It then signs the app, notarizes both apps in one submission, staples and checks them with spctl, and only after that builds the .zip, .dmg (hdiutil) and .app.tar.gz from the stapled apps. The entitlements now live in the workflow, since they are security-relevant.
  • Windows: sign-desktop-windows signs every .exe and .node in both unpacked apps. package-desktop-windows (no secrets) then runs electron-builder --prepackaged to wrap the signed app in the NSIS installer, and sign-desktop-windows-installer signs the installers.
  • Update metadata (desktop*.yml, the update server's latest*.yml) is computed in publish-desktop from the final signed files (sha512, size), never taken from the build. File names and feed layout match today's opencode.ai/update/api/latest/desktop/opencode/latest*.yml.

What is not fully clean yet:

  • The NSIS uninstaller embedded in the installer is unsigned. electron-builder used to sign it through the sign hook, and that would need secrets in a job that runs repo code.
  • package-desktop-windows runs 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.
  • There are no blockmaps, so differential updates are lost and updates download in full. Windows and macOS already had none in the live feeds; only the Linux AppImage had a blockMapSize.

Versioned release, the v* tag, and getting back to dev

  1. source (no secrets, git only) checks out the dispatched dev SHA. It sets every package.json and bun.lock workspace version, prepends the release_notes input to CHANGELOG.md, commits release: vX.Y.Z, and pushes only the tag vX.Y.Z at that commit. It refuses to touch anything but those files, and a rerun reuses the tag if the tree matches.
  2. Every build job checks out that commit, so the tag, the versions and the changelog match what shipped.
  3. After everything is published, release-pr creates branch release-vX.Y.Z at the tagged commit and opens a PR into dev.

Why a PR, not a merge by the workflow. Rulesets block pushes to dev, and letting the workflow merge would need it in the dev bypass list. That is a standing hole any dev workflow could use. The PR costs a human click.

Known trade-off. PRs opened with GITHUB_TOKEN don't trigger pull_request workflows, so required checks won't start on their own. Close and reopen the PR, or push an empty commit, to start them. Or later give release-pr a dedicated App with only contents + pull_requests on this repo, so the checks start by themselves. If PR creation is disabled, the job prints the gh pr create command instead of failing the release.

v1

v1 is isolated, not migrated, in this PR:

  • A push to v1 runs the publish.yml from the v1 branch's tree, so nothing on dev can change it.
  • v1 is a different codebase (packages/opencode, its own build and publish scripts, opencode-ai on npm, GitHub Releases via gh release).

Migrating it means a separate PR against v1 that applies the same split. Until then, isolate it:

  • a ruleset on v1;
  • environment release-v1 (branch v1 only) holding v1's secrets;
  • opencode-ai and 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/*.

plan here refuses refs/heads/v1.

Deleted

File Lines
script/publish.ts 133
script/version.ts 16
script/update-artifact.ts 118
script/publish-winget.ts 223
script/sign-windows.ps1 70
packages/cli/script/publish-aur.ts 99
packages/cli/script/publish-homebrew.ts 120
packages/desktop/scripts/publish.ts 148
packages/ui/script/publish.ts 28
packages/cli/script/entitlements.plist, packages/desktop/resources/entitlements*.plist 36 (moved into the workflow)
.github/workflows/notify-discord.yml (Discord announcement; this also stops the V1 release announcements) 14
.github/workflows/publish-vscode.yml (from #59028) 38
.github/publish-python-sdk.yml (commented out, wrong directory) 71

Also removed:

  • the notify-discord-v2 job;
  • the Sentry sourcemap upload (@sentry/vite-plugin from desktop, SENTRY_AUTH_TOKEN). Runtime Sentry via VITE_SENTRY_DSN stays;
  • the legacy CLI build and its artifacts, plus the dead opencode-cli-signed-windows download;
  • bun i -g opencode-ai;
  • TAURI_SIGNING_*, OPENCODE_API_KEY, WINGET_GITHUB_TOKEN and SENTRY_AUTH_TOKEN references;
  • GH_TOKEN (App token) passed into the build;
  • the npm-fetching version logic in @opencode/script;
  • the --sourcemaps flag, which no script read;
  • the apt caches.

The 14 package publish.ts scripts become pack.ts: same build, minus npm view/npm publish, writing to dist/npm. script/pack.ts drives them for the build job. packages/cli/script/pack.ts replaces the CLI publish script and only packs and archives.

CODEOWNERS

Narrowed to /.github/ → @anomalyco/ci-owners, plus the existing app/desktop owners. script/ and packages/*/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 pin environment: 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 if require_code_owner_review is turned on for dev.

Update server (in this PR, deploys via production)

services/update now requires environment == release on publish tokens, and allows latest from refs/heads/dev only when event_name == workflow_dispatch. Both are true for today's workflow as well (#59028 put publish in release), so it is safe to deploy before or after this merges.

Settings Dax must change (none were changed)

  1. npm trusted publishers: pin every @opencode/* package (the 14 libraries, @opencode/cli, the 12 @opencode/cli-* and the 6 @opencode/cli-node*) to repo anomalyco/opencode, workflow publish.yml, environment release. Then turn on "require 2FA and disallow tokens". Pin opencode-ai and its packages to release-v1 for v1.
  2. Update server: deploy services/update from this PR via production.
  3. Azure Trusted Signing: set the federated credential subject to repo:anomalyco/opencode:environment:release, and remove any branch or pull_request subjects.
  4. Apple: move APPLE_* into the release environment and delete the repo-level copies. Rotate the cert and API key afterwards, since they have been readable from any branch.
  5. Move secrets into release: APPLE_*, AZURE_*, CLOUDFLARE_API_TOKEN, OPENCODE_APP_SECRET, AUR_KEY (remove the repo's access to the org secret). Delete SENTRY_AUTH_TOKEN, DISCORD_WEBHOOK, TAURI_SIGNING_*, WINGET_GITHUB_TOKEN, OPENCODE_API_KEY (still used by docs-locale-sync and v1, so move it rather than delete it if those stay), and the dead APPLE_ID, APPLE_PASSWORD, KEYCHAIN_PASSWORD, SIGNPATH_*, BRENDAN_GITHUB_TOKEN, PORTER_*, DISCORD_ISSUES_WEBHOOK_URL.
  6. Rulesets for v* tags: source pushes the tag with GITHUB_TOKEN. Add the GitHub Actions app as a bypass actor on both overlapping v* tag rulesets, or merge them into one. Remove opencode-agent from the tag bypass.
  7. Actions settings: allow "GitHub Actions to create pull requests" for release-pr. Optionally restrict actions to the pinned list.
  8. opencode-agent App: it's now only used to push homebrew-tap (token scoped to that repo). Drop secrets, organization_secrets and workflows write, and rotate its key. Better still, use a new App installed only on homebrew-tap.
  9. R2 (recommended): replace the derived CLOUDFLARE_API_TOKEN credentials with a bucket-scoped R2 key pair for opencode-production-files.
  10. GHCR: no change; publish-docker uses GITHUB_TOKEN packages: write.
  11. release environment: keep branches dev and beta. Optionally turn on require_code_owner_review on dev so changes to .github/ need ci-owners.

What CI on this PR proves

Run 40228493392 (channel pr, temporary pull_request trigger) passed every job in the graph.

Real builds:

  • 12 CLI targets;
  • 5 Node CLI targets with their service smoke tests;
  • all npm tarballs: 14 libraries, @opencode/cli, the 12 CLI platform packages, and the 6 cli-node packages;
  • the Linux and Node archives;
  • the multi-arch Docker OCI image;
  • 6 desktop builds: an unsigned .app for each macOS arch, unsigned win-unpacked for each Windows arch, and Linux AppImage/deb/rpm for both arches;
  • NSIS installers via electron-builder --prepackaged for both Windows arches.

Secret jobs in dry run (no environment, so no secrets):

  • sign-cli-macos and sign-desktop-macos sign ad-hoc. The inside-out pass signs 25 items per app and codesign --verify --deep --strict passes. Then the zip, dmg and .app.tar.gz are built from the signed apps.
  • The Windows sign jobs skip Azure and pass the files through, so re-packing, zipping and installer handling still run.
  • publish-npm checks all 34 tarballs against the allowlist and runs npm publish --dry-run for each.
  • publish-cli and publish-desktop build the exact update-server payloads, including the desktop*.yml sha512 manifests.
  • Homebrew, AUR and WinGet render their manifests. skopeo reads the OCI image. The GitHub Release job assembles its assets.

Bugs the PR run caught and this PR fixes:

  • SHA256SUMS listed itself, which failed verification.
  • publishConfig.access was rejected; now only keys other than access are rejected.
  • A desktop config test still expected the sign hook.

Not provable without secrets:

  • Developer ID signing, notarization and stapling;
  • Azure Trusted Signing (including files-folder);
  • npm trusted publishing with provenance;
  • R2 uploads, update-server calls, and the GHCR, Homebrew and AUR pushes;
  • the immutable GitHub Release publish;
  • the v* tag push and the release PR.

All of these fail closed.

Rollout

  1. This PR's own CI runs the whole graph as channel pr through the temporary pull_request trigger. 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-run runs, and uploads, pushes and update-server calls only print what they would do.
  2. Deploy the update-server change via production. It is compatible with today's workflow.
  3. Move the secrets and set npm, Azure and rulesets as above.
  4. Remove the temporary pull_request trigger, or keep it with a wider paths filter as a permanent release-path check. Then merge. The first dev push exercises npm dev, R2 and the dev feed.
  5. Push to beta (or merge dev into beta, which carries its own drifted copy of this workflow until then) to exercise desktop signing and the beta feed.
  6. Cut a patch release with ./script/release.ts patch. Check the tag, the npm latest tags, latest*.yml, Homebrew, AUR, GHCR, the GitHub Release, and the release PR.

Residual risks

  • Malicious code merged to dev is 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.yml is the boundary. Anyone who can merge to dev without code-owner review, or org admins who bypass the ruleset, can change what runs in release. Static secrets (Apple, AUR, Cloudflare, App key) can't be bound to one workflow file the way OIDC can.
  • OIDC consumers must pin environment: release. attest and the dry-run jobs hold id-token outside the environment.
  • Windows: the embedded NSIS uninstaller is unsigned, and there are no blockmaps (full-size updates).
  • Zip handling: download-artifact unzips archives produced by build jobs. Protection against hostile zips (zip-slip) relies on the pinned actions/download-artifact.
  • Pinned but large tool surface in secret jobs: Azure's signing action installs its PowerShell module at run time; skopeo, aws and gh come from the runner image.
  • Runtime: a versioned release adds a round trip for Windows (sign app → package → sign installer). macOS notarizes both apps in one submission.
  • Unproven until the first real run: Developer ID signing and notarization of the inside-out signed app, Azure signing with files-folder, npm provenance under trusted publishing (needs npm ≥ 11.5.1; the job pins Node 24.11.0), skopeo copy --all to GHCR, and immutable-release draft → publish. All have fail-closed checks, and none can publish to latest from a push.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Every push to dev now waits on the release environment. 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 from release. If that environment has required reviewers, one dev preview could need several separate approvals. The description doesn't mention this.
  2. The update server allows latest for any workflow_dispatch on dev, including dispatches without a bump or version, so it doesn't fully match plan's rule.
  3. 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: On dev pushes (dry_run=false) this and the other sign/publish jobs now run in release. The base workflow said dev previews don't wait for approval, and the update server now accepts only environment: release tokens, so dev can't simply drop it. If release has 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 the dev channel only, would keep latest behind approval without gating every push.
  • .github/workflows/publish.yml:164: The entry's date comes from date -u at 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" keeps source'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">) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch has not been deployed

No deployments
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