Skip to content

Merge pull request #98 from crystian/changeset-release/main #251

Merge pull request #98 from crystian/changeset-release/main

Merge pull request #98 from crystian/changeset-release/main #251

Workflow file for this run

name: release
on:
push:
branches: [main]
paths:
- '.changeset/*.md'
- '*/package.json'
- 'package.json'
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: 24
cache: 'pnpm'
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Build UI
# The CLI publish step depends on the Angular SPA being built so
# tsup's onSuccess hook can copy `ui/dist/ui/browser/` into
# `src/dist/ui/`. The runtime resolver in `src/server/paths.ts`
# serves the bundle from there in installed mode; without this
# step the published tarball would ship without any UI and
# `sm` would render the placeholder forever.
#
# When Sentry telemetry is wired (auth token + org present), build
# with the `release-sourcemaps` configuration instead: identical
# minified output plus HIDDEN `.map` files (no sourceMappingURL
# comment, so nothing changes for the shipped JS). The maps are
# stripped from the tarball below and uploaded post-publish. The
# maps MUST come from this exact build, a separate rebuild produces
# different content hashes and would not match the shipped bundle.
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
run: |
if [ -n "$SENTRY_AUTH_TOKEN" ] && [ -n "$SENTRY_ORG" ]; then
echo "Building UI with hidden sourcemaps (Sentry upload enabled)"
pnpm --filter ui exec ng build --configuration release-sourcemaps
else
pnpm --filter ui build
fi
- name: Build CLI
run: pnpm --filter @skill-map/cli build
- name: Inject Sentry debug IDs into the CLI bundle
# Node packages install to per-machine paths, so the browser-style
# `release` + `url-prefix` matching the UI upload uses CANNOT resolve
# CLI stack frames (the abs path differs on every install). Debug IDs
# are the path-independent alternative: a stable id is embedded in
# each shipped JS file (and its `.map`), and Sentry matches crash
# events to maps by that id. Injection MUST run before the publish so
# the id lands in the published JS; `changeset publish` does not
# rebuild (and there is no prepublish hook), so the injected dist is
# exactly what ships.
#
# EXCLUDES `src/dist/ui`: that is the BFF-served UI bundle, whose maps
# are matched by filename in the UI upload below. Mutating its JS here
# would shift it relative to those maps and break UI symbolication.
# Self-skips when Sentry is not wired (no token / org), leaving the
# published bundle byte-identical to a no-telemetry release.
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
run: |
if [ -n "$SENTRY_AUTH_TOKEN" ] && [ -n "$SENTRY_ORG" ]; then
echo "Injecting Sentry debug IDs into src/dist (excluding ui/)"
npx @sentry/cli@2 sourcemaps inject --ignore '**/ui/**' src/dist
else
echo "Sentry not configured; skipping CLI debug-id injection."
fi
- name: Stage and strip CLI sourcemaps from the published bundle
# Mirror of the UI strip, for the CLI's own maps. The `.map` files
# must NOT ship in the npm tarball (they embed the original TS via
# `sourcesContent`), but the post-publish upload still needs them.
# Copy them out of the published tree (preserving the `dist/` layout)
# then delete them from `src/dist`. Debug ids (injected above) live
# inside the maps, so the upload matches by id regardless of where
# the file sits. Excludes `src/dist/ui` (handled by the UI strip).
# Gated on Sentry being wired: a no-telemetry release keeps shipping
# its maps exactly as before (no behaviour change without a token).
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
run: |
if [ -z "$SENTRY_AUTH_TOKEN" ] || [ -z "$SENTRY_ORG" ]; then
echo "Sentry not configured; leaving CLI sourcemaps in the bundle as-is."
exit 0
fi
mkdir -p .tmp/cli-sentry-maps
staged=0
while IFS= read -r m; do
cp --parents "$m" .tmp/cli-sentry-maps/
rm -f "$m"
staged=$((staged + 1))
done < <(find src/dist -name '*.map' -not -path '*/ui/*')
echo "Staged + stripped ${staged} CLI sourcemap(s) from the published bundle."
- name: Strip UI sourcemaps from the published bundle
# The `.map` files stay under `ui/dist/` for the post-publish upload,
# but must NEVER ship inside the npm tarball (they would expose the
# original TypeScript source). Scoped to `src/dist/ui` so the CLI's
# own dist maps are untouched. No-op when the UI was built without
# sourcemaps (no token / org).
run: |
before=$(find src/dist/ui -name '*.map' 2>/dev/null | wc -l)
find src/dist/ui -name '*.map' -delete 2>/dev/null || true
echo "Removed ${before} UI sourcemap(s) from the published bundle."
- name: Verify spec index is up to date
run: pnpm --filter @skill-map/spec spec:check
- name: Changesets — version PR or publish
id: changesets
uses: changesets/action@v1
with:
version: pnpm release:version
publish: pnpm release:publish
commit: "chore: version packages"
title: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Smoke install from npm (post-publish)
if: steps.changesets.outputs.published == 'true'
env:
PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }}
run: |
# Read the cli version from the changesets output rather than
# resolving `@latest`. The `latest` dist-tag updates before
# every edge of the npm CDN has the tarball metadata, so a
# `@latest` install can return ETARGET even when the publish
# itself succeeded. The explicit version sidesteps that race.
#
# Why npm and not pnpm: end users install `sm` via
# `npm i -g @skill-map/cli`, so the smoke replays that flow
# exactly. The monorepo's package manager choice is irrelevant
# to the published tarball's install path.
cli_version=$(node -e '
const pkgs = JSON.parse(process.env.PUBLISHED || "[]");
const cli = pkgs.find((p) => p.name === "@skill-map/cli");
if (cli) process.stdout.write(cli.version);
')
if [ -z "$cli_version" ]; then
echo "@skill-map/cli not in this release; skipping smoke install."
exit 0
fi
echo "Smoke-installing @skill-map/cli@${cli_version}"
# Step into a clean directory so the repo's `.npmrc`
# (which pins pnpm-specific flags like strict-dep-builds and
# minimum-release-age) does not bleed into the npm CLI's
# config resolution. The smoke is meant to mirror a fresh
# end-user shell, not the contributor environment.
cd "$(mktemp -d)"
# Retry while the CDN catches up. Two failure modes are
# possible:
# 1. The tarball metadata has not propagated to every edge
# yet (genuine propagation lag, fixed by waiting).
# 2. The npm CLI cached the first 404 and keeps returning
# ETARGET even when the edge now has the version
# (`--prefer-online` forces a fresh staleness check on
# every attempt, dodging the cached negative).
# 10 retries * 30 s = up to ~5 minutes of patience, which
# covers the worst observed propagation windows.
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if npm i -g --prefer-online "@skill-map/cli@${cli_version}"; then
break
fi
if [ "$attempt" = "10" ]; then
echo "registry never served @skill-map/cli@${cli_version} after 10 retries"
exit 1
fi
echo "attempt $attempt failed (likely ETARGET propagation lag or cached 404); sleeping 30s before retry"
sleep 30
done
sm --version
skill-map --version
sm --help > /dev/null
- name: Upload UI sourcemaps to Sentry (post-publish)
# Only on an actual publish, and only when the operator has wired
# the Sentry secret + org. Self-skips (exit 0) otherwise, so a repo
# without telemetry configured releases exactly as before. This
# never touches the already-published bundle: it rebuilds the UI
# with HIDDEN sourcemaps (identical JS + content hashes as the
# shipped production build, plus the `.map` files) purely to upload
# them. Sentry matches events to these maps via the `release` tag
# the SDK sets (`skill-map-cli@<version>`) plus the `~/` url-prefix.
if: steps.changesets.outputs.published == 'true'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: skill-map-ui
PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }}
run: |
if [ -z "$SENTRY_AUTH_TOKEN" ] || [ -z "$SENTRY_ORG" ]; then
echo "SENTRY_AUTH_TOKEN secret or SENTRY_ORG variable not set; skipping sourcemap upload."
exit 0
fi
cli_version=$(node -e '
const pkgs = JSON.parse(process.env.PUBLISHED || "[]");
const cli = pkgs.find((p) => p.name === "@skill-map/cli");
if (cli) process.stdout.write(cli.version);
')
if [ -z "$cli_version" ]; then
echo "@skill-map/cli not in this release; skipping sourcemap upload."
exit 0
fi
if [ -z "$(find ui/dist/ui/browser -name '*.map' -print -quit 2>/dev/null)" ]; then
echo "No UI sourcemaps on disk (UI built without them); skipping upload."
exit 0
fi
release="skill-map-cli@${cli_version}"
echo "Uploading UI sourcemaps for release ${release}"
# Maps come from the same 'release-sourcemaps' build that produced
# the shipped JS, so they match the bundle by filename. Sentry ties
# them to events via the SDK's `release` tag + the `~/` url-prefix.
npx @sentry/cli@2 sourcemaps upload \
--release "${release}" \
--url-prefix '~/' \
ui/dist/ui/browser
- name: Upload CLI sourcemaps to Sentry (post-publish)
# Companion to the UI upload above, targeting the shared Node project
# (`skill-map-cli`, also home to the `surface: bff` events). The CLI
# bundle was debug-id-injected before publish, so Sentry matches CLI
# crash events to these maps by debug id; `--release` only associates
# the artifact bundle for housekeeping (no url-prefix / path matching
# is involved). Excludes `src/dist/ui` (covered by the UI upload).
# Self-skips unless this run published @skill-map/cli with Sentry
# wired and CLI maps present on disk.
if: steps.changesets.outputs.published == 'true'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: skill-map-cli
PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }}
run: |
if [ -z "$SENTRY_AUTH_TOKEN" ] || [ -z "$SENTRY_ORG" ]; then
echo "SENTRY_AUTH_TOKEN secret or SENTRY_ORG variable not set; skipping sourcemap upload."
exit 0
fi
cli_version=$(node -e '
const pkgs = JSON.parse(process.env.PUBLISHED || "[]");
const cli = pkgs.find((p) => p.name === "@skill-map/cli");
if (cli) process.stdout.write(cli.version);
')
if [ -z "$cli_version" ]; then
echo "@skill-map/cli not in this release; skipping sourcemap upload."
exit 0
fi
if [ -z "$(find .tmp/cli-sentry-maps -name '*.map' -print -quit 2>/dev/null)" ]; then
echo "No staged CLI sourcemaps on disk (built without them); skipping upload."
exit 0
fi
release="skill-map-cli@${cli_version}"
echo "Uploading CLI sourcemaps for release ${release}"
# Reads from the staging dir populated before publish (the maps were
# stripped from the tarball there). Debug-id matched, so no
# url-prefix / path matching is needed.
npx @sentry/cli@2 sourcemaps upload \
--release "${release}" \
.tmp/cli-sentry-maps