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
57 changes: 56 additions & 1 deletion .github/workflows/ci-poller.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,13 @@ jobs:
(
number=$(echo "$issue" | jq -r '.number')
title=$(echo "$issue" | jq -r '.title')
expected_security_labels=$(echo "$issue" | jq -c '[.labels[].name | select(. == "accepted" or . == "ci-pending" or . == "ci-ready" or . == "ci-failed" or . == "dry-run")] | sort')
body_file=$(mktemp)
resolver_output_file=""
updated_body_file=""
trap 'for file in "${body_file:-}" "${resolver_output_file:-}" "${updated_body_file:-}"; do [[ -z "$file" ]] || rm -f "$file"; done' EXIT
live_issue_file=""
live_body_file=""
trap 'for file in "${body_file:-}" "${resolver_output_file:-}" "${updated_body_file:-}" "${live_issue_file:-}" "${live_body_file:-}"; do [[ -z "$file" ]] || rm -f "$file"; done' EXIT
printf '%s' "$issue" | jq -jr '.body' > "$body_file"

if ! publish_input=$(PUBLISH_ISSUE_BODY_FILE="$body_file" PUBLISH_TITLE="$title" \
Expand All @@ -108,6 +111,7 @@ jobs:
exit 0
fi
repo=$(echo "$publish_input" | jq -r '.repo')
path=$(echo "$publish_input" | jq -r '.path')
version=$(echo "$publish_input" | jq -r '.version')
issue_sha=$(echo "$publish_input" | jq -r '.revision')

Expand Down Expand Up @@ -160,6 +164,7 @@ jobs:
exit 0
fi
gh issue edit "$number" -R "$GITHUB_REPOSITORY" --body-file "$updated_body_file"
cp "$updated_body_file" "$body_file"
fi

echo "Checking CI for ${repo}@${version} commit ${sha:0:8} (issue #${number})..."
Expand Down Expand Up @@ -215,6 +220,56 @@ jobs:
if [[ "$status_ok" == "true" \
&& "$pending_checks" == "0" && "$unsuccessful_checks" == "0" ]]; then

# Verify live approval: the current accepted-label actor must
# still have target access.
if ! APPROVAL_ISSUE_NUMBER="$number" \
APPROVAL_ISSUE_REPOSITORY="$GITHUB_REPOSITORY" \
APPROVAL_TOKEN="$GH_TOKEN" \
REQUIRE_AUTHORIZED="true" \
TARGET_REPOSITORY="${repo#getsentry/}" \
TARGET_REPOSITORY_PATH="$path" \
TARGET_REPOSITORY_TOKEN="$RELEASE_TOKEN" \
node .__publish__/src/publish/authorize-approval.js; then
echo " Approval is no longer authorized; revoking approval."
gh issue edit "$number" -R "$GITHUB_REPOSITORY" \
--remove-label "accepted" \
--remove-label "ci-pending"
gh issue comment "$number" -R "$GITHUB_REPOSITORY" \
--body "Approval is no longer valid for the target repository. Re-add the **accepted** label after resolving access."
exit 0
fi

# Verify live approval and request state immediately before the
# transition so no network lookup separates this fence from ci-ready.
live_issue_file=$(mktemp)
live_body_file=$(mktemp)
if ! gh issue view "$number" -R "$GITHUB_REPOSITORY" \
--json title,body,labels > "$live_issue_file"; then
echo "::warning::Could not retrieve live issue #${number}; revoking approval."
gh issue edit "$number" -R "$GITHUB_REPOSITORY" \
--remove-label "accepted" \
--remove-label "ci-pending"
exit 0
fi
live_title=$(jq -r '.title' "$live_issue_file")
jq -jr '.body' "$live_issue_file" > "$live_body_file"
has_accepted=$(jq '[.labels[].name == "accepted"] | any' "$live_issue_file")
has_ci_pending=$(jq '[.labels[].name == "ci-pending"] | any' "$live_issue_file")
live_security_labels=$(jq -c '[.labels[].name | select(. == "accepted" or . == "ci-pending" or . == "ci-ready" or . == "ci-failed" or . == "dry-run")] | sort' "$live_issue_file")
if [[ "$live_title" != "$title" \
|| "$has_accepted" != "true" \
|| "$has_ci_pending" != "true" \
|| "$live_security_labels" != "$expected_security_labels" ]] \
|| ! cmp -s "$body_file" "$live_body_file"; then
echo " Publish request changed after approval; revoking approval."
gh issue edit "$number" -R "$GITHUB_REPOSITORY" \
--remove-label "accepted" \
--remove-label "ci-pending"
gh issue comment "$number" -R "$GITHUB_REPOSITORY" \
--body "The publish request changed after approval. Review it and re-add the **accepted** label to retry."
exit 0
fi

echo " CI passed! Adding ci-ready label."
gh issue edit "$number" -R "$GITHUB_REPOSITORY" \
--remove-label "ci-pending" \
Expand Down
119 changes: 119 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,68 @@ jobs:
&& github.event.issue.state == 'open'
&& startsWith(github.event.issue.title, 'publish: ')
steps:
- name: Get publish code
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: yarn

- name: Install yarn dependencies
run: yarn install --frozen-lockfile

- name: Parse target repository
id: target
continue-on-error: true
run: node src/publish/inputs.js

- name: Get target repository token
id: target-token
if: steps.target.outcome == 'success'
continue-on-error: true
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
owner: getsentry
repositories: ${{ fromJSON(steps.target.outputs.result).repo }}

- name: Authorize approval
id: authorization
if: steps.target.outcome == 'success'
continue-on-error: true
env:
APPROVAL_ACTOR: ${{ github.actor }}
TARGET_REPOSITORY: ${{ fromJSON(steps.target.outputs.result).repo }}
TARGET_REPOSITORY_PATH: ${{ fromJSON(steps.target.outputs.result).path }}
TARGET_REPOSITORY_TOKEN: ${{ steps.target-token.outputs.token }}
run: node src/publish/authorize-approval.js

- name: Reject invalid approval
if: >-
always()
&& (
steps.target.outcome != 'success'
|| steps.authorization.outcome != 'success'
|| steps.authorization.outputs.authorized != 'true'
)
env:
GH_TOKEN: ${{ github.token }}
run: |
gh issue edit "${{ github.event.issue.number }}" \
-R "$GITHUB_REPOSITORY" \
--remove-label "accepted" \
--remove-label "ci-pending" \
--remove-label "ci-ready"
gh issue comment "${{ github.event.issue.number }}" \
-R "$GITHUB_REPOSITORY" \
--body "Approval rejected. The approver needs write access to the target repository."
exit 1

- name: Get auth token
id: token
uses: actions/create-github-app-token@v3
Expand All @@ -48,6 +110,7 @@ jobs:
# Label ops are idempotent: --add/--remove don't fail if already
# added/removed.
- name: Mark ci-pending
id: mark-pending
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
run: |
Expand All @@ -57,6 +120,42 @@ jobs:
--remove-label "ci-ready" \
--add-label "ci-pending"

- name: Revoke stranded approval
if: >-
always()
&& steps.authorization.outputs.authorized == 'true'
&& (
steps.token.outcome != 'success'
|| steps.mark-pending.outcome != 'success'
)
env:
GH_TOKEN: ${{ github.token }}
run: |
for attempt in 1 2 3; do
gh issue edit "${{ github.event.issue.number }}" \
-R "$GITHUB_REPOSITORY" \
--remove-label "accepted" || true
gh issue edit "${{ github.event.issue.number }}" \
-R "$GITHUB_REPOSITORY" \
--remove-label "ci-pending" || true
gh issue edit "${{ github.event.issue.number }}" \
-R "$GITHUB_REPOSITORY" \
--remove-label "ci-ready" || true
if labels=$(gh issue view "${{ github.event.issue.number }}" \
-R "$GITHUB_REPOSITORY" \
--json labels \
--jq '.labels[].name'); then
if ! grep -Fxq "accepted" <<< "$labels" \
&& ! grep -Fxq "ci-pending" <<< "$labels" \
&& ! grep -Fxq "ci-ready" <<< "$labels"; then
exit 0
fi
fi
echo "::warning::Approval cleanup attempt ${attempt} failed."
done
echo "::error::Could not revoke the stranded approval."
exit 1

- name: Comment on issue
env:
GH_TOKEN: ${{ github.token }}
Expand Down Expand Up @@ -135,6 +234,26 @@ jobs:
id: inputs
run: node .__publish__/src/publish/inputs.js

- name: Get approval target token
id: approval-target-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
owner: getsentry
repositories: ${{ fromJSON(steps.inputs.outputs.result).repo }}

- name: Verify approved target
env:
APPROVAL_ISSUE_NUMBER: ${{ github.event.issue.number }}
APPROVAL_ISSUE_REPOSITORY: ${{ github.repository }}
APPROVAL_TOKEN: ${{ github.token }}
REQUIRE_AUTHORIZED: "true"
TARGET_REPOSITORY: ${{ fromJSON(steps.inputs.outputs.result).repo }}
TARGET_REPOSITORY_PATH: ${{ fromJSON(steps.inputs.outputs.result).path }}
TARGET_REPOSITORY_TOKEN: ${{ steps.approval-target-token.outputs.token }}
run: node .__publish__/src/publish/authorize-approval.js

- name: Resolve CI-approved release revision
id: release-revision
env:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ flowchart TD
1. We do not want employees to publish through their own accounts
1. We do not want employees to have access to the global credentials
1. We do not want employees to build and publish releases from their machines
1. We want releases to require formal approvals from a limited set of release managers
1. We want releases to require formal approval from someone with write access to the target repository
1. We want all the above to not discourage from any engineer initiating a release

## Usage

1. Go to your repo and trigger the workflow (example: https://github.com/getsentry/sentry/actions/manual?workflow=.github%2Fworkflows%2Frelease.yml)
1. Once the workflow finishes, see the publishing request in this repo (example: #40)
1. Add the [**`accepted`**](https://github.com/getsentry/publish/labels/accepted) label to initiate publishing. Since this action requires elevated permissions, you may need to ask your team lead or manager
1. Add the [**`accepted`**](https://github.com/getsentry/publish/labels/accepted) label to initiate publishing. Approvers need write access to the target repository
1. Observe the issue for information about the triggered run
1. The issue will automatically be closed when publishing succeeds

Expand Down
Loading
Loading