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
62 changes: 36 additions & 26 deletions .github/workflows/ci-poller.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ jobs:
private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
owner: getsentry # create token with access to all getsentry repos

- name: Check out publish controller
uses: actions/checkout@v7
with:
path: .__publish__

- name: Check CI status for ci-pending issues
env:
# Use the sentry-internal-app token for label changes on this
Expand Down Expand Up @@ -88,30 +93,23 @@ jobs:

# Check each issue's CI status
echo "$issues" | jq -c '.[]' | while read -r issue; do
(
number=$(echo "$issue" | jq -r '.number')
title=$(echo "$issue" | jq -r '.title')
body=$(echo "$issue" | jq -r '.body')

# Parse repo and version from title: "publish: owner/repo[/path]@version"
# Only take owner/repo (first two segments) — monorepos like
# "getsentry/relay/py@0.9.26" have a path suffix that isn't part
# of the GitHub repo name.
repo=$(echo "$title" | sed -n 's|^publish: \([^/]*/[^/@]*\).*@.*|\1|p')
version=$(echo "$title" | sed -n 's/^publish: .*@\(.*\)/\1/p')

if [[ -z "$repo" || -z "$version" ]]; then
echo "::warning::Could not parse repo/version from issue #${number}: ${title}"
continue
fi

# Extract the commit SHA from the "View check runs" link in the issue body.
# Link format: https://github.com/{owner}/{repo}/commit/{SHA}/checks/
issue_sha=$(echo "$body" | grep -oP '(?<=commit/)[0-9a-f]{40}(?=/checks)' || true)

if [[ -z "$issue_sha" ]]; then
echo "::warning::Could not extract commit SHA from issue #${number} body, skipping."
continue
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
printf '%s' "$issue" | jq -jr '.body' > "$body_file"

if ! publish_input=$(PUBLISH_ISSUE_BODY_FILE="$body_file" PUBLISH_TITLE="$title" \
node .__publish__/src/publish/resolve-ci-poller-input.js); then
echo "::warning::Could not parse canonical publish input for issue #${number}, skipping."
exit 0
fi
repo=$(echo "$publish_input" | jq -r '.repo')
version=$(echo "$publish_input" | jq -r '.version')
issue_sha=$(echo "$publish_input" | jq -r '.revision')

# Resolve the release branch name from the original commit's check
# suites (avoids hard-coding "release/{version}" since repos can
Expand Down Expand Up @@ -149,8 +147,19 @@ jobs:
# for humans and for subsequent poller runs.
if [[ "$sha" != "$issue_sha" ]]; then
echo " Branch ${branch} moved: ${issue_sha:0:8} → ${sha:0:8}. Updating issue."
updated_body="${body//${issue_sha}/${sha}}"
gh issue edit "$number" -R "$GITHUB_REPOSITORY" --body "$updated_body"
resolver_output_file=$(mktemp)
updated_body_file=$(mktemp)
if ! PUBLISH_ISSUE_BODY_FILE="$body_file" PUBLISH_REVISION="$sha" PUBLISH_TITLE="$title" \
node .__publish__/src/publish/resolve-ci-poller-input.js > "$resolver_output_file"; then
echo "::warning::Could not update canonical publish input for issue #${number}, skipping."
exit 0
fi
if ! jq -ejr 'if (.issueBody | type) == "string" and (.issueBody | length) > 0 then .issueBody else error("Expected nonempty issueBody string") end' \
"$resolver_output_file" > "$updated_body_file"; then
echo "::warning::Could not decode updated publish issue body for issue #${number}, skipping."
exit 0
fi
gh issue edit "$number" -R "$GITHUB_REPOSITORY" --body-file "$updated_body_file"
fi

echo "Checking CI for ${repo}@${version} commit ${sha:0:8} (issue #${number})..."
Expand All @@ -161,7 +170,7 @@ jobs:
# installed on this repo) — don't try to derive state from partial data.
if ! status_json=$(gh_api_release "repos/${repo}/commits/${sha}/status"); then
echo " Could not fetch commit status for ${repo}@${sha:0:8}, skipping."
continue
exit 0
fi
commit_status=$(echo "$status_json" | jq -r '.state')
total_statuses=$(echo "$status_json" | jq -r '.total_count')
Expand All @@ -171,7 +180,7 @@ jobs:
# '.check_runs[]' and count with a second jq pass.
if ! all_checks=$(gh_api_release --paginate "repos/${repo}/commits/${sha}/check-runs" --jq '.check_runs[]'); then
echo " Could not fetch check runs for ${repo}@${sha:0:8}, skipping."
continue
exit 0
fi

total_checks=$(echo "$all_checks" | jq -s 'length')
Expand All @@ -187,7 +196,7 @@ jobs:
# otherwise CI hasn't started yet and all counts would be 0.
if [[ "$total_checks" == "0" && "$total_statuses" == "0" ]]; then
echo " No check runs or commit statuses found — CI may not have started yet."
continue
exit 0
fi

# CI is ready when:
Expand Down Expand Up @@ -243,6 +252,7 @@ jobs:
comment_body="CI **commit status** checks failed for ${repo}@${version} (\`${sha:0:8}\`). Publishing is blocked."$'\n\n'"Failed status checks:"$'\n'"${failed_contexts}"$'\n\n'"[View check runs](https://github.com/${repo}/commit/${sha}/checks/)"$'\n\n'"Re-add the **accepted** label once CI is fixed to retry."
gh issue comment "$number" -R "$GITHUB_REPOSITORY" --body "$comment_body"
fi
)
done

# Cleanup: check if we should disable the poller. Runs even if the
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cocoapods-keepalive.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: "CocoaPods Token Keep-Alive"
name: CocoaPods Token Keep-Alive

on:
schedule:
Expand Down
84 changes: 45 additions & 39 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ jobs:
SENTRY_DSN: "https://303a687befb64dc2b40ce4c96de507c5@o1.ingest.sentry.io/6183838"
steps:
- name: Get repo contents
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: .__publish__

Expand All @@ -135,36 +135,19 @@ jobs:
id: inputs
run: node .__publish__/src/publish/inputs.js

- name: Resolve CI-approved release revision
id: release-revision
env:
PUBLISH_ARGS: ${{ steps.inputs.outputs.result }}
PUBLISH_ISSUE_BODY: ${{ github.event.issue.body }}
run: node .__publish__/src/publish/resolve-release-revision.js

- name: Inform start
if: steps.inputs.outcome == 'success'
if: steps.inputs.outcome == 'success' && steps.release-revision.outcome == 'success'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .__publish__/src/publish/post-workflow-details.js

# Setting the target repo branch will cause the craft config (.craft.yml) to be taken from the checked out branch
# By default, we check out the default branch of the repo.
# If you need to maintain diverging craft configs on different branches, add your repo and the merge target branch
# (i.e. the branch craft will merge the release branch into) into the if condition below.
- name: Set target repo checkout branch
# Note: Branches registered here MUST BE protected in the target repo!
if: |
fromJSON(steps.inputs.outputs.result).repo == 'sentry-migr8' && fromJSON(steps.inputs.outputs.result).merge_target == 'tmp-merge-target' ||
fromJSON(steps.inputs.outputs.result).repo == 'sentry-javascript' && fromJSON(steps.inputs.outputs.result).merge_target == 'v10' ||
fromJSON(steps.inputs.outputs.result).repo == 'sentry-javascript' && fromJSON(steps.inputs.outputs.result).merge_target == 'v9' ||
fromJSON(steps.inputs.outputs.result).repo == 'sentry-javascript' && fromJSON(steps.inputs.outputs.result).merge_target == 'v8' ||
fromJSON(steps.inputs.outputs.result).repo == 'sentry-javascript' && fromJSON(steps.inputs.outputs.result).merge_target == 'v7' ||
fromJSON(steps.inputs.outputs.result).repo == 'sentry-javascript' && fromJSON(steps.inputs.outputs.result).merge_target == 'master' ||
fromJSON(steps.inputs.outputs.result).repo == 'sentry-python' && fromJSON(steps.inputs.outputs.result).merge_target == 'alpha' ||
fromJSON(steps.inputs.outputs.result).repo == 'sentry-wizard' && fromJSON(steps.inputs.outputs.result).merge_target == '1.x' ||
false
id: target-repo-branch
env:
MERGE_TARGET: ${{ fromJSON(steps.inputs.outputs.result).merge_target }}
REPO: ${{ fromJSON(steps.inputs.outputs.result).repo }}
run: |
echo "taking craft config from branch \"$MERGE_TARGET\" in \"$REPO\""
echo "target_repo_branch=$MERGE_TARGET" >> "$GITHUB_OUTPUT"

- name: Get Release Bot auth token
id: token
uses: actions/create-github-app-token@v3
Expand All @@ -173,23 +156,32 @@ jobs:
private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
owner: getsentry # create token that have access to all repos

- uses: actions/checkout@v6
- uses: actions/checkout@v7
name: Check out target repo
if: ${{ steps.inputs.outputs.result }}
with:
path: __repo__
ref: ${{ steps.target-repo-branch.outputs.target_repo_branch || ''}}
ref: ${{ steps.release-revision.outputs.revision }}
repository: getsentry/${{ fromJSON(steps.inputs.outputs.result).repo }}
token: ${{ steps.token.outputs.token }}
fetch-depth: 0

- name: Resolve publish location
id: location
env:
PUBLISH_ARGS: ${{ steps.inputs.outputs.result }}
PUBLISH_REPOSITORY_DIRECTORY: __repo__
run: node .__publish__/src/publish/discover-location.js

- name: Set targets
id: craft-state
shell: bash
if: fromJSON(steps.inputs.outputs.result).targets
env:
CRAFT_PUBLISH_REPO: ${{ fromJSON(steps.inputs.outputs.result).repo }}
CRAFT_PUBLISH_PATH: ${{ fromJSON(steps.inputs.outputs.result).path }}
CRAFT_PUBLISH_PATH: ${{ fromJSON(steps.location.outputs.result).path }}
CRAFT_PUBLISH_VERSION: ${{ fromJSON(steps.inputs.outputs.result).version }}
CRAFT_PUBLISH_WORKSPACE: ${{ fromJSON(steps.location.outputs.result).workspace || '' }}
CRAFT_PUBLISH_TARGETS_JSON: ${{ toJSON(fromJSON(steps.inputs.outputs.result).targets) }}
run: |
# Render the "already published" JSON.
Expand All @@ -209,22 +201,31 @@ jobs:
# __repo__/<path>` and Node's process.cwd() canonicalisation,
# the cwd is `/github/workspace/__repo__` (root) or
# `/github/workspace/__repo__/subdir/...` (monorepo).
case "$CRAFT_PUBLISH_PATH" in
.|./) container_cwd="/github/workspace/__repo__" ;;
./*) container_cwd="/github/workspace/__repo__/${CRAFT_PUBLISH_PATH#./}" ;;
*) container_cwd="/github/workspace/__repo__/${CRAFT_PUBLISH_PATH}" ;;
esac
# Strip any trailing slash to match Node's canonicalisation.
container_cwd="${container_cwd%/}"
container_cwd="$(realpath -m "/github/workspace/__repo__/$CRAFT_PUBLISH_PATH")"
if [[ "$container_cwd" != "/github/workspace/__repo__" && "$container_cwd" != /github/workspace/__repo__/* ]]; then
echo "::error::Publish path must remain inside the target checkout."
exit 1
fi
cwd_hash="$(printf %s "$container_cwd" | sha1sum | cut -c1-12)"
sanitise() { printf %s "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]\+/_/g; s/^_\+//; s/_\+$//'; }
owner_sanitised="$(sanitise getsentry)"
repo_sanitised="$(sanitise "$CRAFT_PUBLISH_REPO")"
version_sanitised="$(sanitise "$CRAFT_PUBLISH_VERSION")"
version_component="$version_sanitised"
if [[ "$version_sanitised" != "$CRAFT_PUBLISH_VERSION" ]]; then
version_encoded="$(printf %s "$CRAFT_PUBLISH_VERSION" | base64 -w 0 | tr '+/' '-_' | tr -d '=')"
version_component="version-${version_encoded}"
fi
workspace_prefix=""
if [[ -n "$CRAFT_PUBLISH_WORKSPACE" ]]; then
workspace_encoded="$(printf %s "$CRAFT_PUBLISH_WORKSPACE" | base64 -w 0 | tr '+/' '-_' | tr -d '=')"
workspace_prefix="workspace-${workspace_encoded}-"
fi
state_dir="$GITHUB_WORKSPACE/.craft-state/craft"
state_file="$state_dir/publish-state-${owner_sanitised}-${repo_sanitised}-${cwd_hash}-${version_sanitised}.json"
state_file="$state_dir/publish-state-${owner_sanitised}-${repo_sanitised}-${cwd_hash}-${workspace_prefix}${version_component}.json"
mkdir -p "$state_dir"
printf %s "$payload" > "$state_file"
echo "state_file=$state_file" >> "$GITHUB_OUTPUT"
echo "Wrote state file: $state_file"

- uses: docker://getsentry/craft:latest
Expand All @@ -235,8 +236,8 @@ jobs:
-e
-c "
export HOME=/root &&
cd __repo__/${{ fromJSON(steps.inputs.outputs.result).path }} &&
exec craft publish ${{ fromJSON(steps.inputs.outputs.result).version }}
cd __repo__/${{ fromJSON(steps.location.outputs.result).path }} &&
exec craft publish ${{ fromJSON(steps.inputs.outputs.result).version }} --rev ${{ steps.release-revision.outputs.revision }}
"
env:
# Pin Craft's publish-state directory to a path outside
Expand All @@ -246,6 +247,10 @@ jobs:
CRAFT_MERGE_TARGET: ${{ fromJSON(steps.inputs.outputs.result).merge_target }}
CRAFT_LOG_LEVEL: ${{ vars.CRAFT_LOG_LEVEL || 'Info' }}
CRAFT_DRY_RUN: ${{ fromJSON(steps.inputs.outputs.result).dry_run }}
CRAFT_WORKSPACE: ${{ fromJSON(steps.location.outputs.result).workspace || '' }}
# State must follow the issue checkout identity, not a workspace's
# optional GitHub release-repository override.
CRAFT_PUBLISH_STATE_GITHUB_REPO: getsentry/${{ fromJSON(steps.inputs.outputs.result).repo }}
GIT_COMMITTER_NAME: sentry-release-bot[bot]
GIT_AUTHOR_NAME: sentry-release-bot[bot]
EMAIL: 180476844+sentry-release-bot[bot]@users.noreply.github.com
Expand Down Expand Up @@ -287,6 +292,7 @@ jobs:
if: ${{ cancelled() || failure() }}
env:
PUBLISH_ARGS: ${{ steps.inputs.outputs.result }}
CRAFT_STATE_FILE_PATH: ${{ steps.craft-state.outputs.state_file }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .__publish__/src/publish/update-issue.js

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ flowchart TD
1. Observe the issue for information about the triggered run
1. The issue will automatically be closed when publishing succeeds

## Publish Issue Format

The release workflow creates publish requests with a stable title and body contract. See
[Publish Issue Format](docs/publish-issue-format.md) for the accepted syntax and fields.

## CalVer

To enable calendar versioning, add the following to your `.craft.yml`:
Expand Down
107 changes: 107 additions & 0 deletions docs/publish-issue-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Publish Issue Format

The Publish workflow treats a publish issue as a release request. The title identifies
the release; the body supplies the merge target and selected targets. Do not edit the
title by hand unless it remains valid under this format.

## Title

Every title starts with `publish: `. The following canonical Peggy grammar is
generated from `src/modules/publish-issue-title.peggy`:

<!-- BEGIN GENERATED TITLE GRAMMAR -->
```peggy
// Canonical grammar for publish issue titles. A path suffix is syntactic only:
// the controller resolves the complete suffix as a workspace after checking
// out the CI-approved revision.
PublishIssueTitle
= "publish: " "getsentry/"? repo:Repository path:Path? "@" version:Version !. {
return {
repo,
path: path || "",
version,
};
}

Repository
= characters:RepositoryCharacter+ { return join(characters); }

RepositoryCharacter
= [A-Za-z0-9_.-]

Path
= segments:("/" segment:PathSegment { return `/${segment}`; })+ { return join(segments); }

PathSegment
= characters:RepositoryCharacter+ { return join(characters); }

Version
= characters:[A-Za-z0-9_.+-]+ { return join(characters); }
```
<!-- END GENERATED TITLE GRAMMAR -->

New Craft requests always include the checkout repository identity. A workspace
release uses its full concrete path as the title suffix:

```text
publish: getsentry/sentry@21.3.1
publish: getsentry/toolkit/packages/cli@1.2.3
```

Workspace paths in titles use ASCII path segments matching `[A-Za-z0-9_.-]+`,
except `.`, `..`, `__proto__`, and segments starting with `-`. Craft preserves
their exact spelling.

Repository identities use the same safe token rule and cannot be `.`, `..`,
`__proto__`, or start with `-`. Versions must be valid Craft semantic versions;
build metadata such as `4.2.6+sentry1` is valid.

The controller resolves the complete suffix only after it checks out the exact
CI-approved revision from the `View check runs` link. When that checkout has a root
`.craft.yml`, `craft workspace list` supplies the exact concrete workspace paths. A
suffix that exactly matches one of those paths is a workspace; every other suffix
remains a checkout path. The controller never normalizes names. A missing root
`.craft.yml` always means checkout-path behavior. Discovery errors with a root
configuration fail the release.

`getsentry/` remains optional when parsing existing issues. Paths must use only
safe workspace segments.

## Body

The request must start with these body fields:

```markdown
Requested by: @<actor>

Merge target: <branch-or-(default)>

Quick links:

- [View changes](compare-url)
- [View check runs](checks-url)

Assign the **accepted** label to this issue to approve the release.

### Targets

- [ ] <target-id>

Checked targets will be skipped (either already published or user-requested skip). Uncheck to retry a target.
```

`Merge target` is required. `(default)` means the target repository's default branch.
The branch may contain letters, digits, `_`, `.`, `/`, and `-`.

The workflow reads checked entries (`- [x] <target-id>`) in the `### Targets` section.
It preserves checked entries when Craft refreshes an existing request. During a failed
release, the controller updates target checkboxes from the secure Craft publish-state
file; targets marked checked are skipped on retry unless manually unchecked.

The requester, approval guidance, and optional changelog section are informational. The
`Quick links` must follow `Requested by` and `Merge target`, and contain exactly one `View
changes` line followed by exactly one `View check runs` line for the checkout repository.
The controller and CI poller use that check-runs revision as the release authority.
The `accepted` label starts CI waiting. Publishing starts only when the CI poller
adds a fresh `ci-ready` label to an open, accepted issue with neither `ci-pending`
nor `ci-failed`. `dry-run` requests dry-run mode.
Loading
Loading