Skip to content

Commit 8e241bc

Browse files
committed
feat: add reusable workflow to check PR labels for release conditions
Add check-pr-labels.yml workflow that checks for labels on PRs (or merged PRs on push events) and outputs boolean values for common release labels: - github-release - crates-release - prerelease - npm-release Supports both pull_request and push events, with optional default values for direct pushes
1 parent 0a619a5 commit 8e241bc

13 files changed

Lines changed: 593 additions & 404 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
name: 'Check PR Labels'
2+
description: 'Check PR labels and output flags for release decisions'
3+
4+
inputs:
5+
default-on-push:
6+
description: 'Comma-separated list of labels to default to true on direct push (no PR found). Format: github-release,crates-release'
7+
required: false
8+
default: ''
9+
10+
outputs:
11+
has_github_release:
12+
description: 'Whether PR has github-release label'
13+
value: ${{ steps.check-labels.outputs.has_github_release }}
14+
has_crates_release:
15+
description: 'Whether PR has crates-release label'
16+
value: ${{ steps.check-labels.outputs.has_crates_release }}
17+
has_prerelease:
18+
description: 'Whether PR has prerelease label'
19+
value: ${{ steps.check-labels.outputs.has_prerelease }}
20+
has_npm_release:
21+
description: 'Whether PR has npm-release label'
22+
value: ${{ steps.check-labels.outputs.has_npm_release }}
23+
24+
runs:
25+
using: 'composite'
26+
steps:
27+
- name: Check PR Labels
28+
id: check-labels
29+
uses: actions/github-script@v7
30+
with:
31+
script: |
32+
// Always check these labels
33+
const defaultOnPush = '${{ inputs.default-on-push }}'.split(',').map(l => l.trim().toLowerCase()).filter(Boolean);
34+
35+
let pr = null;
36+
let labels = [];
37+
38+
// If this is a pull_request event, use it directly
39+
if (context.eventName === 'pull_request') {
40+
pr = context.payload.pull_request;
41+
labels = (pr.labels || []).map(l => l.name.toLowerCase());
42+
console.log(`PR event: Found PR #${pr.number} with labels: ${labels.join(', ') || 'none'}`);
43+
} else {
44+
// For push events, find the merged PR
45+
try {
46+
const { data: prs } = await github.rest.pulls.list({
47+
owner: context.repo.owner,
48+
repo: context.repo.repo,
49+
state: 'closed',
50+
base: context.ref.replace('refs/heads/', ''),
51+
sort: 'updated',
52+
direction: 'desc',
53+
per_page: 10
54+
});
55+
56+
// Find the PR that was merged to this commit
57+
pr = prs.find(p => p.merge_commit_sha === context.sha);
58+
59+
if (pr) {
60+
const { data: prWithLabels } = await github.rest.pulls.get({
61+
owner: context.repo.owner,
62+
repo: context.repo.repo,
63+
pull_number: pr.number
64+
});
65+
labels = (prWithLabels.labels || []).map(l => l.name.toLowerCase());
66+
console.log(`Push event: Found merged PR #${pr.number} with labels: ${labels.join(', ') || 'none'}`);
67+
} else {
68+
console.log('No merged PR found for this commit.');
69+
}
70+
} catch (error) {
71+
console.log(`Error finding PR: ${error.message}`);
72+
}
73+
}
74+
75+
const labelsToCheck = ['github-release', 'crates-release', 'prerelease', 'npm-release'];
76+
77+
for (const label of defaultOnPush) {
78+
if (labelsToCheck.includes(label)) {
79+
labels.push(label);
80+
}
81+
}
82+
83+
const hasGithubRelease = labels.includes('github-release');
84+
const hasCratesRelease = labels.includes('crates-release');
85+
const hasPrerelease = labels.includes('prerelease');
86+
const hasNpmRelease = labels.includes('npm-release');
87+
88+
core.setOutput('has_github_release', hasGithubRelease ? 'true' : 'false');
89+
core.setOutput('has_crates_release', hasCratesRelease ? 'true' : 'false');
90+
core.setOutput('has_prerelease', hasPrerelease ? 'true' : 'false');
91+
core.setOutput('has_npm_release', hasNpmRelease ? 'true' : 'false');
92+
93+
return {
94+
has_github_release: hasGithubRelease,
95+
has_crates_release: hasCratesRelease,
96+
has_prerelease: hasPrerelease,
97+
has_npm_release: hasNpmRelease
98+
};
99+
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: 'Create GitHub Release'
2+
description: 'Create a GitHub release with artifacts'
3+
4+
inputs:
5+
version:
6+
description: 'The version to release (e.g., 1.2.3)'
7+
required: true
8+
title:
9+
description: 'The release title'
10+
required: true
11+
notes:
12+
description: 'Release notes in markdown'
13+
required: false
14+
default: ''
15+
prerelease:
16+
description: 'Whether this is a prerelease'
17+
required: false
18+
default: false
19+
draft:
20+
description: 'Whether to create as draft'
21+
required: false
22+
default: false
23+
tag-prefix:
24+
description: 'Prefix for the git tag'
25+
required: false
26+
default: 'v'
27+
artifact-pattern:
28+
description: 'Pattern to match artifacts to attach (leave empty for none)'
29+
required: false
30+
default: ''
31+
32+
outputs:
33+
url:
34+
description: 'URL of the created release'
35+
value: ${{ steps.gh-release.outputs.url }}
36+
tag:
37+
description: 'The created tag name'
38+
value: ${{ inputs.tag-prefix }}${{ inputs.version }}
39+
40+
runs:
41+
using: 'composite'
42+
steps:
43+
- name: Download artifacts
44+
if: inputs.artifact-pattern != ''
45+
uses: actions/download-artifact@v4
46+
with:
47+
path: artifacts
48+
pattern: ${{ inputs.artifact-pattern }}
49+
merge-multiple: true
50+
51+
- name: Create Release
52+
id: gh-release
53+
uses: softprops/action-gh-release@v2
54+
with:
55+
tag_name: ${{ inputs.tag-prefix }}${{ inputs.version }}
56+
name: ${{ inputs.title }}
57+
body: |
58+
${{ inputs.notes }}
59+
60+
---
61+
**Version:** ${{ inputs.version }}
62+
draft: ${{ inputs.draft }}
63+
prerelease: ${{ inputs.prerelease }}
64+
files: ${{ inputs.artifact-pattern != '' && 'artifacts/*' || '' }}
65+
env:
66+
GITHUB_TOKEN: ${{ github.token }}
67+
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
name: 'Generate Release Info'
2+
description: 'Generate version number and release notes using AI'
3+
4+
inputs:
5+
service-name:
6+
description: 'The name of the service (used in release notes)'
7+
required: true
8+
prerelease:
9+
description: 'Whether this is a prerelease (appends -rc.N suffix)'
10+
required: false
11+
default: false
12+
openai-api-key:
13+
description: 'OpenAI API key for AI-powered version detection and notes'
14+
required: true
15+
16+
outputs:
17+
version:
18+
description: 'The determined version (e.g., 1.2.3 or 1.2.3-rc.5)'
19+
value: ${{ steps.version.outputs.final }}
20+
release_notes:
21+
description: 'AI-generated release notes in markdown'
22+
value: ${{ steps.ai-notes.outputs.final-message }}
23+
24+
runs:
25+
using: 'composite'
26+
steps:
27+
- name: Get last release info
28+
id: last-release
29+
uses: actions/github-script@v7
30+
with:
31+
script: |
32+
try {
33+
const { data: releases } = await github.rest.repos.listReleases({
34+
owner: context.repo.owner,
35+
repo: context.repo.repo,
36+
per_page: 1
37+
});
38+
39+
if (releases.length > 0) {
40+
const tagName = releases[0].tag_name;
41+
const { data: ref } = await github.rest.git.getRef({
42+
owner: context.repo.owner,
43+
repo: context.repo.repo,
44+
ref: `tags/${tagName}`
45+
});
46+
core.setOutput('sha', ref.object.sha);
47+
core.setOutput('version', tagName.replace(/^v/, ''));
48+
} else {
49+
const allCommits = await github.paginate(github.rest.repos.listCommits, {
50+
owner: context.repo.owner,
51+
repo: context.repo.repo,
52+
per_page: 100
53+
});
54+
core.setOutput('sha', allCommits[allCommits.length - 1].sha);
55+
core.setOutput('version', '0.0.0');
56+
}
57+
} catch (error) {
58+
core.setOutput('sha', context.sha);
59+
core.setOutput('version', '0.0.0');
60+
}
61+
62+
- name: AI Determine Version
63+
id: ai-version
64+
uses: openai/codex-action@v1
65+
with:
66+
openai-api-key: ${{ inputs.openai-api-key }}
67+
safety-strategy: read-only
68+
prompt: |
69+
Analyze commits between ${{ steps.last-release.outputs.sha }} and ${{ github.sha }}.
70+
Current version: ${{ steps.last-release.outputs.version }}
71+
Prerelease: ${{ inputs.prerelease }}
72+
73+
Rules:
74+
- BREAKING CHANGE or ! = major bump
75+
- feat: = minor bump
76+
- fix:, perf:, or other = patch bump
77+
- Default to patch if unclear
78+
- For prereleases: use same version logic, suffix will be added automatically
79+
80+
Respond with ONLY X.Y.Z
81+
82+
- name: Parse Version
83+
id: version
84+
shell: bash
85+
run: |
86+
NEXT=$(echo "${{ steps.ai-version.outputs.final-message }}" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
87+
[ -z "$NEXT" ] && { echo "::error::Invalid version"; exit 1; }
88+
89+
if [ "${{ inputs.prerelease }}" == "true" ]; then
90+
echo "final=${NEXT}-rc.${GITHUB_RUN_NUMBER}" >> $GITHUB_OUTPUT
91+
else
92+
echo "final=${NEXT}" >> $GITHUB_OUTPUT
93+
fi
94+
95+
- name: AI Generate Notes
96+
id: ai-notes
97+
uses: openai/codex-action@v1
98+
with:
99+
prompt: |
100+
Generate release notes for ${{ inputs.service-name }} v${{ steps.version.outputs.final }}.
101+
Changes from ${{ steps.last-release.outputs.sha }} to ${{ github.sha }}.
102+
103+
REQUIREMENTS:
104+
1. NO title - start with content directly
105+
2. Plain language - like explaining to a friend
106+
3. SHORT - 1-2 sentences per item
107+
4. Group: ## New Features, ## Bug Fixes, ## Improvement, if they exist. For example,
108+
if there are no new features, bug fixes, or improvements, don't include them.
109+
5. Only include breaking changes if they exist
110+
6. Include commit SHAs if necessary. If the commit is large, don't include it.
111+
7. 1-2 emojis max for personality
112+
8. Code diffs ONLY if necessary. If the diff is large, don't include it.
113+
114+
The end goal is clear business technical writing that is easy to understand, use, and bring
115+
delight to the user
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: 'Publish to Crates.io'
2+
description: 'Publish a Rust crate to crates.io'
3+
4+
inputs:
5+
crate-name:
6+
description: 'The name of the crate to publish (from Cargo.toml)'
7+
required: true
8+
crate-path:
9+
description: 'Optional: Path to crate directory (e.g., crates/client). If empty, publishes from workspace root using -p flag.'
10+
required: false
11+
default: ''
12+
dry-run:
13+
description: 'Run cargo publish --dry-run instead of actual publish'
14+
required: false
15+
default: false
16+
cargo-registry-token:
17+
description: 'Crates.io API token'
18+
required: true
19+
20+
runs:
21+
using: 'composite'
22+
steps:
23+
- name: Setup Rust
24+
uses: dtolnay/rust-toolchain@stable
25+
26+
- name: Publish to crates.io
27+
shell: bash
28+
env:
29+
CARGO_REGISTRY_TOKEN: ${{ inputs.cargo-registry-token }}
30+
run: |
31+
if [ -n "${{ inputs.crate-path }}" ]; then
32+
# Publish from crate directory
33+
echo "Publishing ${{ inputs.crate-name }} from ${{ inputs.crate-path }}..."
34+
cd "${{ inputs.crate-path }}"
35+
if [ "${{ inputs.dry-run }}" == "true" ]; then
36+
cargo publish --dry-run
37+
else
38+
cargo publish
39+
fi
40+
else
41+
# Publish from workspace root using -p flag
42+
echo "Publishing ${{ inputs.crate-name }} from workspace root..."
43+
if [ "${{ inputs.dry-run }}" == "true" ]; then
44+
cargo publish --dry-run -p "${{ inputs.crate-name }}"
45+
else
46+
cargo publish -p "${{ inputs.crate-name }}"
47+
fi
48+
fi
49+
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: 'Publish to NPM'
2+
description: 'Publish a package to NPM registry'
3+
4+
inputs:
5+
bun-version:
6+
description: 'Bun version to use'
7+
required: false
8+
default: 'latest'
9+
node-version:
10+
description: 'Node.js version'
11+
required: false
12+
default: '20'
13+
working-directory:
14+
description: 'Directory containing package.json'
15+
required: false
16+
default: '.'
17+
build-command:
18+
description: 'Build command'
19+
required: false
20+
default: 'bun run build'
21+
tag:
22+
description: "NPM tag (e.g., 'latest', 'beta')"
23+
required: false
24+
default: 'latest'
25+
npm-token:
26+
description: 'NPM authentication token'
27+
required: true
28+
29+
runs:
30+
using: 'composite'
31+
steps:
32+
- uses: oven-sh/setup-bun@v2
33+
with:
34+
bun-version: ${{ inputs.bun-version }}
35+
36+
- uses: actions/setup-node@v4
37+
with:
38+
node-version: ${{ inputs.node-version }}
39+
registry-url: 'https://registry.npmjs.org'
40+
41+
- name: Install dependencies
42+
shell: bash
43+
working-directory: ${{ inputs.working-directory }}
44+
run: bun install
45+
46+
- name: Build
47+
shell: bash
48+
working-directory: ${{ inputs.working-directory }}
49+
run: ${{ inputs.build-command }}
50+
51+
- name: Publish
52+
shell: bash
53+
working-directory: ${{ inputs.working-directory }}
54+
env:
55+
NODE_AUTH_TOKEN: ${{ inputs.npm-token }}
56+
run: npm publish --tag ${{ inputs.tag }} --access public

0 commit comments

Comments
 (0)