From 903c97efad6cb64a970478215421cd31fdfb0963 Mon Sep 17 00:00:00 2001 From: Marko Petzold Date: Mon, 26 Oct 2026 18:24:10 +0100 Subject: [PATCH] ci: checks on pull requests; npm release on merge Joins the IronFlock CI harness: ci.yml (generated types current, build, workflow lint, release intent, release dry run), security.yml (report-only). build-publish.yml becomes the release workflow (name kept: npm's trusted publisher names it): the merge of a release pull request publishes to npm, then the release App tags the bare version and creates the GitHub release. just merge / just release-patch replace npm run release; the preflight and verify scripts and .npmrc go. --- .github/workflows/build-publish.yml | 115 +++++++++++++++++----------- .github/workflows/ci.yml | 65 ++++++++++++++++ .github/workflows/security.yml | 33 ++++++++ .npmrc | 1 - CLAUDE.md | 3 +- Justfile | 70 +++++++++++++++++ package.json | 8 +- scripts/release-preflight.sh | 39 ---------- scripts/release-verify.sh | 39 ---------- 9 files changed, 241 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/security.yml delete mode 100644 .npmrc create mode 100644 Justfile delete mode 100755 scripts/release-preflight.sh delete mode 100755 scripts/release-verify.sh diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml index acaca1c..6e13b61 100644 --- a/.github/workflows/build-publish.yml +++ b/.github/workflows/build-publish.yml @@ -1,62 +1,87 @@ -name: Build and Publish to npm and unpkg +# Release: the merge of a release pull request (`just release-patch`: version +# bump, title `release widget-value `) publishes the package to npm, then +# sets the tag (a bare version; a tag always means "on npm") and +# creates the GitHub release, both with the release GitHub App's token. Main +# accepts only pull requests whose required checks (ci-ok, security / report) +# passed, so every release comes from a merged, fully green commit; nothing is +# published or tagged from a laptop. +# The file keeps its old name because npm's trusted publisher of the package +# names it (IronFlock/widget-value :: build-publish.yml). +# A no-release merge starts nothing: `paths` holds only the version file, and +# when a no-release pull request touches it anyway (a dependency) the jobs +# below are skipped by its merge title, so no runner starts. +# ci.yml calls it on release pull requests (workflow_call), and run by hand +# (workflow_dispatch) it is a dry run: `npm publish --dry-run`, no tag. +# A re-run is safe: a version already on npm is not published again. +name: release on: push: - tags: - - "*" + branches: [main] + paths: [package.json] + workflow_call: + workflow_dispatch: -# id-token lets the runner mint the OIDC token npm exchanges for publish rights. -# Without it `npm publish` looks for a token and fails. permissions: - contents: write - id-token: write + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - build: + npm: + if: github.event_name != 'push' || startsWith(github.event.head_commit.message, 'release ') runs-on: ubuntu-latest - + timeout-minutes: 15 + permissions: + contents: read + # the OIDC token npm exchanges for publish rights (trusted publishing, + # bound to this file) + id-token: write steps: - - name: Checkout code - uses: actions/checkout@v7 - - # Pinned, not "24": npm trusted publishing needs npm >= 11.5.1, and the - # npm bundled with a Node line is not something we want floating. - - name: Install Node.js - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # npm >= 11.5.1 for trusted publishing; Node 24.20.0 bundles npm 11.19.0. + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: "24.20.0" registry-url: "https://registry.npmjs.org" - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - # No .npmrc, no NPM_TOKEN. npm detects the OIDC environment and - # authenticates against the trusted publisher configured for this - # package (IronFlock/widget-value :: build-publish.yml). - - name: Publish to npm - # Skips a version that is already on npm: the first @ironflock release of - # each widget was published by hand (npm only accepts a trusted publisher - # for an existing package), and its tag still runs this job. + # A publishing run never restores a cache another run could have written. + package-manager-cache: false + - run: npm ci + # Puts the version into the element name (widget--). + - run: npm run build + # No .npmrc, no NPM_TOKEN: npm detects the OIDC environment and + # authenticates against the trusted publisher of this package. + - name: npm publish + env: + PUBLISH: ${{ github.event_name == 'push' }} run: | - name=$(node -p "require('./package.json').name") - version=$(node -p "require('./package.json').version") - if [ "$(npm view "$name@$version" version 2>/dev/null)" = "$version" ]; then - echo "$name@$version is already on npm, skipping publish" + name=$(jq -r .name package.json) + version=$(jq -r .version package.json) + if [ "$PUBLISH" != true ]; then + npm publish --access public --dry-run + elif [ "$(npm view "$name@$version" version 2>/dev/null)" = "$version" ]; then + echo "$name@$version is already on npm" else npm publish --access public fi - - name: Get the new version - id: get_version - run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "${{ steps.get_version.outputs.version }}" \ - --title "Release ${{ steps.get_version.outputs.version }}" \ - --generate-notes + # The tag once the package is on npm, with the release GitHub App's token + # from the `release` environment (deployments from main only). + tag: + needs: [npm] + if: github.event_name == 'push' && startsWith(github.event.head_commit.message, 'release ') + uses: IronFlock/workflows/.github/workflows/release-tag.yml@v1 + permissions: + contents: read + # The App key is a secret of the `release` environment; environment + # secrets reach a reusable workflow only through `secrets: inherit`. + secrets: inherit # zizmor: ignore[secrets-inherit] + with: + version-file: package.json + tag-prefix: "" + # The GitHub release of the tag, with the same App token. + github-release: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..59fa988 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +# Gate (the committed generated files are current, the widget build; no unit +# tests) and workflow lint from the shared IronFlock CI harness. +# Pull requests into main only: the ruleset lets nothing reach main without +# ci-ok. The release intent decides whether the merge releases. +name: ci + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gate: + uses: IronFlock/workflows/.github/workflows/node-gate.yml@v1 + with: + package-manager: npm + skip: unit + + workflows: + uses: IronFlock/workflows/.github/workflows/workflows-lint.yml@v1 + + # The version decides: a bump makes a release PR (`just release-patch`, + # title `release widget-value `), an unchanged version a no-release PR + # (`just merge`) whose merge releases nothing. Tags are bare versions. + intent: + uses: IronFlock/workflows/.github/workflows/release-intent.yml@v1 + with: + version-file: package.json + tag-prefix: "" + + # Release pull requests only: build-publish.yml as a dry run (`npm publish + # --dry-run`, no tag), so a broken package never reaches main. + release-build: + needs: [intent] + if: needs.intent.outputs.kind == 'release' + uses: ./.github/workflows/build-publish.yml + permissions: + contents: read + id-token: write + + # The required check of the main-branch ruleset: green only when every job + # above succeeded (skipped or cancelled counts as failed; release-build may be + # skipped, on a no-release pull request). + ci-ok: + if: ${{ always() }} + needs: [gate, workflows, intent, release-build] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: all ci jobs succeeded + env: + REQUIRED: ${{ needs.gate.result }} ${{ needs.workflows.result }} ${{ needs.intent.result }} + RELEASE_BUILD: ${{ needs.release-build.result }} + KIND: ${{ needs.intent.outputs.kind }} + run: | + echo "required: $REQUIRED, release-build: $RELEASE_BUILD ($KIND)" + for r in $REQUIRED; do [ "$r" = success ] || exit 1; done + if [ "$KIND" = release ]; then [ "$RELEASE_BUILD" = success ]; else [ "$RELEASE_BUILD" = skipped ]; fi diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..3c2772e --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,33 @@ +# Security screening from the shared IronFlock CI harness: secrets, npm +# dependencies (osv-scanner on package-lock.json), semgrep, configuration and +# licences. +# +# Report-only for now (fail-on empty): the report job lists every finding; +# blocking follows once they are fixed. Accepted findings go into +# .trivyignore.yaml / security/vex.openvex.json / .gitleaks.toml with a reason. +name: security + +on: + pull_request: + branches: [main] + schedule: + # Mondays 12:57 UTC: catch CVEs disclosed against unchanged code. + - cron: "57 12 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + security: + uses: IronFlock/workflows/.github/workflows/security.yml@v1 + permissions: + contents: read + security-events: write + with: + languages: node + fail-on: "" diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 9555e17..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -tag-version-prefix="" diff --git a/CLAUDE.md b/CLAUDE.md index 333678c..e2e0f37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `npm run build` — Production build via Vite library mode → `dist/widget-value.js` (ES only). - `npm run watch` — Rebuild on changes without dev server. - `npm run types` — Regenerate `src/definition-schema.d.ts` from `src/definition-schema.json` using `json2ts`. Run this after editing the JSON schema. -- `npm run release` — `npm version patch`: preflight guards (on `main`, clean tree, not behind `origin/main`, generated files current, build passes), then the bump and a `dist/` rebuild (the `version` hook, so the local build registers the new tag), then commit, bare-semver tag, `git push --follow-tags`, then waits on the CI run and fails if the npm publish fails. `npm run release:minor` / `release:major` for other bumps. +- `just lint` — what the CI gate checks besides the build: regenerating the committed generated files (`npm run types`) changes nothing. `just build` = `npm run build`. +- `just merge` / `just release-patch` (`release-minor`, `release-major`) — pull requests into `main` from a feature branch with everything committed: a no-release one, or a release one whose merge publishes to npm in CI (`.github/workflows/build-publish.yml`), then the release GitHub App tags the bare version and creates the GitHub release. Never `npm publish`, tag or edit the version by hand. - `npm run link` / `npm run unlink` — Symlink the built package into a sibling `../RESWARM/frontend` checkout for local integration testing. No test runner or lint script is wired into npm scripts (eslint/prettier are devDependencies; no `test` script exists). diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..7c54e50 --- /dev/null +++ b/Justfile @@ -0,0 +1,70 @@ +# The recipes the CI gate runs and the release, the same in every widget +# repository (IronFlock/widget-*) but for RELEASE_NAME. + +# The package name without its scope; the tags are bare versions (1.0.10). +RELEASE_NAME := "widget-value" +VERSION_FILE := "package.json" +# Every file _write-version changes; the release commit holds exactly these. +VERSION_FILES := "package.json package-lock.json" + +# Generated files are committed (`npm run types`, and `catalog` where it +# exists): regenerating them must change nothing, also next to uncommitted +# work. +# +# Lint as CI runs it: the generated files are current +lint: + #!/usr/bin/env bash + set -euo pipefail + before=$(git diff | git hash-object --stdin) + for gen in types catalog; do + if [ -n "$(jq -r --arg g "$gen" '.scripts[$g] // empty' package.json)" ]; then + npm run --silent "$gen" + fi + done + if [ "$(git diff | git hash-object --stdin)" != "$before" ]; then + echo "generated files were stale (now regenerated): commit them" >&2 + git diff --stat >&2 + exit 1 + fi + +# Build the widget bundle (dist/) +build: + npm run build + +# ------------------------------------------------------------------ release -- +# Pull requests into main, from IronFlock/workflows (scripts/pr.sh at v1); see +# its README, "Pull requests from a laptop": +# just merge [TITLE] a no-release pull request from this feature branch +# just release-patch a release pull request (-minor, -major): the bump +# from main's version on top of this branch (or on a +# new branch release/ from an up-to-date main) +# Both merge automatically once ci and security are green. The merge of a +# release pull request runs .github/workflows/build-publish.yml: npm publish, +# then the tag (release GitHub App), then the GitHub release. + +# Writes the version; called by pr.sh with the next version. +_write-version version: + npm version "{{version}}" --no-git-tag-version --allow-same-version + +[positional-arguments] +_pr *args: + #!/usr/bin/env bash + set -euo pipefail + script=$(mktemp) + trap 'rm -f "$script"' EXIT + gh api -H 'Accept: application/vnd.github.raw' \ + 'repos/IronFlock/workflows/contents/scripts/pr.sh?ref=v1' > "$script" + bash "$script" "$@" + +[positional-arguments] +merge TITLE="": + just _pr merge {{VERSION_FILE}} "$1" + +release-patch: + TAG_PREFIX= just _pr release patch {{RELEASE_NAME}} {{VERSION_FILE}} build-publish.yml {{VERSION_FILES}} + +release-minor: + TAG_PREFIX= just _pr release minor {{RELEASE_NAME}} {{VERSION_FILE}} build-publish.yml {{VERSION_FILES}} + +release-major: + TAG_PREFIX= just _pr release major {{RELEASE_NAME}} {{VERSION_FILE}} build-publish.yml {{VERSION_FILES}} diff --git a/package.json b/package.json index e6d813e..cbb301f 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,7 @@ "watch": "vite build --watch", "link": "npm run build && npm link && cd ../RESWARM/frontend && npm link @ironflock/widget-value", "unlink": "npm unlink --global && cd ../RESWARM/frontend && npm unlink @ironflock/widget-value && npm i @ironflock/widget-value", - "types": "cat src/definition-schema.json | json2ts --style.tabWidth=4 > src/definition-schema.d.ts", - "preversion": "sh scripts/release-preflight.sh", - "version": "npm run build", - "postversion": "git push --follow-tags && sh scripts/release-verify.sh", - "release": "npm version patch", - "release:minor": "npm version minor", - "release:major": "npm version major" + "types": "cat src/definition-schema.json | json2ts --style.tabWidth=4 > src/definition-schema.d.ts" }, "dependencies": { "lit": "^3.3.3", diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh deleted file mode 100755 index 8485d1c..0000000 --- a/scripts/release-preflight.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env sh -# Guards for the `preversion` lifecycle hook, so they run before anything is -# bumped, committed or tagged. Every exit path here leaves the repo as it was. -set -eu - -fail() { - echo "release: $1" >&2 - exit 1 -} - -has_script() { - [ "$(node -p "require('./package.json').scripts['$1'] ? 'yes' : 'no'")" = yes ] -} - -branch=$(git symbolic-ref --short HEAD 2>/dev/null) || fail "detached HEAD; check out main" -[ "$branch" = "main" ] || fail "on '$branch', but releases are cut from main" - -git diff --quiet && git diff --cached --quiet || - fail "working tree is dirty; commit or stash before releasing" - -git fetch --quiet origin main || fail "cannot reach origin" -git merge-base --is-ancestor origin/main HEAD || - fail "HEAD is behind origin/main; pull, then release" - -# Generated files are tracked, so a stale copy would ship inside the tag. -# The tree was clean a moment ago, so regenerating must leave it clean too -- -# any diff means a source (e.g. definition-schema.json) was edited without -# rerunning its generator. -for gen in types catalog; do - has_script "$gen" && npm run --silent "$gen" -done -git diff --quiet || - fail "generated files are stale: $(git diff --name-only | tr '\n' ' ')-- rerun the generator, commit, then release" - -# Build gate. CI builds again from the tag, but failing here costs no version. -# This runs BEFORE the bump, so the dist it leaves registers the OLD version -# tag; the `version` hook in package.json rebuilds after the bump so local -# consumers (IronFlock-UI dev aliases this dist) get the new tag. -npm run build diff --git a/scripts/release-verify.sh b/scripts/release-verify.sh deleted file mode 100755 index 195f76f..0000000 --- a/scripts/release-verify.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env sh -# Runs after the tag is pushed. Publishing happens in CI (OIDC trusted -# publishing cannot run from a laptop), so without this the release command -# reports success whether or not the package ever reaches npm -- which is how -# widget-linechart 1.7.5 and widget-statehistory 1.0.19 sat unpublished. -set -eu - -command -v gh >/dev/null 2>&1 || { - echo "release: gh not installed, skipping the CI check; verify the run yourself" >&2 - exit 0 -} - -version=$(node -p "require('./package.json').version") -# remote.origin.url explicitly: some of these repos carry an unrelated -# 'upstream' remote, and a bare `gh run list` resolves to the wrong repo. -repo=$(git config --get remote.origin.url | sed 's#.*github\.com[:/]##; s#\.git$##') - -echo "release: waiting for the $version workflow on $repo" -run="" -i=0 -while [ "$i" -lt 30 ]; do - run=$(gh run list -R "$repo" --branch "$version" --limit 1 \ - --json databaseId --jq '.[0].databaseId' 2>/dev/null || true) - [ -n "$run" ] && break - i=$((i + 1)) - sleep 2 -done -[ -n "$run" ] || { - echo "release: no workflow run appeared for $version; check https://github.com/$repo/actions" >&2 - exit 1 -} - -gh run watch "$run" -R "$repo" --exit-status --interval 5 >/dev/null || { - echo "release: the $version workflow FAILED -- $version is NOT on npm" >&2 - echo "release: gh run view $run -R $repo --log-failed" >&2 - exit 1 -} - -echo "release: $version published"