From 6a24a1d67e9f985267e000d0e18465c6f5a9b357 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 7 Nov 2026 14:43:01 +0100 Subject: [PATCH 1/5] ci: run release jobs in a protected release environment Every job that reads a release credential now declares environment: release. Once the credentials move from repository secrets into that environment, only its approver can run them: write access no longer reaches the signing certificates or the release token from a branch workflow, nor cuts a release through workflow_dispatch. --- .github/workflows/announce-release.yml | 1 + .github/workflows/aur-publish.yml | 1 + .github/workflows/build.yml | 3 +++ .github/workflows/prerelease.yml | 1 + .github/workflows/promote.yml | 1 + .github/workflows/publish-msstore.yml | 1 + .github/workflows/publish-winget.yml | 1 + .github/workflows/update-homebrew-cask.yml | 1 + .../engineering/release-and-secrets.md | 16 +++++++++++++--- 9 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/announce-release.yml b/.github/workflows/announce-release.yml index 078195c882..01be8980ed 100644 --- a/.github/workflows/announce-release.yml +++ b/.github/workflows/announce-release.yml @@ -52,6 +52,7 @@ jobs: announce: name: Announce ${{ inputs.tag || github.event.release.tag_name }} runs-on: ubuntu-latest + environment: release steps: - name: Checkout uses: actions/checkout@v7 diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index b4eef00978..c2b70250bc 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -28,6 +28,7 @@ env: jobs: publish: runs-on: ubuntu-latest + environment: release if: (github.event_name == 'workflow_dispatch' || !github.event.release.prerelease) && vars.AUR_PACKAGE_NAME != '' steps: - name: Resolve and validate tag diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4adf2dbaae..57a5359766 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -300,6 +300,7 @@ jobs: # ffmpeg's configure, cargo and the output paths — four blind changes on a release # branch, none of them testable without a Mac. runs-on: ${{ matrix.arch == 'x64' && 'macos-15-intel' || 'macos-latest' }} + environment: release strategy: fail-fast: false matrix: @@ -883,6 +884,7 @@ jobs: publish-release: name: Publish GitHub release runs-on: ubuntu-latest + environment: release needs: - build-windows - build-macos @@ -1204,6 +1206,7 @@ jobs: publish-msstore: name: Publish to Microsoft Store runs-on: windows-latest + environment: release # The workflow-wide token is `contents: write` because publish-release needs # it. This job only reads: it checks the tree out so the CLI can identify the # project, downloads a same-run artifact (which uses the runtime token, not diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index e43b6ff200..ec06090ef8 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -32,6 +32,7 @@ jobs: prerelease: name: Cut pre-release runs-on: ubuntu-latest + environment: release steps: - name: Checkout uses: actions/checkout@v7 diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 97f04bb7cd..00aa26b008 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -26,6 +26,7 @@ jobs: promote: name: Promote RC to stable runs-on: ubuntu-latest + environment: release steps: - name: Checkout uses: actions/checkout@v7 diff --git a/.github/workflows/publish-msstore.yml b/.github/workflows/publish-msstore.yml index cda0c31e8d..e3ee8d6efa 100644 --- a/.github/workflows/publish-msstore.yml +++ b/.github/workflows/publish-msstore.yml @@ -45,6 +45,7 @@ jobs: submit: name: Submit ${{ inputs.release_tag }} to the Store runs-on: windows-latest + environment: release # No job-level `if` on MSSTORE_PRODUCT_ID, deliberately. build.yml can afford # to skip: it is one job among many in an automatic release. This one is # something a person asked for by hand, and a skipped job is green and diff --git a/.github/workflows/publish-winget.yml b/.github/workflows/publish-winget.yml index f72f0055ac..869c99a70a 100644 --- a/.github/workflows/publish-winget.yml +++ b/.github/workflows/publish-winget.yml @@ -19,6 +19,7 @@ permissions: jobs: publish: runs-on: windows-latest + environment: release # La condition de configuration a QUITTÉ ce `if`, et c'est tout l'objet du # changement. `vars.WINGET_IDENTIFIER != ''` ici rendait le job `skipped`, et un # job sauté est vert : les huit dernières releases, v1.8.0 comprise, ont toutes diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml index 8b9ba3127f..d56ce76447 100644 --- a/.github/workflows/update-homebrew-cask.yml +++ b/.github/workflows/update-homebrew-cask.yml @@ -16,6 +16,7 @@ permissions: jobs: update-cask: runs-on: ubuntu-latest + environment: release # The tap configuration has LEFT this `if:`, and that is the point of the change. # `vars.HOMEBREW_TAP_OWNER != '' && vars.HOMEBREW_TAP_REPO != ''` here made the job # `skipped`, and a skipped job is green: every release since this workflow was diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index c9b0a1d00c..50b4ee3b82 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -1,6 +1,6 @@ # Release and secrets -OpenScreen's release machinery lives in `.github/workflows/prerelease.yml`, `promote.yml`, and `build.yml`; its credentials are repository secrets and variables consumed by those workflows and the downstream package and Discord automations. This page is the operational reference for cutting releases and maintaining those credentials. +OpenScreen's release machinery lives in `.github/workflows/prerelease.yml`, `promote.yml`, and `build.yml`; its credentials are secrets of the `release` environment (see [Release environment](#release-environment)), plus repository secrets and variables for the Discord automations. This page is the operational reference for cutting releases and maintaining those credentials. ## Release flow @@ -106,6 +106,16 @@ No new workflow code is needed; the tag-pushed trigger is branch-agnostic. - **Between RC cut and promote**: any PR that merges during the RC window lands back in the empty `Next Release`. It is **not** retroactively added to `vX.Y.Z`. If a critical fix lands, cut `vX.Y.Z-rc.(N+1)` instead of promoting. - **At promote**: `promote.yml` closes the `vX.Y.Z` milestone and uses its closed issues to populate the Discord release announcement. +## Release environment + +Write access merges code; it does not cut, promote or publish a release. Three settings hold that line, and none of them lives in a file a pull request can change: + +- **The `release` environment** holds every release credential: `OPENSCREEN_RELEASE_TOKEN`, the six Apple signing secrets, the four Microsoft Store secrets, `WINGET_ACC_TOKEN`, `HOMEBREW_TAP_TOKEN` and `AUR_SSH_PRIVATE_KEY`. It requires the repository owner's approval before any job that names it starts. A repository secret would not do: it reaches any workflow pushed to any branch by anyone with write access, and `OPENSCREEN_RELEASE_TOKEN` acts as the owner, who bypasses the main ruleset. +- **The `release-tags` ruleset** stops anyone but the owner and `github-actions[bot]` from moving or deleting a `v*` tag. Creating one stays open, because `promote.yml` pushes the stable tag with `GITHUB_TOKEN` (see above) and the GitHub Actions app cannot be a bypass actor of a repository ruleset. A stray tag publishes nothing: the build it starts still waits for approval. +- **The `release-branches` ruleset** reserves `release/*` to the owner, so the frozen branch only moves through the owner or the token. + +Each job that reads a release credential declares `environment: release`: `prerelease`, `promote`, `build-macos`, `publish-release` and `publish-msstore` in `build.yml`, `publish-msstore.yml`, `publish-winget.yml`, `aur-publish.yml`, `update-homebrew-cask.yml` and `announce-release.yml`. A new job that needs one must declare it too, or it reads an empty value. Set or rotate a release secret with `gh secret set --env release --repo getopenscreen/openscreen`. + ## Required release credential ### `OPENSCREEN_RELEASE_TOKEN` @@ -121,10 +131,10 @@ Grant the token access only to the OpenScreen repository with: - Workflows: read and write, because the pushed release branch contains `.github/workflows/`. - Metadata: read-only. -Create a fine-grained token from GitHub settings, set a finite expiry, and save it as the repository secret `OPENSCREEN_RELEASE_TOKEN`: +Create a fine-grained token from GitHub settings, set a finite expiry, and save it as the `release` environment secret `OPENSCREEN_RELEASE_TOKEN`: ```bash -gh secret set OPENSCREEN_RELEASE_TOKEN --body "" --repo getopenscreen/openscreen +gh secret set OPENSCREEN_RELEASE_TOKEN --env release --body "" --repo getopenscreen/openscreen ``` Rotate it by creating the replacement with the same repository and scopes, updating the secret, verifying a non-destructive workflow/API operation, then revoking the old token. Do not revoke the previous token until the replacement is installed. From 81674c3787d671300e6d4e1ec498d36c3146710d Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 7 Nov 2026 14:56:00 +0100 Subject: [PATCH 2/5] docs(release): state what the environment does not stop, and how to move the secrets Write access can still publish a release by hand; variables stay at repository level; old release branches need the environment lines before the credentials move; repository copies must be deleted; the token is read from a prompt, not the command line. --- .../engineering/release-and-secrets.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index 50b4ee3b82..ec9bf54ef4 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -108,13 +108,20 @@ No new workflow code is needed; the tag-pushed trigger is branch-agnostic. ## Release environment -Write access merges code; it does not cut, promote or publish a release. Three settings hold that line, and none of them lives in a file a pull request can change: +Write access merges code; it does not reach the release credentials or run the release automation. Three settings hold that line, and none of them lives in a file a pull request can change: - **The `release` environment** holds every release credential: `OPENSCREEN_RELEASE_TOKEN`, the six Apple signing secrets, the four Microsoft Store secrets, `WINGET_ACC_TOKEN`, `HOMEBREW_TAP_TOKEN` and `AUR_SSH_PRIVATE_KEY`. It requires the repository owner's approval before any job that names it starts. A repository secret would not do: it reaches any workflow pushed to any branch by anyone with write access, and `OPENSCREEN_RELEASE_TOKEN` acts as the owner, who bypasses the main ruleset. - **The `release-tags` ruleset** stops anyone but the owner and `github-actions[bot]` from moving or deleting a `v*` tag. Creating one stays open, because `promote.yml` pushes the stable tag with `GITHUB_TOKEN` (see above) and the GitHub Actions app cannot be a bypass actor of a repository ruleset. A stray tag publishes nothing: the build it starts still waits for approval. - **The `release-branches` ruleset** reserves `release/*` to the owner, so the frozen branch only moves through the owner or the token. -Each job that reads a release credential declares `environment: release`: `prerelease`, `promote`, `build-macos`, `publish-release` and `publish-msstore` in `build.yml`, `publish-msstore.yml`, `publish-winget.yml`, `aur-publish.yml`, `update-homebrew-cask.yml` and `announce-release.yml`. A new job that needs one must declare it too, or it reads an empty value. Set or rotate a release secret with `gh secret set --env release --repo getopenscreen/openscreen`. +Each job that reads a release credential declares `environment: release`: `prerelease`, `promote`, `build-macos`, `publish-release` and `publish-msstore` in `build.yml`, `publish-msstore.yml`, `publish-winget.yml`, `aur-publish.yml`, `update-homebrew-cask.yml` and `announce-release.yml`. A new job that needs one must declare it too, or it reads an empty value. Set or rotate a release secret with `gh secret set --env release --repo getopenscreen/openscreen`, which prompts for the value. Variables (`AUR_PACKAGE_NAME`, `HOMEBREW_TAP_*`, the Discord channel IDs) stay repository variables: job-level `if:` conditions read them before the environment loads. + +**What it does not stop.** GitHub's write role can still create, edit and attach assets to a release by hand, and GitHub has no role that merges pull requests without also allowing that. Such a release is unsigned and starts no package publication without approval, but it is still a public release; enabling immutable releases protects the published ones from asset replacement, provided the pipeline uploads every asset before publishing. + +**Moving the credentials** is only done once both of these hold, in this order: + +1. Every release branch still in use carries the `environment: release` lines. A branch cut before them runs its own frozen `build.yml` from its tags, so cherry-pick the change onto it first, or its builds read empty values and the macOS job falls back to an ad-hoc signature. +2. Each secret is set in the environment, then its repository copy is deleted with `gh secret delete --repo getopenscreen/openscreen`. An environment secret does not remove the repository secret of the same name, and the repository copy stays readable from any branch until it is deleted. ## Required release credential @@ -134,7 +141,7 @@ Grant the token access only to the OpenScreen repository with: Create a fine-grained token from GitHub settings, set a finite expiry, and save it as the `release` environment secret `OPENSCREEN_RELEASE_TOKEN`: ```bash -gh secret set OPENSCREEN_RELEASE_TOKEN --env release --body "" --repo getopenscreen/openscreen +gh secret set OPENSCREEN_RELEASE_TOKEN --env release --repo getopenscreen/openscreen ``` Rotate it by creating the replacement with the same repository and scopes, updating the secret, verifying a non-destructive workflow/API operation, then revoking the old token. Do not revoke the previous token until the replacement is installed. From b4d011081136fece1efe223a1b225053442a5cf1 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 7 Nov 2026 15:03:08 +0100 Subject: [PATCH 3/5] docs(release): backport branches need the environment change before tagging --- technical-documentation/engineering/release-and-secrets.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index ec9bf54ef4..3719916c6f 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -93,7 +93,7 @@ The fallback skips milestone migration/closure, release-branch automation, main For a `v1.4.2` while `v1.5.0` is in flight: 1. Branch `release/1.4.x` from the `v1.4.0` (or `v1.4.1`) tag. -2. Cherry-pick the fix commits. +2. Cherry-pick the fix commits, plus the `environment: release` change if the base tag predates it (see [Release environment](#release-environment)). 3. Push the branch, then `git tag v1.4.2-rc.1` on the branch tip. 4. `git push origin release/1.4.x v1.4.2-rc.1` — `build.yml` works from any branch. @@ -120,7 +120,7 @@ Each job that reads a release credential declares `environment: release`: `prere **Moving the credentials** is only done once both of these hold, in this order: -1. Every release branch still in use carries the `environment: release` lines. A branch cut before them runs its own frozen `build.yml` from its tags, so cherry-pick the change onto it first, or its builds read empty values and the macOS job falls back to an ad-hoc signature. +1. Every release branch still in use carries the `environment: release` lines. A tag builds with the `build.yml` it points at, so a branch cut before them, or a backport branch cut later from an older tag, needs the change cherry-picked before its next tag; an existing tag never acquires it. Without it, its builds read empty values and the macOS job falls back to an ad-hoc signature. 2. Each secret is set in the environment, then its repository copy is deleted with `gh secret delete --repo getopenscreen/openscreen`. An environment secret does not remove the repository secret of the same name, and the repository copy stays readable from any branch until it is deleted. ## Required release credential From 057b291e76a0b7e8afc1c22bb957024afaf6a18c Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 7 Nov 2026 15:08:05 +0100 Subject: [PATCH 4/5] docs(release): immutable releases are on --- technical-documentation/engineering/release-and-secrets.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index 3719916c6f..c24318c27f 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -116,7 +116,9 @@ Write access merges code; it does not reach the release credentials or run the r Each job that reads a release credential declares `environment: release`: `prerelease`, `promote`, `build-macos`, `publish-release` and `publish-msstore` in `build.yml`, `publish-msstore.yml`, `publish-winget.yml`, `aur-publish.yml`, `update-homebrew-cask.yml` and `announce-release.yml`. A new job that needs one must declare it too, or it reads an empty value. Set or rotate a release secret with `gh secret set --env release --repo getopenscreen/openscreen`, which prompts for the value. Variables (`AUR_PACKAGE_NAME`, `HOMEBREW_TAP_*`, the Discord channel IDs) stay repository variables: job-level `if:` conditions read them before the environment loads. -**What it does not stop.** GitHub's write role can still create, edit and attach assets to a release by hand, and GitHub has no role that merges pull requests without also allowing that. Such a release is unsigned and starts no package publication without approval, but it is still a public release; enabling immutable releases protects the published ones from asset replacement, provided the pipeline uploads every asset before publishing. +**What it does not stop.** GitHub's write role can still create, edit and attach assets to a release by hand, and GitHub has no role that merges pull requests without also allowing that. Such a release is unsigned and starts no package publication without approval, but it is still a public release. + +**Immutable releases are on** (since 2026-11-07, for releases published from then on). Once published, a release's assets and tag are frozen; its title, notes, pre-release and latest flags stay editable. The pipeline is unaffected, because `gh release create` attaches the assets to a draft and publishes last. What it gives up: `publish-release`'s `--clobber` re-upload only works on a draft now, so a published release with a bad asset is fixed by a new version, and a deleted release's tag name can never be reused. **Moving the credentials** is only done once both of these hold, in this order: From 0a9d6ccb968822c40985df63b587bcaaa22442f4 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 7 Nov 2026 15:17:17 +0100 Subject: [PATCH 5/5] docs(release): old lines get the environment by hand; old tags are not rebuilt --- technical-documentation/engineering/release-and-secrets.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index c24318c27f..ac2ef98651 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -93,7 +93,7 @@ The fallback skips milestone migration/closure, release-branch automation, main For a `v1.4.2` while `v1.5.0` is in flight: 1. Branch `release/1.4.x` from the `v1.4.0` (or `v1.4.1`) tag. -2. Cherry-pick the fix commits, plus the `environment: release` change if the base tag predates it (see [Release environment](#release-environment)). +2. Cherry-pick the fix commits. If the base tag predates the `environment: release` lines (see [Release environment](#release-environment)), add `environment: release` by hand to each job of that branch's workflows that reads a release secret: the original commit touches workflows an old line may not have, so it does not cherry-pick cleanly. 3. Push the branch, then `git tag v1.4.2-rc.1` on the branch tip. 4. `git push origin release/1.4.x v1.4.2-rc.1` — `build.yml` works from any branch. @@ -122,7 +122,7 @@ Each job that reads a release credential declares `environment: release`: `prere **Moving the credentials** is only done once both of these hold, in this order: -1. Every release branch still in use carries the `environment: release` lines. A tag builds with the `build.yml` it points at, so a branch cut before them, or a backport branch cut later from an older tag, needs the change cherry-picked before its next tag; an existing tag never acquires it. Without it, its builds read empty values and the macOS job falls back to an ad-hoc signature. +1. Every release branch still in use carries the `environment: release` lines. A tag builds with the `build.yml` it points at, so a branch cut before them, or a backport branch cut later from an older tag, needs the change before its next tag. An existing tag never acquires it, so a tag older than the move is not rebuilt: a broken release is fixed by a new version, as immutable releases require anyway. Without it, its builds read empty values and the macOS job falls back to an ad-hoc signature. 2. Each secret is set in the environment, then its repository copy is deleted with `gh secret delete --repo getopenscreen/openscreen`. An environment secret does not remove the repository secret of the same name, and the repository copy stays readable from any branch until it is deleted. ## Required release credential