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
77 changes: 77 additions & 0 deletions .github/workflows/_publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Publish to npm

# Reusable workflow — publishes workspace packages to npm after release.
# Consumers pass the list of package dirs as a newline-separated input;
# each dir is published idempotently (checks the registry first).
#
# Pins to `release_sha` so a concurrent push can't change the tree
# between release creation and publish.

on:
workflow_call:
inputs:
release_sha:
description: 'Commit SHA to check out (the chore(release) commit from _release.yml)'
type: string
required: true
packages:
description: 'Newline-separated list of package directories to publish (e.g. "packages/core\npackages/calculators")'
type: string
required: true

jobs:
publish:
name: Publish
runs-on: ubuntu-latest
# `id-token: write` is for Sigstore attestations (`--provenance`),
# NOT for npm auth. Auth uses `NPM_TOKEN` org-secret — OIDC trusted
# publishing was evaluated and rejected because it requires manual
# per-package click-through in the npm web UI (no CLI/API to
# automate). `--provenance` gives us supply chain attestations
# regardless of auth method.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.release_sha }}

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v6
with:
node-version: 22
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'

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

- name: Build
run: pnpm turbo run build

- name: Publish packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
PACKAGES: ${{ inputs.packages }}
run: |
set -e
publish_if_needed() {
local dir="$1"
local pkg_name pkg_version
pkg_name=$(node -p "require('./$dir/package.json').name")
pkg_version=$(node -p "require('./$dir/package.json').version")

if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then
Comment thread
rlueder marked this conversation as resolved.
echo "Skipping $pkg_name@$pkg_version (already published)"
else
echo "Publishing $pkg_name@$pkg_version..."
( cd "$dir" && pnpm publish --provenance --access public --no-git-checks )
fi
}

while IFS= read -r dir; do
[ -z "$dir" ] && continue
publish_if_needed "$dir"
done <<< "$PACKAGES"
156 changes: 156 additions & 0 deletions .github/workflows/_release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: Release

# Reusable workflow — semantic-release with packages_changed guard.
# Produces outputs so downstream jobs (publish, deploy-site) can gate
# on whether a release happened and which commit carries the bump.
#
# Guards release on `packages/**` or root `package.json` changing in
# the pushed range. Site-only or docs-only pushes skip the release
# cycle. Set `require_package_changes: false` to release regardless.

on:
workflow_call:
inputs:
require_package_changes:
description: 'Only run semantic-release if packages/** or package.json changed'
type: boolean
default: true
outputs:
released:
description: 'Whether a new version was released'
value: ${{ jobs.release.outputs.released }}
version:
description: 'The released version number (without leading v)'
value: ${{ jobs.release.outputs.version }}
release_sha:
description: 'The commit SHA that was released (chore(release) commit, or github.sha on no-op)'
value: ${{ jobs.release.outputs.release_sha }}

jobs:
release:
name: semantic-release
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
outputs:
released: ${{ steps.semantic.outputs.released }}
version: ${{ steps.semantic.outputs.version }}
# Fallback to github.sha on no-op — consumers without an `if: released == 'true'`
# guard won't checkout an empty ref.
release_sha: ${{ steps.semantic.outputs.release_sha || github.sha }}
steps:
- name: Check for package changes across pushed commits
id: changes
if: inputs.require_package_changes
uses: actions/github-script@v7
with:
# Compares `before..after` from the push payload. Paginates explicitly
# because `compareCommits` truncates at 300 files per page — a large
# push could otherwise silently miss detection.
script: |
const before = context.payload.before;
const after = context.sha;
const isInitial = !before || /^0+$/.test(before);
let filenames = [];
if (isInitial) {
const { data: commit } = await github.rest.repos.getCommit({
owner: context.repo.owner, repo: context.repo.repo, ref: after,
});
filenames = (commit.files ?? []).map(f => f.filename);
} else {
const pages = await github.paginate(
github.rest.repos.compareCommitsWithBasehead,
{
owner: context.repo.owner,
repo: context.repo.repo,
basehead: `${before}...${after}`,
per_page: 100,
},
);
filenames = pages.flatMap(p => (p.files ?? []).map(f => f.filename));
}
const packageChanged = filenames.some(
f => f.startsWith('packages/') || f === 'package.json'
);
Comment thread
rlueder marked this conversation as resolved.
core.setOutput('packages_changed', packageChanged ? 'true' : 'false');
core.info(`inspected ${filenames.length} files, packages_changed=${packageChanged}`);

- name: Generate GitHub App token
id: app-token
if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'"
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

- uses: actions/checkout@v6
if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'"
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}

- uses: pnpm/action-setup@v4
if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'"

- uses: actions/setup-node@v6
if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'"
with:
node-version: 22
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'

- name: Install dependencies
if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'"
run: pnpm install --frozen-lockfile

- name: Build
if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'"
run: pnpm turbo run build

- name: Run semantic-release
id: semantic
if: "!inputs.require_package_changes || steps.changes.outputs.packages_changed == 'true'"
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
HUSKY: 0
# Three cases:
# (a) success with release: stdout contains 'Published release' →
# we take the version from `git describe --tags` (more robust
# to format changes in semantic-release's stdout).
# (b) no-op: exit 0 AND stdout mentions 'no relevant changes' →
# released=false. Requiring BOTH conditions avoids masking
# silent failures where the tool exits 0 without publishing
# or explaining why.
# (c) real error: any other combination → non-zero exit.
run: |
set +e
OUTPUT=$(npx semantic-release 2>&1)
EXIT=$?
set -e
echo "$OUTPUT"

if echo "$OUTPUT" | grep -q "Published release"; then
RAW_TAG=$(git describe --tags --abbrev=0)
VERSION="${RAW_TAG#v}"
if [ -z "$VERSION" ]; then
echo "::error::semantic-release published but git describe found no tag"
exit 1
fi
RELEASE_SHA=$(git rev-parse HEAD)
{
echo "released=true"
echo "version=$VERSION"
echo "release_sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
elif [ "$EXIT" -eq 0 ] && echo "$OUTPUT" | grep -qE "no relevant changes|There are no relevant changes"; then
echo "released=false" >> "$GITHUB_OUTPUT"
else
echo "::error::semantic-release exited $EXIT with no recognized release or no-op marker"
exit "${EXIT:-1}"
fi

- name: Skip release (no package changes)
if: inputs.require_package_changes && steps.changes.outputs.packages_changed != 'true'
run: echo "No changes under packages/** — release skipped"
49 changes: 40 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
name: CI
name: CI/CD

# Tooling publishes packages via standalone `release.yml` (semantic-release
# fires on every push to main). ci.yml wires the canonical `_checks.yml`
# + `review.yml` reusables so checks run identically to the rest of the
# ecosystem.
# Orchestrator — wires reusable workflows. Mirrors the canonical shape
# used by datasus-brasil / fhir-brasil / medbench-brasil / platform.
# Each concern is a reusable workflow under `.github/workflows/`:
#
# _checks.yml — parallel CI checks (lint, typecheck, format, build, test)
# _release.yml — semantic-release with packages_changed guard
# _publish.yml — npm publish pinned to release_sha
# review.yml — automated code review (PRs only)
Comment thread
rlueder marked this conversation as resolved.

on:
push:
Expand All @@ -13,12 +17,15 @@ on:
workflow_dispatch:

permissions:
contents: read
# `contents: write` porque _release.yml (reusable) precisa criar
# commits e tags via semantic-release. Workflows reusáveis só
# conseguem pedir permissões que o caller também concede.
contents: write
issues: write
pull-requests: write
# `models: read` é necessário porque review.yml (reusable) pode chamar
# GitHub Models via GITHUB_TOKEN no fallback. Workflows reusáveis só
# conseguem pedir permissões que o caller também concede.
id-token: write
# `models: read` pelo mesmo motivo: review.yml chama GitHub Models
# via GITHUB_TOKEN no fallback de providers.
models: read

concurrency:
Expand All @@ -37,3 +44,27 @@ jobs:
with:
pr_number: ${{ github.event.pull_request.number }}
secrets: inherit

release:
needs: checks
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: ./.github/workflows/_release.yml
secrets: inherit

publish:
needs: release
if: needs.release.outputs.released == 'true'
uses: ./.github/workflows/_publish.yml
with:
release_sha: ${{ needs.release.outputs.release_sha }}
packages: |
packages/agent-instructions
packages/cli
packages/commitlint-config
packages/eslint-config
packages/prettier-config
packages/themes
packages/tsconfig
packages/ui
packages/worktree-cli
secrets: inherit
76 changes: 0 additions & 76 deletions .github/workflows/release.yml

This file was deleted.

Loading
Loading