From 37df60b985571eb63cdcf0110f66f2aef61a861b Mon Sep 17 00:00:00 2001 From: Reynaldo Endis Date: Mon, 5 Oct 2026 18:09:03 +0000 Subject: [PATCH] ci: add manual release workflow with npm trusted publishing Releases needed several manual steps on a maintainer machine: tagging, creating the GitHub release from the CHANGELOG, and publishing the Studio packages with the pinned pnpm so workspace dependencies resolve. The Release workflow runs from main with a version input. It reuses the Quality workflow, creates the annotated tag and the GitHub release from the CHANGELOG section, then packs the Studio packages with pnpm 10.11.0 and publishes them through npm Trusted Publishing with provenance. Every step is idempotent, target limits the run to github or npm, and dry_run previews without creating anything. Quality now also runs as a reusable workflow, with a concurrency group that keeps release runs separate from push runs. --- .github/workflows/quality.yml | 6 +- .github/workflows/release.yml | 198 ++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 24 +++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 61c73f0..4f3fe49 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -5,12 +5,16 @@ on: push: branches: [main] workflow_dispatch: + # Reused by the release workflow so releases run the same checks. + workflow_call: permissions: contents: read concurrency: - group: quality-${{ github.ref }} + # github.workflow is the caller's name when reused, so a release never + # shares (and gets cancelled with) the group of a regular push run. + group: ${{ github.workflow }}-quality-${{ github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4654020 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,198 @@ +name: Release + +# Manual release: validates the repository, tags `main`, creates the GitHub +# release from the CHANGELOG section, and publishes the Studio packages to npm +# through npm Trusted Publishing (OIDC, no stored npm token). +# +# Each step is idempotent: an existing tag or release is kept, and package +# versions already on npm are skipped, so a failed run can be re-run safely. + +on: + workflow_dispatch: + inputs: + version: + description: Release version without the leading v (for example 3.4.0). Must have a CHANGELOG section. + required: true + type: string + target: + description: What to release. + required: true + default: all + type: choice + options: + - all + - github + - npm + dry_run: + description: Validate and preview only; create nothing and publish nothing. + required: true + default: true + type: boolean + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tag: ${{ steps.meta.outputs.tag }} + steps: + - name: Require main + if: github.ref != 'refs/heads/main' + run: | + echo "::error::Run the release workflow from main (got ${GITHUB_REF})." + exit 1 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Validate version and CHANGELOG + id: meta + env: + VERSION: ${{ inputs.version }} + run: | + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Version '$VERSION' is not semver without a leading v." + exit 1 + fi + if ! grep -qF "## [$VERSION]" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## [$VERSION]' section." + exit 1 + fi + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + quality: + needs: prepare + uses: ./.github/workflows/quality.yml + + github: + needs: [prepare, quality] + if: inputs.target == 'all' || inputs.target == 'github' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + env: + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - name: Extract release notes + run: | + awk -v header="## [$VERSION]" ' + index($0, header) == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md | sed -e '/./,$!d' > "$RUNNER_TEMP/notes.md" + compare=$(grep -F "[$VERSION]: " CHANGELOG.md | head -1 | sed 's/^[^:]*: //') + if [ -n "$compare" ]; then + printf '\n**Full Changelog**: %s\n' "$compare" >> "$RUNNER_TEMP/notes.md" + fi + if ! grep -q '[^[:space:]]' "$RUNNER_TEMP/notes.md"; then + echo "::error::The CHANGELOG section for $VERSION is empty." + exit 1 + fi + cat "$RUNNER_TEMP/notes.md" + - name: Create tag + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null; then + echo "Tag $TAG already exists; keeping it." + exit 0 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "[dry run] Would tag $GITHUB_SHA as $TAG." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" "$GITHUB_SHA" -m "$TAG" + git push origin "refs/tags/$TAG" + - name: Create GitHub release + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists; keeping it." + exit 0 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "[dry run] Would create release $TAG with the notes above." + exit 0 + fi + gh release create "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" --verify-tag + + npm: + needs: [prepare, quality, github] + if: >- + !cancelled() + && needs.quality.result == 'success' + && (needs.github.result == 'success' || needs.github.result == 'skipped') + && (inputs.target == 'all' || inputs.target == 'npm') + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write # npm Trusted Publishing and provenance + env: + TAG: ${{ needs.prepare.outputs.tag }} + DRY_RUN: ${{ inputs.dry_run }} + steps: + - name: Resolve source ref + id: source + run: | + # Publish exactly what was tagged; a dry run before tagging uses main. + if git ls-remote --exit-code --tags "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$TAG" >/dev/null; then + echo "ref=refs/tags/$TAG" >> "$GITHUB_OUTPUT" + elif [ "$DRY_RUN" = "true" ]; then + echo "ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + else + echo "::error::Tag $TAG does not exist. Run with target 'all' or 'github' first." + exit 1 + fi + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ steps.source.outputs.ref }} + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10.11.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "24" + registry-url: https://registry.npmjs.org + cache: pnpm + cache-dependency-path: studio/pnpm-lock.yaml + - name: Use an npm that supports Trusted Publishing + run: npm install -g npm@^11.5.1 + - name: Build Studio packages + working-directory: studio + run: | + pnpm install --frozen-lockfile + pnpm build + - name: Pack and publish + run: | + mkdir -p "$RUNNER_TEMP/packages" + # editor-core first: the Web Component depends on it. + for dir in studio/packages/editor-core studio/packages/web-component; do + name=$(node -p "require('./$dir/package.json').name") + version=$(node -p "require('./$dir/package.json').version") + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "$name@$version is already on npm; skipping." + continue + fi + # pnpm pack rewrites workspace:* to the real version. + tarball=$(cd "$dir" && pnpm pack --pack-destination "$RUNNER_TEMP/packages" | tail -1) + if tar -xOzf "$tarball" package/package.json | grep -q '"workspace:'; then + echo "::error::$name@$version still has a workspace: dependency." + exit 1 + fi + if [ "$DRY_RUN" = "true" ]; then + npm publish "$tarball" --access public --dry-run + else + npm publish "$tarball" --access public --provenance + fi + done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 516a464..c96a540 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,7 @@ Thank you for your interest in contributing to StatePro! This guide outlines how - [Documentation](#documentation) - [Pull Request Process](#pull-request-process) - [Reporting Issues](#reporting-issues) +- [Releasing](#releasing) - [Getting Help](#getting-help) - [Recognition](#recognition) @@ -379,6 +380,29 @@ For new features, include: - `help wanted`: Community contribution needed - `question`: Questions and discussions +## Releasing + +Releases are cut by maintainers with the **Release** workflow (`.github/workflows/release.yml`). + +1. Open a PR that moves the `[Unreleased]` entries in `CHANGELOG.md` to a `## [X.Y.Z] - YYYY-MM-DD` + section, updates the compare links, and bumps the Studio package versions when they changed. + Merge it to `main`. +2. Run **Actions → Release → Run workflow** on `main` with the version `X.Y.Z` and `dry_run` enabled. + Check the previewed notes and the npm dry-run output. +3. Run it again with `dry_run` disabled. The workflow runs the Quality checks, creates the annotated + tag `vX.Y.Z` on `main`, creates the GitHub release from the CHANGELOG section, and publishes the + Studio packages to npm with provenance. + +`target` limits the run to `github` (tag and release only, for example a Go-only release) or `npm` +(publish an existing tag). Every step is idempotent: an existing tag or release is kept and package +versions already on npm are skipped, so a failed run can be re-run. The Go module needs no extra step; +the tag is enough for `go get`. + +npm publishing uses [Trusted Publishing](https://docs.npmjs.com/trusted-publishers), so no npm token is +stored. Each package needs a one-time trusted publisher on npmjs.com (package **Settings → Trusted +Publisher → GitHub Actions**) with organization `rendis`, repository `statepro`, and workflow +`release.yml`. + ## Getting Help - **Issues**: Use GitHub issues for bugs and feature requests