diff --git a/.gitea/workflows/node.yaml b/.gitea/workflows/node.yaml index 2955abb..3c13340 100644 --- a/.gitea/workflows/node.yaml +++ b/.gitea/workflows/node.yaml @@ -1,5 +1,11 @@ # Shared Node.js CI: install the locked dependencies and run the repository's own gates, in order, -# in the official node:-bookworm image. Verify only; nothing is published. +# in the official node:-bookworm image. +# +# Verify only, unless the caller sets `publish: true`. Then a release tag (vX.Y.Z or vX.Y.Z-
, e.g.
+# v1.2.0-RC.1) whose version equals `version-file` also publishes, after every command passed:
+#   npm-publish-dirs / publish-commands -> the owner's Gitea npm registry (prerelease -> dist-tag next)
+#   release-files                       -> a Gitea release with SHA256SUMS and the CHANGELOG section as notes
+# Branches and other tags never publish.
 name: node
 
 on:
@@ -28,9 +34,33 @@ on:
         description: Directory the install and commands run in
         type: string
         default: '.'
+      publish:
+        description: On a vX.Y.Z[-pre] tag, publish (npm and/or a Gitea release) after the commands pass
+        type: boolean
+        default: false
+      version-file:
+        description: package.json whose version the release tag must equal (v)
+        type: string
+        default: package.json
+      npm-publish-dirs:
+        description: Package directories to npm publish, one per line, in order; empty = no npm publish
+        type: string
+        default: '.'
+      publish-commands:
+        description: Shell commands that publish instead of the npm-publish-dirs loop (registry and dist-tag preconfigured)
+        type: string
+        default: ''
+      release-files:
+        description: Files or globs, one per line, attached to a Gitea release with SHA256SUMS; empty = no release
+        type: string
+        default: ''
+    secrets:
+      PACKAGES_TOKEN:
+        description: Package-write token for npm publishing (callers pass secrets inherit)
+        required: false
 
 permissions:
-  contents: read
+  contents: write
 
 env:
   UV_VERSION: 0.13.6
@@ -57,6 +87,32 @@ jobs:
           git config --global --add safe.directory "$GITHUB_WORKSPACE"
           echo "node $(node --version), npm $(npm --version)"
 
+      - name: Decide verify or release
+        id: plan
+        shell: bash
+        working-directory: ${{ inputs.working-directory }}
+        env:
+          PUBLISH: ${{ inputs.publish }}
+          REF_TYPE: ${{ github.ref_type }}
+          REF_NAME: ${{ github.ref_name }}
+          VERSION_FILE: ${{ inputs.version-file }}
+        run: |
+          set -euo pipefail
+          if [[ $PUBLISH != true || $REF_TYPE != tag ]]; then
+            echo "release=false" >> "$GITHUB_OUTPUT"; echo "Build kind: verify ($REF_TYPE $REF_NAME)"; exit 0
+          fi
+          if [[ ! $REF_NAME =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
+            echo "release=false" >> "$GITHUB_OUTPUT"; echo "Build kind: verify (tag $REF_NAME is not a release tag)"; exit 0
+          fi
+          version=$(node -p 'require(require("path").resolve(process.argv[1])).version' "$VERSION_FILE")
+          if [[ "v$version" != "$REF_NAME" ]]; then
+            echo "Tag $REF_NAME does not match $VERSION_FILE version $version"; exit 1
+          fi
+          dist_tag=latest
+          if [[ $version == *-* ]]; then dist_tag=next; fi
+          { echo "release=true"; echo "version=$version"; echo "dist_tag=$dist_tag"; } >> "$GITHUB_OUTPUT"
+          echo "Build kind: release $version (npm dist-tag $dist_tag)"
+
       - name: Install uv
         if: inputs.uv
         shell: bash
@@ -83,3 +139,93 @@ jobs:
         run: |
           printf '%s\n' "$COMMANDS" > /tmp/commands.sh
           bash -exo pipefail /tmp/commands.sh
+
+      - name: Publish to the Gitea npm registry
+        if: steps.plan.outputs.release == 'true' && (inputs.npm-publish-dirs != '' || inputs.publish-commands != '')
+        shell: bash
+        working-directory: ${{ inputs.working-directory }}
+        env:
+          PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
+          OWNER: ${{ github.repository_owner }}
+          DIRS: ${{ inputs.npm-publish-dirs }}
+          PUBLISH_COMMANDS: ${{ inputs.publish-commands }}
+          DIST_TAG: ${{ steps.plan.outputs.dist_tag }}
+        run: |
+          set -euo pipefail
+          if [[ -z ${PACKAGES_TOKEN:-} ]]; then echo "PACKAGES_TOKEN is empty: the caller needs secrets: inherit"; exit 1; fi
+          registry="$GITHUB_SERVER_URL/api/packages/$OWNER/npm/"
+          # A throwaway user config; the token stays in the environment, npm expands it.
+          export NPM_CONFIG_USERCONFIG=/tmp/publish.npmrc
+          printf '//%s:_authToken=${PACKAGES_TOKEN}\n' "${registry#*://}" > "$NPM_CONFIG_USERCONFIG"
+          export NPM_CONFIG_REGISTRY="$registry" NPM_CONFIG_TAG="$DIST_TAG"
+          if [[ -n $PUBLISH_COMMANDS ]]; then
+            printf '%s\n' "$PUBLISH_COMMANDS" > /tmp/publish.sh
+            bash -exo pipefail /tmp/publish.sh
+            exit 0
+          fi
+          while read -r dir; do
+            [[ -z $dir ]] && continue
+            (
+              cd "$dir"
+              name=$(node -p 'require("./package.json").name')
+              version=$(node -p 'require("./package.json").version')
+              # A publishConfig.registry (npmjs, GitHub) would override the registry; it is not committed back.
+              npm pkg delete publishConfig.registry
+              scope=()
+              if [[ $name == @*/* ]]; then scope=("--${name%%/*}:registry=$registry"); fi
+              echo "Publishing $name@$version to $registry (dist-tag $DIST_TAG)"
+              npm publish --registry "$registry" "${scope[@]}" --tag "$DIST_TAG"
+              echo "Published: $(npm view "$name@$version" version --registry "$registry" "${scope[@]}")"
+            )
+          done <<< "$DIRS"
+
+      - name: Gitea release
+        if: steps.plan.outputs.release == 'true' && inputs.release-files != ''
+        shell: bash
+        working-directory: ${{ inputs.working-directory }}
+        env:
+          TOKEN: ${{ github.token }}
+          FILES: ${{ inputs.release-files }}
+          TAG: ${{ github.ref_name }}
+          VERSION: ${{ steps.plan.outputs.version }}
+        run: |
+          set -euo pipefail
+          out=/tmp/release && rm -rf "$out" && mkdir -p "$out"
+          while read -r pattern; do
+            [[ -z $pattern ]] && continue
+            matches=($pattern)
+            [[ -e ${matches[0]} ]] || { echo "No file matches $pattern"; exit 1; }
+            for f in "${matches[@]}"; do
+              [[ -f $f ]] || continue
+              base=$(basename "$f")
+              if [[ $base == SHA256SUMS ]]; then (cd "$(dirname "$f")" && sha256sum --check --strict SHA256SUMS); continue; fi
+              [[ ! -e $out/$base ]] || { echo "Two release files are named $base"; exit 1; }
+              cp "$f" "$out/"
+            done
+          done <<< "$FILES"
+          (cd "$out" && files=(*) && sha256sum "${files[@]}" > SHA256SUMS && cat SHA256SUMS)
+
+          # Notes: the CHANGELOG.md section whose "## " heading names this version.
+          notes=$(awk -v v="$VERSION" '
+            /^## / { if (found) exit; h = $0; gsub(/[][]/, " ", h); n = split(h, w, /[ \t]+/)
+                     for (i = 1; i <= n; i++) if (w[i] == v || w[i] == "v" v) { found = 1; next } }
+            found { print }' CHANGELOG.md 2>/dev/null || true)
+          [[ -n ${notes//[[:space:]]/} ]] || notes="Release $TAG."
+          prerelease=false
+          if [[ $VERSION == *-* ]]; then prerelease=true; fi
+
+          api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
+          auth="Authorization: token $TOKEN"
+          if curl -fsS -o /dev/null -H "$auth" "$api/releases/tags/$TAG" 2>/dev/null; then
+            echo "A release for $TAG already exists; a version is released once"; exit 1
+          fi
+          body=$(NOTES="$notes" PRE="$prerelease" node -e 'process.stdout.write(JSON.stringify({
+            tag_name: process.env.TAG, name: process.env.TAG, body: process.env.NOTES,
+            draft: false, prerelease: process.env.PRE === "true" }))')
+          id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" |
+            node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(0, "utf8")).id))')
+          for f in "$out"/*; do
+            curl -fsS -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$id/assets?name=$(basename "$f")"
+            echo "Attached $(basename "$f")"
+          done
+          echo "Released: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG"
diff --git a/README.md b/README.md
index 099a20b..c0ab5a1 100644
--- a/README.md
+++ b/README.md
@@ -66,9 +66,59 @@ jobs:
 | `uv` | `false` | Install a pinned, sha256-verified `uv` for Python script checks |
 | `working-directory` | `.` | Where the install and commands run |
 | `runner` | `container-build` | Runner label |
+| `publish` | `false` | On a release tag, publish after every command passed (below) |
+| `version-file` | `package.json` | The release tag must be `v` of this file, or the build fails |
+| `npm-publish-dirs` | `.` | Package directories to `npm publish`, one per line, in order; empty = none |
+| `publish-commands` | empty | Commands that publish instead of the `npm-publish-dirs` loop |
+| `release-files` | empty | Files or globs, one per line, for a Gitea release; empty = no release |
 
-Verify only: it publishes nothing. npm, binary and release publishing are a
-separate, later workflow.
+### Publishing
+
+Without `publish: true` the workflow only verifies. With it, branches still only
+verify; a tag `vX.Y.Z` or `vX.Y.Z-
` (for example `v1.2.0-RC.1`) publishes:
+
+1. The tag must equal `v` + the `version-file` version. Set the version in
+   `package.json` (`npm version 1.2.0-RC.1 --no-git-tag-version`), merge, then tag
+   the merge commit. A mismatch fails the build before anything is installed.
+2. Install and `commands` run as on any push; a failure stops the release.
+3. Each `npm-publish-dirs` package goes to the owner's Gitea npm registry,
+   `https://git.kof22.com/api/packages//npm/`, with the `PACKAGES_TOKEN`
+   org secret (`secrets: inherit`). A prerelease version gets dist-tag `next`,
+   a final version `latest`. The package keeps its name and scope; a
+   `publishConfig.registry` (npmjs, GitHub) is ignored for the publish. A
+   version that already exists fails: publish a new version instead.
+4. `release-files` become a Gitea release for the tag (a prerelease for
+   `-
` versions) with a fresh `SHA256SUMS`, and the `CHANGELOG.md` section
+   whose `## ` heading names the version as its notes. A `SHA256SUMS` among the
+   files is checked, then replaced. An existing release for the tag fails.
+
+```yaml
+jobs:
+  node:
+    uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
+    secrets: inherit
+    with:
+      commands: |
+        npm test
+        npm run build
+      publish: true
+      # npm-publish-dirs: |          # workspaces, in dependency order
+      #   packages/client
+      #   packages/cli
+      # version-file: packages/cli/package.json
+      # release-files: dist/*        # with npm-publish-dirs: '' for a release only
+```
+
+To consume a package:
+
+```ini
+# .npmrc
+@scope:registry=https://git.kof22.com/api/packages/QRun-IO/npm/
+//git.kof22.com/api/packages/QRun-IO/npm/:_authToken=${GITEA_TOKEN}
+```
+
+Binaries for macOS or Windows need native runners; repositories that ship
+them (for example `QRun-IO/qqq-cli`) keep their own release workflow.
 
 ## Releasing
 
@@ -101,7 +151,8 @@ gets `v2` and a rollout, not a moved `v1`.
   is never published twice.
 - The `release:rc` PR label flow is gone. Cut a release candidate by pushing
   `release/X.Y`.
-- RC and hotfix publishes create a `v` git tag. No GitHub or Gitea
-  release objects are created; tags appear on the repository's Releases page.
+- RC and hotfix publishes create a `v` git tag. Maven builds create no
+  GitHub or Gitea release objects; tags appear on the repository's Releases page.
+  Node builds create a Gitea release only when `release-files` is set.
 - Not ported yet: security scans (gitleaks, Semgrep, OWASP, CycloneDX),
   SpotBugs/PMD, the QQQ middleware API-version check and frontend packaging.