Skip to content
Open
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
32 changes: 13 additions & 19 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@v5

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
uses: actions/checkout@v5
uses: actions/checkout@v7

Let's go with latest version

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 @@ -92,26 +97,14 @@ jobs:
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."
if ! publish_input=$(PUBLISH_ISSUE_BODY="$body" 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."
continue
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,7 +142,8 @@ 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}}"
updated_body=$(PUBLISH_ISSUE_BODY="$body" PUBLISH_REVISION="$sha" PUBLISH_TITLE="$title" \
node .__publish__/src/publish/resolve-ci-poller-input.js | jq -r '.issueBody')
gh issue edit "$number" -R "$GITHUB_REPOSITORY" --body "$updated_body"
fi

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
88 changes: 51 additions & 37 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 @@ -178,18 +161,40 @@ jobs:
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 }}
run: |
workspace_names='[]'
requires_workspace_discovery="$(node -e '
const { needsWorkspaceDiscovery } = require("./.__publish__/src/modules/publish-location");
const input = JSON.parse(process.env.PUBLISH_ARGS || "");
process.stdout.write(String(needsWorkspaceDiscovery(input)));
')"
Comment on lines +175 to +179

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not put this into a file too? Even better, make it a runnable node script to avoid passing JS through YAML and bash?

if [[ "$requires_workspace_discovery" == "true" && -f __repo__/.craft.yml ]]; then
workspace_names="$(docker run --rm \
--volume "$GITHUB_WORKSPACE/__repo__:/github/workspace/__repo__" \
--workdir /github/workspace/__repo__ \
getsentry/craft:latest workspace list)"
Comment thread
BYK marked this conversation as resolved.
fi
CRAFT_WORKSPACE_NAMES="$workspace_names" \
node .__publish__/src/publish/resolve-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 +214,26 @@ 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")"
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_sanitised}.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 +244,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 +255,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 +300,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
96 changes: 96 additions & 0 deletions docs/publish-issue-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 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: `. This EBNF is canonical:

```text
title = "publish: ", [ "getsentry/" ], repository, [ path ],
[ legacy-workspace ], "@", version ;
repository = token, { token } ;
path = "/", path-segment, { "/", path-segment } ;
path-segment = token, { token } ;
legacy-workspace = " [workspace: ", json-string, "] " ;
version = version-character, { version-character } ;
token = ? ASCII letter, digit, ".", "_", or "-" ? ;
version-character = token | "+" ;
```

New Craft requests always include the checkout repository identity. Root workspace
releases use one trailing path segment for the workspace name:

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

Craft rejects a workspace with a non-root checkout path. Workspace names in new titles
must match `^[A-Za-z0-9_.-]+$`; Craft preserves their exact spelling.

The controller resolves a one-segment 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 workspace keys. A suffix that
exactly matches one of those keys 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.

Existing JSON-qualified workspace titles remain supported for compatibility, but Craft
does not create them:

```text
publish: getsentry/toolkit [workspace: "cli/v2"] @1.2.3
publish: getsentry/toolkit [workspace: "cli [preview] \"next\""] @1.2.3
```

Legacy workspace titles must also use the repository root path.

`<json-string>` is one valid JSON string, including its double quotes. It must decode
to a nonempty workspace name and must not contain Unicode control (`Cc`), format
(`Cf`), line-separator (`Zl`), or paragraph-separator (`Zp`) characters. Legacy
workspace titles have one space after `]` before `@`; unqualified titles have no space
before `@`.

`getsentry/` remains optional when parsing existing issues. Paths must not contain a
`..` segment.

## 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 optional. `(default)` means the target repository's default branch.
When present, 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 publishing;
`dry-run` requests dry-run mode.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
"undici": "^6.23.0"
},
"scripts": {
"test": "vitest run",
"generate": "node scripts/generate-publish-issue-title-parser.js",
"check:generated": "node scripts/generate-publish-issue-title-parser.js --check",
"test": "yarn check:generated && vitest run",
"test:watch": "vitest",
"lint": "eslint src .github --ignore-pattern '!.github'",
"prettier": "prettier --write src"
Expand All @@ -24,6 +26,7 @@
"eslint": "^8.9.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-yml": "^0.13.0",
"peggy": "5.1.0",
"prettier": "^2.2.1",
"vitest": "^4.1.0"
},
Expand Down
40 changes: 40 additions & 0 deletions scripts/generate-publish-issue-title-parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const fs = require("fs");
const path = require("path");
const peggy = require("peggy");
const prettier = require("prettier");

const grammarPath = path.join(
__dirname,
"..",
"src",
"modules",
"publish-issue-title.peggy"
);
const outputPath = path.join(
__dirname,
"..",
"src",
"modules",
"publish-issue-title.js"
);
const grammar = fs.readFileSync(grammarPath, "utf8");
const parser = peggy.generate(grammar, {
format: "commonjs",
grammarSource: "publish-issue-title.peggy",
output: "source",
});

const generatedParser = prettier.format(`/* eslint-disable */\n${parser}`, {
filepath: outputPath,
});

if (process.argv.includes("--check")) {
const currentParser = fs.readFileSync(outputPath, "utf8");
if (currentParser !== generatedParser) {
throw new Error(
"The generated publish issue title parser is stale. Run `yarn generate`."
);
}
} else {
fs.writeFileSync(outputPath, generatedParser);
}
Loading
Loading