Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 105 additions & 21 deletions .github/workflows/release-from-notes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -204,22 +204,35 @@ jobs:
} >> "${GITHUB_STEP_SUMMARY}"

# ---------------------------------------------------------------------------
# prepare-release: the ONLY job with contents:write before publication.
# Bumps the version in build.gradle.kts with sed (NO Gradle execution, so no
# repository-controlled code runs in this job at all), creates the release
# commit + next development commit, tags, and pushes atomically to main.
# prepare-release: the ONLY job with contents:write/pull-requests:write before
# publication. Bumps the version in build.gradle.kts with sed (NO Gradle
# execution, so no repository-controlled code runs in this job at all),
# creates the release commit + next development commit on a throwaway
# release/<version> branch, and lands them on main via a PR (auto-merged,
# not pushed directly).
#
# Why a PR instead of a direct push: main's ruleset requires changes to go
# through a pull request. A direct push would need a bypass actor added to
# that ruleset -- but ruleset bypass is granted to an IDENTITY (e.g. "GitHub
# Actions"), not to this one job. Any other workflow in the repo capable of
# authenticating as that identity would inherit the same bypass, which is a
# far bigger hole than this job's own blast radius. Merging a PR needs no
# bypass at all: it is the standard, already-allowed path, so this design
# adds no new privileged identity to the repository.
#
# Receives NO Central/npm/signing secrets; the GITHUB_TOKEN exists only in
# the push step. Fails (non-fast-forward) if main moved since dispatch.
# the push/PR steps. A genuine conflict (something else changed
# build.gradle.kts concurrently) fails the merge instead of silently
# resolving it.
# ---------------------------------------------------------------------------
prepare-release:
name: Create release commit and tag
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 20
needs: validate
permissions:
# push release commit + tag to main (requires ruleset bypass for the
# GitHub Actions app, see docs/release-security.md#repository-rules)
contents: write
contents: write # push the release branch and the tag
pull-requests: write # open and auto-merge the release PR
outputs:
release_commit_sha: ${{ steps.commit.outputs.release_commit_sha }}
steps:
Expand All @@ -230,6 +243,16 @@ jobs:
fetch-depth: 0
persist-credentials: false

- name: Refuse a stale release branch
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
set -euo pipefail
if git ls-remote --exit-code --heads origin "release/${VERSION}" >/dev/null 2>&1; then
echo "::error::Branch release/${VERSION} already exists on origin (leftover from a previous run?). Delete it and re-run."
exit 1
fi

- name: Set release version and create release commit and tag
id: commit
env:
Expand Down Expand Up @@ -263,6 +286,8 @@ jobs:

set_version "${VERSION}"
git commit -am "chore(release): release ${VERSION}"
# Tag object is created locally now (pointing at this exact commit)
# but only pushed once the PR below has actually merged into main.
git tag -a "${TAG}" -m "Release ${VERSION}"
echo "release_commit_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"

Expand All @@ -280,30 +305,89 @@ jobs:
git commit -am "chore(release): prepare next development version ${DEV_VERSION}"

# The token is only present in this step's env, never persisted to disk
# or exposed to any build tool. The non-fast-forward push doubles as the
# "fail if main moved during the release" policy.
- name: Push release commits and tag to main
# or exposed to any build tool.
- name: Push release branch
env:
GITHUB_TOKEN: ${{ github.token }}
RELEASE_SHA: ${{ needs.validate.outputs.release_sha }}
TAG: ${{ needs.validate.outputs.tag }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
set -euo pipefail

git fetch origin main
if [[ "$(git rev-parse origin/main)" != "${RELEASE_SHA}" ]]; then
echo "::error::main moved while the release was running (expected ${RELEASE_SHA}). Start a new release run."
exit 1
fi
ASKPASS="$(mktemp)"
trap 'rm -f "${ASKPASS}"' EXIT
printf '#!/bin/sh\nprintf %%s "${GITHUB_TOKEN}"\n' > "${ASKPASS}"
chmod 700 "${ASKPASS}"

GIT_ASKPASS="${ASKPASS}" git push \
"https://x-access-token@github.com/${GITHUB_REPOSITORY}.git" \
"HEAD:refs/heads/release/${VERSION}"

- name: Open and auto-merge release PR
id: pr
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
set -euo pipefail
PR_URL="$(gh pr create --base main --head "release/${VERSION}" \
--title "chore(release): ${VERSION}" \
--body "Automated release commit and next-development version bump for ${TAG}. This PR is required because main is protected; once merged, the release continues automatically.")"
PR_NUMBER="${PR_URL##*/}"
echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}"
gh pr merge "${PR_NUMBER}" --merge --auto --delete-branch

# Waits for GitHub's native auto-merge to land the PR (e.g. once required
# status checks pass). Fails fast on a real conflict or on the PR being
# closed unmerged, instead of spinning for the full timeout.
- name: Wait for release PR to merge
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
run: |
set -euo pipefail
ELAPSED=0
TIMEOUT=900
while true; do
STATE="$(gh pr view "${PR_NUMBER}" --json state --jq '.state')"
if [[ "${STATE}" == "MERGED" ]]; then
break
fi
if [[ "${STATE}" == "CLOSED" ]]; then
echo "::error::Release PR #${PR_NUMBER} was closed without merging."
exit 1
fi
MERGEABLE="$(gh pr view "${PR_NUMBER}" --json mergeable --jq '.mergeable')"
if [[ "${MERGEABLE}" == "CONFLICTING" ]]; then
echo "::error::Release PR #${PR_NUMBER} conflicts with main (something else changed build.gradle.kts concurrently). Resolve manually and re-run the release."
exit 1
fi
if (( ELAPSED >= TIMEOUT )); then
echo "::error::Timed out waiting for release PR #${PR_NUMBER} to merge (state=${STATE}). Check required status checks / branch protection."
exit 1
fi
sleep 15
ELAPSED=$((ELAPSED + 15))
done

# Only pushed after the commit above is confirmed to be reachable from
# main via the merge, so the tag can never point at unmerged content.
- name: Push release tag
env:
GITHUB_TOKEN: ${{ github.token }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
set -euo pipefail
ASKPASS="$(mktemp)"
trap 'rm -f "${ASKPASS}"' EXIT
printf '#!/bin/sh\nprintf %%s "${GITHUB_TOKEN}"\n' > "${ASKPASS}"
chmod 700 "${ASKPASS}"

GIT_ASKPASS="${ASKPASS}" git push --atomic \
GIT_ASKPASS="${ASKPASS}" git push \
"https://x-access-token@github.com/${GITHUB_REPOSITORY}.git" \
"HEAD:refs/heads/main" "refs/tags/${TAG}"
"refs/tags/${TAG}"

# ---------------------------------------------------------------------------
# build-and-test: full Gradle build from the immutable release commit with NO
Expand Down
68 changes: 48 additions & 20 deletions docs/release-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,28 @@ token. The two privileged jobs check out no repository code: the only code
running while credentials are available is the audited shell inline in the
workflow file (itself protected on `main`).

## Trusted commit capture and the "main moved" policy

Identical to zenwave-sdk: `github.sha` is resolved once at dispatch; the run
refuses non-`main` dispatch, verifies reachability from `origin/main`, and
threads the SHA (and derived release commit) through every job. **Policy: fail
if `main` moved** — `prepare-release` re-checks `origin/main` before its
atomic, non-fast-forward push; if you pushed mid-release, the run fails before
any tag or commit exists.
## Trusted commit capture and landing the release commit on main

`github.sha` is resolved once at dispatch; the run refuses non-`main`
dispatch, verifies reachability from `origin/main`, and threads the SHA (and
derived release commit) through every job.

`prepare-release` does **not** push directly to `main`. It pushes the release
commit and the next-SNAPSHOT commit to a throwaway `release/<version>`
branch, opens a PR into `main`, enables GitHub's native auto-merge, and waits
for the PR to actually merge before pushing the tag. This is deliberate:
`main`'s ruleset requires a pull request, and the only way to push directly
despite that would be adding a bypass actor to the ruleset. Ruleset bypass is
granted to an **identity** (e.g. "GitHub Actions"), not to this one job —
every other workflow in the repository capable of authenticating as that
identity would inherit the same bypass, which is a strictly larger hole than
this job's own blast radius. Merging a PR needs no bypass at all: it is the
standard, already-allowed path, so this design adds no new privileged
identity to the repository. A genuine conflict (something else changed
`build.gradle.kts` concurrently) fails the wait step outright instead of
silently resolving; the run also refuses to start if a stale
`release/<version>` branch is still sitting on origin from a previous failed
attempt.

## Release commit and tag

Expand All @@ -86,10 +100,11 @@ The version bump is a deterministic `sed` replacement of the single top-level
invocation, so the only job with a write token before publication executes no
repository-controlled code at all. The job fails if the file does not contain
exactly one version line, if the result does not match the expected line, or
if anything other than `build.gradle.kts` changed. Both commits (release +
next SNAPSHOT) and the annotated tag `vX.Y.Z` are pushed atomically. The
privileged jobs later re-verify tag → release-commit → reachable-from-main via
the GitHub API.
if anything other than `build.gradle.kts` changed. The annotated tag `vX.Y.Z`
is created locally right after the release commit but is only pushed after
the release PR has actually merged, so it can never point at unmerged
content. The privileged jobs later re-verify tag → release-commit →
reachable-from-main via the GitHub API.

## Artifact trust boundary

Expand Down Expand Up @@ -203,16 +218,23 @@ required reviewers.

### Repository rules

Same as zenwave-sdk:

- **`main` ruleset**: require PR (approvals 0 while solo — you cannot approve
your own PRs; enable code-owner review once a second maintainer exists),
require status checks, block force pushes and deletion, enforce for admins,
bypass **only** for the GitHub Actions app (so `prepare-release` can push
the release commits), Settings → Actions → Workflow permissions set to
**read-only** default.
- **Tag ruleset `v*`**: creation only via the bypass actor; updates and
deletions blocked for everyone (immutable tags).
block force pushes and deletion, enforce for admins, Settings → Actions →
Workflow permissions set to **read-only** default. **No bypass actor is
configured or needed** — `prepare-release` lands its commits through a
real, auto-merged PR (see above), not a direct push, so there is no
privileged identity to grant. If you also require status checks on `main`,
make sure at least one check actually runs on `pull_request` events (this
repo's CI currently only triggers on `push`/`workflow_dispatch`), otherwise
the release PR's auto-merge will wait until `prepare-release`'s own
15-minute timeout and fail.
- **Tag ruleset `v*`**: block updates and deletions for everyone (immutable
tags). Tag **creation** does not need to be restricted to a bypass actor:
by the time `prepare-release` pushes a tag, the commit it points to has
already gone through the required-PR merge into `main`, so an unrestricted
`git push origin refs/tags/vX.Y.Z` from the default token grants no extra
power — it can only ever label a commit that already passed review.
- **CODEOWNERS** (`.github/CODEOWNERS`) covers `.github/`, Gradle build files
and wrapper, `kotlin-js-store/` (Kotlin/JS yarn lockfile),
`nodejs-test-project/`, `release-notes/` and the release docs. Ineffective
Expand All @@ -231,6 +253,12 @@ Identical model to zenwave-sdk:
can never rebuild a different commit under the same version. "Re-run all
jobs" after `prepare-release` succeeded fails fast in `validate` (tag
exists): intentional duplicate protection. Artifacts are retained 14 days.
- if `prepare-release` itself fails **after** pushing the `release/<version>`
branch (e.g. the merge-wait step timed out because required status checks
never reported), do not just re-run the job — it will refuse to start
because that branch still exists on origin. Check the open release PR
first: merge it manually if it is just stalled, or close the PR and delete
the branch if you want to abort, then re-run.

## Snapshot publication

Expand Down
Loading