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
4 changes: 4 additions & 0 deletions .changeset/streamlined-release-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

ci: Use streamlined release actions
217 changes: 179 additions & 38 deletions .github/workflows/publish-stable-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ env:
HUSKY: "0"
NPM_CONFIG_PROVENANCE: "true"

# sdk-actions PR #64 merge commit. Keep release action references immutable.

jobs:
validate-release-sha:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -69,18 +71,24 @@ jobs:
echo "Diff to latest release: $diff_url"
} | tee -a "$GITHUB_STEP_SUMMARY"

publish-stable:
build-package-artifacts:
needs:
- validate-release-sha
- print-changelog-links
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
issues: write
contents: read
id-token: write
pull-requests: read
environment: npm-publish
attestations: write
outputs:
has_work: ${{ steps.detect.outputs.has_work }}
manifest_json: ${{ steps.detect.outputs.manifest_json }}
release_sha: ${{ needs.validate-release-sha.outputs.release_sha }}
branch: ${{ steps.release-context.outputs.branch }}
on_release_branch: ${{ steps.release-context.outputs.on_release_branch }}
# Displayed in the approval summary so reviewers can identify the selected SHA.
commit_message: ${{ steps.release-context.outputs.commit_message }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
Expand All @@ -97,6 +105,27 @@ jobs:
echo "Checked out $checked_out_sha, expected $RELEASE_SHA." >&2
exit 1
fi
- name: Read release context
id: release-context
run: |
set -euo pipefail

containing_branches="$(
git branch -r --contains HEAD --format='%(refname:short)' |
sed '/^origin\/HEAD$/d; s|^origin/||'
)"
branch="${containing_branches%%$'\n'*}"
branch="${branch:-unknown}"
on_release_branch=false
case "$branch" in
main | prepare-release/*) on_release_branch=true ;;
esac

{
echo "branch=$branch"
echo "on_release_branch=$on_release_branch"
echo "commit_message=$(git log -1 --format='%s' HEAD)"
} >> "$GITHUB_OUTPUT"
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
Expand All @@ -111,25 +140,121 @@ jobs:
id: detect
run: node scripts/release/release-manifest.mjs --mode stable --output .release-manifest.json
- name: Build packages
if: steps.detect.outputs.needs_publish == 'true'
if: steps.detect.outputs.needs_artifacts == 'true'
run: pnpm run build
- name: Publish stable packages to npm
if: steps.detect.outputs.needs_publish == 'true'
env:
NODE_AUTH_TOKEN: ""
NPM_TOKEN: ""
run: node scripts/release/publish-release-manifest.mjs --manifest .release-manifest.json
- name: Pack packages and generate SBOMs
if: steps.detect.outputs.needs_artifacts == 'true'
run: node scripts/release/pack-publishable-packages.mjs --manifest .release-manifest.json --output-dir artifacts/release-packages --report artifacts/release-packages/pack-report.json
- name: Stage release manifest
if: steps.detect.outputs.needs_artifacts == 'true'
run: cp .release-manifest.json artifacts/release-packages/release-manifest.json
- name: Attest tarball build provenance
if: steps.detect.outputs.needs_artifacts == 'true'
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: artifacts/release-packages/*.tgz
- name: Upload release package artifacts
if: steps.detect.outputs.needs_artifacts == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: stable-release-packages
path: artifacts/release-packages/
retention-days: 1
if-no-files-found: error

attest-package-sboms:
name: Attest SBOM (${{ matrix.package.name }})
needs:
- build-package-artifacts
if: needs.build-package-artifacts.outputs.has_work == 'true'
strategy:
matrix:
package: ${{ fromJSON(needs.build-package-artifacts.outputs.manifest_json).packages }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Download release package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: stable-release-packages
path: release-artifacts
- name: Attest package SBOM
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-path: release-artifacts/${{ matrix.package.tarball_asset }}
sbom-path: release-artifacts/${{ matrix.package.sbom_asset }}

request-approval:
needs:
- build-package-artifacts
- attest-package-sboms
if: needs.build-package-artifacts.outputs.has_work == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Request stable release approval
uses: braintrustdata/sdk-actions/actions/release/request-approval@337c5475631d724441403f5a4d8ad0b32ce12e29
with:
packages: ${{ needs.build-package-artifacts.outputs.manifest_json }}
title: Braintrust JavaScript SDK stable release
sha: ${{ needs.build-package-artifacts.outputs.release_sha }}
branch: ${{ needs.build-package-artifacts.outputs.branch }}
on_release_branch: ${{ needs.build-package-artifacts.outputs.on_release_branch }}
commit_message: ${{ needs.build-package-artifacts.outputs.commit_message }}
slack_token: ${{ secrets.SLACK_BOT_TOKEN }}
slack_channel: C0ABHT0SWA2

publish-stable:
needs:
- build-package-artifacts
- request-approval
if: |
always() &&
needs.build-package-artifacts.outputs.has_work == 'true' &&
needs.request-approval.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
issues: write
id-token: write
pull-requests: read
attestations: read
environment: npm-publish
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
ref: ${{ needs.build-package-artifacts.outputs.release_sha }}
- name: Download release package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: stable-release-packages
path: release-artifacts
- name: Publish stable package tarballs
uses: braintrustdata/sdk-actions/actions/release/lang/js/publish-npm-tarballs@337c5475631d724441403f5a4d8ad0b32ce12e29
with:
manifest: release-artifacts/release-manifest.json
tarballs: release-artifacts/*.tgz
repo: ${{ github.repository }}
slack_token: ${{ secrets.SLACK_BOT_TOKEN }}
slack_channel: C0ABHT0SWA2
- name: Push Changesets release tags
if: steps.detect.outputs.has_work == 'true'
run: |
set -euo pipefail

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

target_commit="$(jq -r '.commit // empty' .release-manifest.json)"
manifest="release-artifacts/release-manifest.json"
target_commit="$(jq -r '.commit // empty' "$manifest")"
mapfile -t tags < <(
jq -r '.packages[]? | .tag // "\(.name)@\(.version)"' .release-manifest.json
jq -r '.packages[]? | .tag // "\(.name)@\(.version)"' "$manifest"
)

if [ "${#tags[@]}" -eq 0 ]; then
Expand Down Expand Up @@ -174,44 +299,60 @@ jobs:

git push origin "${to_push[@]}"
- name: Create GitHub Releases
if: steps.detect.outputs.has_work == 'true'
uses: braintrustdata/sdk-actions/actions/release/create-package-github-releases@337c5475631d724441403f5a4d8ad0b32ce12e29
with:
manifest: release-artifacts/release-manifest.json
repo: ${{ github.repository }}
- name: Ensure GitHub release SBOM assets
env:
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/release/create-github-releases.mjs --manifest .release-manifest.json
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail

manifest="release-artifacts/release-manifest.json"
manifest_dir="$(dirname "$manifest")"
while IFS= read -r encoded; do
package_json="$(printf '%s' "$encoded" | base64 -d)"
tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")"
sbom_asset="$(jq -r '.sbom_asset // empty' <<< "$package_json")"
if [ -z "$sbom_asset" ] || [ "$(basename "$sbom_asset")" != "$sbom_asset" ]; then
echo "Invalid SBOM asset for $tag: $sbom_asset" >&2
exit 1
fi

sbom_path="$manifest_dir/$sbom_asset"
if [ ! -f "$sbom_path" ]; then
echo "SBOM asset does not exist for $tag: $sbom_path" >&2
exit 1
fi

gh release upload "$tag" "$sbom_path" \
--repo "$GITHUB_REPOSITORY" \
--clobber
done < <(jq -r '.packages[] | @base64' "$manifest")
- name: Comment on issues closed by released PRs
if: steps.detect.outputs.has_work == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/release/comment-release-issues.mjs
- name: Post stable release to Slack
if: steps.detect.outputs.has_work == 'true'
uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: C0ABHT0SWA2
text: "✅ Packages published"
blocks:
- type: "header"
text:
type: "plain_text"
text: "✅ Packages published"
- type: "section"
text:
type: "mrkdwn"
text: "*Release SHA:* `${{ needs.validate-release-sha.outputs.release_sha }}`\n\n*Packages:*\n${{ steps.detect.outputs.markdown }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"
run: |
cp release-artifacts/release-manifest.json .release-manifest.json
node scripts/release/comment-release-issues.mjs

notify-failure:
needs:
- validate-release-sha
- print-changelog-links
- build-package-artifacts
- attest-package-sboms
- request-approval
- publish-stable
if: |
always() &&
(
needs.validate-release-sha.result == 'failure' ||
needs.print-changelog-links.result == 'failure' ||
needs.build-package-artifacts.result == 'failure' ||
needs.attest-package-sboms.result == 'failure' ||
needs.request-approval.result == 'failure' ||
needs.publish-stable.result == 'failure'
)
runs-on: ubuntu-latest
Expand Down
93 changes: 92 additions & 1 deletion scripts/release/_shared.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function getApprovedPackageByName(name) {
return PUBLISHABLE_PACKAGE_MAP.get(name);
}

export function escapeRegExp(value) {
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

Expand Down Expand Up @@ -191,6 +191,97 @@ export function formatPackageList(packages) {
return packages.map((pkg) => `- ${pkg.name}@${pkg.version}`).join("\n");
}

export function extractReleaseNotes(relativeDir, packageName, version) {
const fallback = `Published ${packageName}@${version}.`;
const changelogPath = repoPath(relativeDir, "CHANGELOG.md");
if (!existsSync(changelogPath)) {
return fallback;
}

const changelog = readFileSync(changelogPath, "utf8");
const heading = new RegExp(`^##\\s+${escapeRegExp(version)}\\s*$`, "m");
const match = heading.exec(changelog);
if (!match) {
return fallback;
}

const afterHeading = changelog.slice(match.index);
const nextHeading = afterHeading.slice(match[0].length).search(/^##\s+/m);
const section =
nextHeading === -1
? afterHeading
: afterHeading.slice(0, match[0].length + nextHeading);

return `# ${packageName}\n\n${section.trim()}`;
}

export function packageArtifactBase(name, version) {
return `${name.replace(/^@/, "").replace(/[\\/@]/g, "-")}-${version}`;
}

export function orderPackagesForPublish(packages) {
const packageMap = new Map(
packages.map((pkg) => [
pkg.name,
{ ...pkg, packageJson: readPackage(pkg.dir) },
]),
);
const visiting = new Set();
const visited = new Set();
const ordered = [];

function visit(pkg) {
if (visited.has(pkg.name)) {
return;
}

if (visiting.has(pkg.name)) {
throw new Error(
`Detected a publish dependency cycle involving ${pkg.name}`,
);
}

visiting.add(pkg.name);

for (const dependencyName of getWorkspaceReleaseDependencies(
pkg.packageJson,
)) {
const dependency = packageMap.get(dependencyName);
if (dependency) {
visit(dependency);
}
}

visiting.delete(pkg.name);
visited.add(pkg.name);
ordered.push(pkg);
}

for (const pkg of packageMap.values()) {
visit(pkg);
}

return ordered.map(({ packageJson: _packageJson, ...pkg }) => pkg);
}

function getWorkspaceReleaseDependencies(packageJson) {
const dependencyNames = new Set();

for (const field of [
"dependencies",
"optionalDependencies",
"peerDependencies",
"devDependencies",
]) {
for (const dependencyName of Object.keys(packageJson[field] ?? {})) {
dependencyNames.add(dependencyName);
}
}

dependencyNames.delete(packageJson.name);
return dependencyNames;
}

export function isPublishedToNpm(name, version) {
const result = spawnSync(
"npm",
Expand Down
Loading
Loading