diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml new file mode 100644 index 00000000..dcac5322 --- /dev/null +++ b/.github/workflows/publish-prerelease.yml @@ -0,0 +1,60 @@ +name: Publish Prerelease +# Publishes a draft release as a GitHub PRERELEASE (a release candidate), but +# only after the full Verify Release suite (signed checksums, Windows +# Authenticode, macOS notarization) passes. Same gate as publish-release.yml; +# the only difference is the release is published with the prerelease flag and +# is NOT marked latest, so github.com/.../releases/latest and every customer +# keep resolving the previous release. +# +# agent-api's release sync mirrors a prerelease as a release-channel candidate +# that only rc tenants receive. Once the candidate is validated, run +# publish-release.yml with the same tag to promote these exact bytes to GA. +on: + workflow_dispatch: + inputs: + tag: + description: 'Draft release tag to verify and publish as a prerelease, e.g. v1.18.0.' + required: true + type: string + +permissions: {} + +jobs: + verify: + name: Verify release + uses: ./.github/workflows/verify-release.yml + permissions: + contents: write # required to see DRAFT releases; verification only downloads assets + with: + tag: ${{ inputs.tag }} + + publish: + name: Publish prerelease + needs: verify + runs-on: ubuntu-latest + permissions: + contents: write # gh release edit needs push access to flip draft -> published + env: + TAG: ${{ inputs.tag }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Publish draft release as a prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Only a draft can become a release candidate. A published release is + # immutable and may already be GA; flagging it as a prerelease would + # not hide it from anyone (agent-api keeps a promoted release GA). + is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" + if [ "$is_draft" != "true" ]; then + echo "::error::Release ${TAG} is not a draft. Only a draft release can be published as a prerelease." + exit 1 + fi + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease --latest=false + echo "Release ${TAG} is now public as a prerelease (not latest)." + echo "✅ Release \`${TAG}\` verified and published as a prerelease." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 7a66d9b9..194699c1 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,13 +1,14 @@ name: Publish Release -# Publishes a draft release, but only after the full Verify Release suite +# Publishes a draft release, or promotes a prerelease published by +# publish-prerelease.yml, but only after the full Verify Release suite # (signed checksums, Windows Authenticode, macOS notarization) passes. This # replaces the manual: -# gh release edit vX.Y.Z --repo --draft=false --latest +# gh release edit vX.Y.Z --repo --draft=false --prerelease=false --latest on: workflow_dispatch: inputs: tag: - description: 'Draft release tag to verify and publish, e.g. v1.12.0.' + description: 'Draft or prerelease tag to verify and publish as latest, e.g. v1.12.0.' required: true type: string @@ -39,11 +40,13 @@ jobs: with: egress-policy: audit - - name: Make draft release public and mark as latest + - name: Make release public, clear prerelease, and mark as latest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + # --prerelease=false promotes a release candidate; GitHub never marks + # a prerelease as latest. It is a no-op for a draft. + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease=false --latest echo "Release ${TAG} is now public and marked as latest." echo "✅ Release \`${TAG}\` verified and published as latest." >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/release-process.md b/docs/release-process.md index db2fad3a..52299b1d 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -8,11 +8,12 @@ This document describes how releases are created, signed, notarized, and verifie ## Overview -Releases are a three-phase process, where each phase is its own GitHub Actions workflow: +Releases are built, signed and verified in three phases, each its own GitHub Actions workflow, and then go out as a release candidate before GA: 1. **Build and draft (`release.yml`)**: builds binaries for all platforms, Authenticode-signs the Windows `.exe`s and `.msi`s via Azure Trusted Signing, Sigstore-signs the Linux and Windows artifacts (after Authenticode for Windows, so cosign bundles match the bytes users download), creates a **draft** release with the unsigned macOS binary, and emits SLSA build provenance attestations for every artifact except macOS. The Windows signing job runs in the `release` GitHub Environment, which requires two reviewers and is restricted to `main`. 2. **Sign and notarize macOS (`release-macos.yml`)**: a dispatch-only workflow that downloads the unsigned macOS binary from the draft release, codesigns it with the Apple Developer ID certificate, notarizes it with Apple, then Sigstore-signs and attests the final notarized bytes and uploads them to the draft release. It runs in the `release` environment as well. Because Apple notarization can hang, this is kept separate from `release.yml` so it can be re-run on its own without repeating the build. A companion workflow, `check-notarization-status.yml`, inspects a notarization submission that did not finish in time. 3. **Verify and publish (`verify-release.yml` + `publish-release.yml`)**: `verify-release.yml` checks that every distributable binary has a valid out-of-band signed checksum (`.sha256.sig`), that the Windows `.exe`s and `.msi`s are Authenticode-signed and timestamped, and that the macOS binary is codesigned and notarized. It can be dispatched on its own to check that a draft (or published) release is healthy. `publish-release.yml` calls it as a reusable workflow and, only if every check passes, marks the draft release as published and latest. +4. **Release candidate (`publish-prerelease.yml`), the default path**: the same verification gate, but the draft is published as a GitHub **prerelease** that is not marked latest. agent-api serves a prerelease only to its rc tenants (our own devices), so the release is tested end to end before customers get it. Once it passes, `publish-release.yml` promotes the same release (same tag, same bytes) to latest. The full candidate-to-GA runbook, including the agent-api steps, is in agent-api `docs/developer-mdm/release-candidates-runbook.md`. macOS signing runs entirely in GitHub Actions; no local Mac is required. The signing credentials are stored as encrypted secrets in the `release` environment (see [Signing credentials](#signing-credentials-one-time-setup)). @@ -87,7 +88,25 @@ A final `gate` job gives a single pass/fail status for the release. > The signed checksums are produced and uploaded out-of-band; make sure the `.sha256.sig` assets are attached to the draft before running verification, or the run will fail. -### 5. Publish the release +### 5. Publish as a release candidate (prerelease) + +1. Go to [Actions > Publish Prerelease](https://github.com/step-security/dev-machine-guard/actions/workflows/publish-prerelease.yml) +2. Click **Run workflow** and enter the release tag (e.g. `v1.9.1`) + +The workflow re-runs the entire Verify Release suite, and only if every check passes runs the equivalent of: + +```bash +gh release edit "v${VERSION}" --repo step-security/dev-machine-guard \ + --draft=false --prerelease --latest=false +``` + +It refuses a release that is not a draft. The release is now public and immutable, but **Latest** stays on the previous release and agent-api serves it only to rc tenants. Mirror it into agent-api and test it as described in agent-api `docs/developer-mdm/release-candidates-runbook.md`. + +If the candidate fails, do not promote it: pull it in agent-api and cut the next patch version. A published release's assets and tag cannot change, so a version is never reused. + +### 6. Publish the release + +Run this once the release candidate has passed (or directly on a draft, to skip the candidate stage). 1. Go to [Actions > Publish Release](https://github.com/step-security/dev-machine-guard/actions/workflows/publish-release.yml) 2. Click **Run workflow** and enter the release tag (e.g. `v1.9.1`) @@ -96,10 +115,12 @@ The workflow re-runs the entire Verify Release suite above (it calls `verify-rel ```bash gh release edit "v${VERSION}" --repo step-security/dev-machine-guard \ - --draft=false --latest + --draft=false --prerelease=false --latest ``` -so a draft can never be published without passing verification. +so a draft can never be published, and a candidate never promoted, without passing verification. `--prerelease=false` is what promotes a candidate: GitHub rejects marking a prerelease as latest (`Latest release cannot be draft or prerelease`). + +Never flip a published release back to prerelease. GitHub allows it even for an immutable release and moves **Latest** back to the previous version, but agent-api keeps the version GA. ---