diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..0849903 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,32 @@ +# Code owners for files that can alter the release pipeline or execute code +# while release/publication credentials are present. +# +# IMPORTANT: CODEOWNERS has no effect unless the 'main' branch ruleset requires +# a pull request with "Require review from Code Owners" enabled. As a solo +# maintainer you cannot approve your own PRs, so enable code-owner review only +# once the project has a second trusted maintainer. Until then this file still +# documents ownership and triggers review requests. See docs/release-security.md. + +# CI/CD workflows, this file, and repository automation +/.github/ @ivangsa + +# Gradle build definitions: plugins and build logic declared here execute +# during release builds and snapshot publication. +/build.gradle.kts @ivangsa +/settings.gradle.kts @ivangsa +/gradle/ @ivangsa +/gradlew @ivangsa +/gradlew.bat @ivangsa + +# Kotlin/JS toolchain lockfile (yarn.lock) used by the Gradle build. +/kotlin-js-store/ @ivangsa + +# npm code executed during builds (Node.js integration tests). +/nodejs-test-project/ @ivangsa + +# Release notes consumed verbatim by the release workflow. +/release-notes/ @ivangsa + +# Release documentation. +/docs/release-security.md @ivangsa +/RELEASING.md @ivangsa diff --git a/.github/dependabot..yml b/.github/dependabot..yml deleted file mode 100644 index d6a086a..0000000 --- a/.github/dependabot..yml +++ /dev/null @@ -1,17 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - package-ecosystem: "maven" # See documentation for possible values - directory: "/" # Location of package manifests - target-branch: "master" - schedule: - interval: "daily" - - package-ecosystem: "github-actions" - directory: "/" - target-branch: "master" - schedule: - interval: "daily" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6990ced --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +# Dependabot keeps release-relevant dependencies current: +# - github-actions: bumps the full-SHA pins in .github/workflows (supply-chain +# control -- pinned actions only move via a reviewable PR). +# - gradle: dependency/plugin updates against the working branch 'develop'. +# - npm: Node.js integration-test project dependencies. +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "gradle" + directory: "/" + target-branch: "develop" + schedule: + interval: "weekly" + - package-ecosystem: "npm" + directory: "/nodejs-test-project" + target-branch: "develop" + schedule: + interval: "weekly" diff --git a/.github/workflows/prepare-maven-release.yml b/.github/workflows/prepare-maven-release.yml deleted file mode 100644 index deef0be..0000000 --- a/.github/workflows/prepare-maven-release.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Create Gradle Release - -on: - workflow_dispatch: - inputs: - releaseVersion: - description: "Release Version (e.g., 1.0.0):" - required: true - developmentVersion: - description: "Next Development Version (e.g., 1.1.0-SNAPSHOT):" - required: true - -permissions: - contents: write - pull-requests: write - -jobs: - release: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Configure Git User - run: | - git config user.email "${{ github.actor }}@users.noreply.github.com" - git config user.name "${{ github.actor }}" - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Update version to release version - run: | - sed -i 's/^version = ".*"/version = "${{ github.event.inputs.releaseVersion }}"/' build.gradle.kts - git add build.gradle.kts - git commit -m "[release] Prepare release ${{ github.event.inputs.releaseVersion }}" - - - name: Create release tag - run: | - git tag -a "v${{ github.event.inputs.releaseVersion }}" -m "Release ${{ github.event.inputs.releaseVersion }}" - - - name: Update version to next development version - run: | - sed -i 's/^version = ".*"/version = "${{ github.event.inputs.developmentVersion }}"/' build.gradle.kts - git add build.gradle.kts - git commit -m "[release] Prepare for next development iteration ${{ github.event.inputs.developmentVersion }}" - - - name: Push changes to release branch - run: | - git push origin HEAD:release/${{ github.event.inputs.releaseVersion }} - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Create Pull Request - id: cpr - uses: peter-evans/create-pull-request@v5 - with: - token: ${{ github.token }} - branch: release/${{ github.event.inputs.releaseVersion }} - base: main - title: 'Release ${{ github.event.inputs.releaseVersion }}' - body: | - ## Release ${{ github.event.inputs.releaseVersion }} - - This PR contains: - - Version bump to ${{ github.event.inputs.releaseVersion }} - - Version bump to next development version ${{ github.event.inputs.developmentVersion }} - - Release tag v${{ github.event.inputs.releaseVersion }} - - The Maven Central publish workflow is triggered by the pushed tag. - labels: release - - - name: Enable Pull Request Automerge - if: steps.cpr.outputs.pull-request-number != '' - run: gh pr merge --merge --auto --delete-branch "${{ steps.cpr.outputs.pull-request-number }}" - env: - GH_TOKEN: ${{ github.token }} - - - name: Push Release Tag - run: git push origin "v${{ github.event.inputs.releaseVersion }}" - env: - GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/publish-maven-central.yml b/.github/workflows/publish-maven-central.yml deleted file mode 100644 index 515226f..0000000 --- a/.github/workflows/publish-maven-central.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Publish Release to Maven Central - -on: - release: - types: - - created - workflow_dispatch: - inputs: - gitRef: - description: 'Git ref to publish (tag or branch)' - required: true - default: 'main' - -permissions: - contents: read - -concurrency: - group: publish-maven-central-${{ inputs.gitRef || github.event.release.tag_name || github.ref }} - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.gitRef || github.event.release.tag_name }} - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - cache: 'gradle' - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Build and test - run: ./gradlew build - - - name: Publish and release to Maven Central - run: ./gradlew publishAndReleaseToMavenCentral - env: - ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.CENTRAL_USERNAME }} - ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.CENTRAL_TOKEN }} - ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGN_KEY }} - ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGN_KEY_PASS }} - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: maven-central-build-artifacts - path: | - build/libs/ - build/js/packages/ diff --git a/.github/workflows/publish-maven-snapshots.yml b/.github/workflows/publish-maven-snapshots.yml index 4fe94a1..1625559 100644 --- a/.github/workflows/publish-maven-snapshots.yml +++ b/.github/workflows/publish-maven-snapshots.yml @@ -1,38 +1,74 @@ name: Build and Publish Snapshots +# Security model: see docs/release-security.md#snapshot-publication +# +# Snapshot publication triggers only from the protected internal branches +# 'develop' and 'next' (push, or explicit dispatch from those branches). +# Secrets live in the 'maven-central-snapshots' GitHub Environment whose +# deployment branch policy is restricted to develop/next, so a dispatch from +# any other branch cannot obtain them even with a modified workflow file. +# +# Residual risk (documented): any code merged into develop/next executes via +# `./gradlew publishToMavenCentral` while the snapshot publishing credentials +# are present. on: push: - branches: [ develop, next ] + branches: [develop, next] workflow_dispatch: permissions: contents: read +concurrency: + group: maven-snapshots-${{ github.ref }} + cancel-in-progress: false + jobs: - build: + publish-snapshot: + name: Build, test and publish snapshot runs-on: ubuntu-latest - + timeout-minutes: 60 + # Defense in depth; the environment branch policy is the hard boundary. + if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/next' + environment: maven-central-snapshots steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + # The committed gradle-wrapper.jar is executable code: verify its + # checksum against Gradle's published checksums before running it. + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: - java-version: '17' - distribution: 'temurin' - cache: 'gradle' + java-version: "17" + distribution: "temurin" + cache: "gradle" - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: '18' + node-version: "18" - - name: Grant execute permission for gradlew - run: chmod +x gradlew + # Guard against accidentally publishing a release version as a snapshot. + # Runs without secrets in the environment. + - name: Enforce snapshot version + run: | + set -euo pipefail + if ! grep -qE '^version = "[0-9]+\.[0-9]+\.[0-9]+-SNAPSHOT"$' build.gradle.kts; then + echo "::error::build.gradle.kts version is not a -SNAPSHOT; refusing to publish a snapshot." + grep '^version = ' build.gradle.kts || true + exit 1 + fi - name: Build and test with coverage - run: ./gradlew build koverHtmlReport koverXmlReport koverLog + run: | + chmod +x gradlew + ./gradlew build koverHtmlReport koverXmlReport koverLog - name: Extract coverage metrics if: always() @@ -62,12 +98,6 @@ jobs: env.write(f"BRANCH_COVERAGE={metrics.get('BRANCH', 0)}\n") PY - - name: Print coverage to console - if: always() - run: | - echo "line coverage = ${LINE_COVERAGE}%" - echo "branch coverage = ${BRANCH_COVERAGE}%" - - name: Publish coverage summary if: always() run: | @@ -80,7 +110,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload coverage reports - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: coverage-reports @@ -88,6 +118,10 @@ jobs: build/reports/kover/ build/reports/tests/ + # Secrets are scoped to this single step. Everything Gradle runs here + # (plugins, dependencies, Kotlin/JS tooling) executes with the snapshot + # credentials present -- that is the documented residual risk of + # snapshot publishing. - name: Publish snapshots to Maven Central run: ./gradlew publishToMavenCentral env: diff --git a/.github/workflows/release-from-notes.yml b/.github/workflows/release-from-notes.yml new file mode 100644 index 0000000..ff5c1f7 --- /dev/null +++ b/.github/workflows/release-from-notes.yml @@ -0,0 +1,835 @@ +name: Release from Notes + +run-name: "Release v${{ inputs.version }} from main" + +# Security model: see docs/release-security.md +# +# The ONLY source for a release is the current commit of the protected 'main' +# branch at dispatch time (github.sha). No branch/ref/sha/repository inputs are +# accepted. Maven Central credentials and the GPG signing key live exclusively +# in the protected 'maven-central-upload' GitHub Environment and are only +# exposed to the 'sign-and-upload-central' job, which checks out no repository +# code and executes no Gradle/npm logic. npm publication uses OIDC trusted +# publishing (no stored token) behind the separate protected 'npm-publish' +# environment. +on: + workflow_dispatch: + inputs: + version: + description: "Semantic version to release, without a v prefix (e.g. 1.0.0 or 1.0.0-rc.1)" + required: true + type: string + developmentVersion: + description: "Next development version (e.g. 1.0.1-SNAPSHOT). Leave empty to auto-derive the next patch SNAPSHOT." + required: false + type: string + default: "" + # Flip the default to true once the npm package exists and OIDC trusted + # publishing is configured (docs/release-security.md#npm-publication). + publishNpm: + description: "Publish @zenwave360/json-schema-ref-parser-kmp to npm (requires npm-publish environment approval)" + required: false + type: boolean + default: false + +# Default deny: every job gets read-only unless it explicitly declares more. +permissions: + contents: read + +# Only one release may run at a time, and a running release is never cancelled. +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + +env: + NPM_PACKAGE: "@zenwave360/json-schema-ref-parser-kmp" + NPM_PACKAGE_URLENC: "@zenwave360%2Fjson-schema-ref-parser-kmp" + CENTRAL_NAMESPACE: "io.zenwave360.jsonrefparser" + CENTRAL_ARTIFACT: "json-schema-ref-parser-kmp" + +jobs: + # --------------------------------------------------------------------------- + # validate: no secrets, read-only. Validates inputs, captures the trusted + # main SHA (github.sha, immutable across reruns), validates release notes, + # and checks the version is not already released anywhere (git tag, GitHub + # release, Maven Central, npm when enabled). + # --------------------------------------------------------------------------- + validate: + name: Validate version, notes and trusted commit + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + release_sha: ${{ steps.capture.outputs.release_sha }} + version: ${{ steps.capture.outputs.version }} + tag: ${{ steps.capture.outputs.tag }} + notes_file: ${{ steps.capture.outputs.notes_file }} + dev_version: ${{ steps.capture.outputs.dev_version }} + publish_npm: ${{ steps.capture.outputs.publish_npm }} + steps: + - name: Require dispatch from the default branch (main) + if: github.ref != 'refs/heads/main' + run: | + echo "::error::Release must be dispatched from 'main'. Got '${GITHUB_REF}'." + exit 1 + + # github.sha is the tip of main captured once at dispatch time; it does + # not move if main receives new pushes, and it is stable across reruns. + - name: Checkout trusted main commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Validate inputs, release notes and duplicate protection + id: capture + env: + # Inputs are passed via env (never interpolated into script source) + # and validated against a strict pattern before any other use. + INPUT_VERSION: ${{ inputs.version }} + INPUT_DEV_VERSION: ${{ inputs.developmentVersion }} + INPUT_PUBLISH_NPM: ${{ inputs.publishNpm }} + RELEASE_SHA: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # --- version policy: X.Y.Z or X.Y.Z-rc.N, no 'v' prefix --- + if [[ ! "${INPUT_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "::error::version must match X.Y.Z or X.Y.Z-rc.N (e.g. 1.0.0, 1.0.0-rc.1). Got '${INPUT_VERSION}'." + exit 1 + fi + VERSION="${INPUT_VERSION}" + TAG="v${VERSION}" + + # --- next development version: X.Y.Z-SNAPSHOT, auto-derived if empty --- + if [[ -z "${INPUT_DEV_VERSION}" ]]; then + BASE="${VERSION%%-*}" + MAJOR="${BASE%%.*}"; REST="${BASE#*.}"; MINOR="${REST%%.*}"; PATCH="${REST#*.}" + if [[ "${VERSION}" == *-rc.* ]]; then + DEV_VERSION="${BASE}-SNAPSHOT" + else + DEV_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))-SNAPSHOT" + fi + else + DEV_VERSION="${INPUT_DEV_VERSION}" + fi + if [[ ! "${DEV_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-SNAPSHOT$ ]]; then + echo "::error::developmentVersion must match X.Y.Z-SNAPSHOT. Got '${DEV_VERSION}'." + exit 1 + fi + + if [[ "${INPUT_PUBLISH_NPM}" != "true" && "${INPUT_PUBLISH_NPM}" != "false" ]]; then + echo "::error::publishNpm must be true or false." + exit 1 + fi + + # --- trusted commit must be reachable from origin/main --- + if ! git merge-base --is-ancestor "${RELEASE_SHA}" origin/main; then + echo "::error::Captured commit ${RELEASE_SHA} is not reachable from origin/main." + exit 1 + fi + + # --- release notes: deterministic path derived from the validated version --- + NOTES_FILE="release-notes/release-notes.v${VERSION}.md" + if [[ -L "${NOTES_FILE}" ]]; then + echo "::error::Release notes ${NOTES_FILE} must not be a symlink." + exit 1 + fi + if [[ ! -f "${NOTES_FILE}" || ! -s "${NOTES_FILE}" ]]; then + echo "::error::Missing or empty release notes: ${NOTES_FILE}. Commit them to main before releasing." + exit 1 + fi + if ! grep -qF "${VERSION}" "${NOTES_FILE}"; then + echo "::error::${NOTES_FILE} never mentions '${VERSION}'. Are these the notes for this release?" + exit 1 + fi + + # --- duplicate release protection --- + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "::error::Tag ${TAG} already exists locally." + exit 1 + fi + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "::error::Tag ${TAG} already exists on origin." + exit 1 + fi + if gh release view "${TAG}" >/dev/null 2>&1; then + echo "::error::GitHub release ${TAG} already exists." + exit 1 + fi + HTTP_CODE="$(curl -sS -o /dev/null -w '%{http_code}' \ + "https://repo1.maven.org/maven2/io/zenwave360/jsonrefparser/${CENTRAL_ARTIFACT}/${VERSION}/")" + if [[ "${HTTP_CODE}" != "404" ]]; then + echo "::error::Version ${VERSION} appears already published on Maven Central (HTTP ${HTTP_CODE})." + exit 1 + fi + + # --- npm pre-flight (only when npm publication is requested) --- + if [[ "${INPUT_PUBLISH_NPM}" == "true" ]]; then + NPM_META_FILE="$(mktemp)" + NPM_CODE="$(curl -sS -o "${NPM_META_FILE}" -w '%{http_code}' \ + "https://registry.npmjs.org/${NPM_PACKAGE_URLENC}")" + if [[ "${NPM_CODE}" != "200" ]]; then + echo "::error::${NPM_PACKAGE} does not exist on registry.npmjs.org (HTTP ${NPM_CODE})." + echo "::error::OIDC trusted publishing requires an existing package: the FIRST publish is manual. See docs/release-security.md#first-npm-publish" + exit 1 + fi + if ! jq --arg v "${VERSION}" -e '.versions[$v] | not' "${NPM_META_FILE}" >/dev/null; then + echo "::error::${NPM_PACKAGE}@${VERSION} is already published on npm." + exit 1 + fi + rm -f "${NPM_META_FILE}" + fi + + { + echo "release_sha=${RELEASE_SHA}" + echo "version=${VERSION}" + echo "tag=${TAG}" + echo "notes_file=${NOTES_FILE}" + echo "dev_version=${DEV_VERSION}" + echo "publish_npm=${INPUT_PUBLISH_NPM}" + } >> "${GITHUB_OUTPUT}" + + { + echo "### Release v${VERSION}" + echo "- Trusted main commit: \`${RELEASE_SHA}\`" + echo "- Tag: \`${TAG}\`" + echo "- Release notes: \`${NOTES_FILE}\`" + echo "- Next development version: \`${DEV_VERSION}\`" + echo "- npm publication: \`${INPUT_PUBLISH_NPM}\`" + } >> "${GITHUB_STEP_SUMMARY}" + + # --------------------------------------------------------------------------- + # prepare-release: the ONLY job with contents:write before publication. + # Bumps the version in build.gradle.kts with sed (NO Gradle execution, so no + # repository-controlled code runs in this job at all), creates the release + # commit + next development commit, tags, and pushes atomically to main. + # Receives NO Central/npm/signing secrets; the GITHUB_TOKEN exists only in + # the push step. Fails (non-fast-forward) if main moved since dispatch. + # --------------------------------------------------------------------------- + prepare-release: + name: Create release commit and tag + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: validate + permissions: + # push release commit + tag to main (requires ruleset bypass for the + # GitHub Actions app, see docs/release-security.md#repository-rules) + contents: write + outputs: + release_commit_sha: ${{ steps.commit.outputs.release_commit_sha }} + steps: + - name: Checkout trusted main commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.release_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set release version and create release commit and tag + id: commit + env: + VERSION: ${{ needs.validate.outputs.version }} + TAG: ${{ needs.validate.outputs.tag }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + set_version() { # set_version + if [[ "$(grep -c '^version = ' build.gradle.kts)" != "1" ]]; then + echo "::error::Expected exactly one top-level 'version = ' line in build.gradle.kts." + exit 1 + fi + sed -i -E "s/^version = \".*\"$/version = \"$1\"/" build.gradle.kts + if ! grep -qxF "version = \"$1\"" build.gradle.kts; then + echo "::error::Failed to set version $1 in build.gradle.kts." + exit 1 + fi + # Only build.gradle.kts may change; anything else aborts the release. + UNEXPECTED="$(git diff --name-only | grep -vxF 'build.gradle.kts' || true)" + if [[ -n "${UNEXPECTED}" ]]; then + echo "::error::Unexpected files modified by version update:" + echo "${UNEXPECTED}" + exit 1 + fi + git --no-pager diff + } + + set_version "${VERSION}" + git commit -am "chore(release): release ${VERSION}" + git tag -a "${TAG}" -m "Release ${VERSION}" + echo "release_commit_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + + - name: Set next development version + env: + DEV_VERSION: ${{ needs.validate.outputs.dev_version }} + run: | + set -euo pipefail + sed -i -E "s/^version = \".*\"$/version = \"${DEV_VERSION}\"/" build.gradle.kts + if ! grep -qxF "version = \"${DEV_VERSION}\"" build.gradle.kts; then + echo "::error::Failed to set next development version in build.gradle.kts." + exit 1 + fi + git --no-pager diff + git commit -am "chore(release): prepare next development version ${DEV_VERSION}" + + # The token is only present in this step's env, never persisted to disk + # or exposed to any build tool. The non-fast-forward push doubles as the + # "fail if main moved during the release" policy. + - name: Push release commits and tag to main + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.validate.outputs.release_sha }} + TAG: ${{ needs.validate.outputs.tag }} + run: | + set -euo pipefail + + git fetch origin main + if [[ "$(git rev-parse origin/main)" != "${RELEASE_SHA}" ]]; then + echo "::error::main moved while the release was running (expected ${RELEASE_SHA}). Start a new release run." + exit 1 + fi + + ASKPASS="$(mktemp)" + trap 'rm -f "${ASKPASS}"' EXIT + printf '#!/bin/sh\nprintf %%s "${GITHUB_TOKEN}"\n' > "${ASKPASS}" + chmod 700 "${ASKPASS}" + + GIT_ASKPASS="${ASKPASS}" git push --atomic \ + "https://x-access-token@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/main" "refs/tags/${TAG}" + + # --------------------------------------------------------------------------- + # build-and-test: full Gradle build from the immutable release commit with NO + # publication or signing secrets and a read-only token. Gradle plugins, tests, + # Kotlin/JS tooling and npm lifecycle scripts (nodejs integration tests) are + # treated as arbitrary code execution: there is nothing here worth stealing. + # Produces the exact unsigned Maven artifacts (staged to a local repository), + # the packed npm tarball, plus SHA256SUMS and a manifest binding everything + # to this release. + # --------------------------------------------------------------------------- + build-and-test: + name: Build, test and stage artifacts + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: [validate, prepare-release] + permissions: + contents: read + steps: + - name: Checkout immutable release commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.prepare-release.outputs.release_commit_sha }} + persist-credentials: false + + # The committed gradle-wrapper.jar is executable code: verify its + # checksum against Gradle's published checksums before running it. + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 + + - name: Set up JDK 17 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + java-version: "17" + distribution: "temurin" + cache: "gradle" + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "18" + + - name: Build, test and stage Maven publications locally + run: | + set -euo pipefail + chmod +x gradlew + # localStaging is a file:// repository under build/staging-deploy + # (declared in build.gradle.kts): publishing there needs no + # credentials and no signing. + ./gradlew build publishAllPublicationsToLocalStagingRepository + + - name: Pack npm package tarball + env: + VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + PKG_DIR="build/js/packages/json-schema-ref-parser-kmp" + + if [[ ! -f "${PKG_DIR}/package.json" ]]; then + echo "::error::Generated npm package not found at ${PKG_DIR}." + exit 1 + fi + NAME="$(jq -er '.name' "${PKG_DIR}/package.json")" + PKG_VERSION="$(jq -er '.version' "${PKG_DIR}/package.json")" + if [[ "${NAME}" != "${NPM_PACKAGE}" ]]; then + echo "::error::package.json name '${NAME}' != expected '${NPM_PACKAGE}'." + exit 1 + fi + if [[ "${PKG_VERSION}" != "${VERSION}" ]]; then + echo "::error::package.json version '${PKG_VERSION}' != release version '${VERSION}'." + exit 1 + fi + + mkdir -p "${RUNNER_TEMP}/release-staging/npm" + npm pack --ignore-scripts \ + --pack-destination "${RUNNER_TEMP}/release-staging/npm" "./${PKG_DIR}" + + - name: Generate checksums and release manifest + env: + VERSION: ${{ needs.validate.outputs.version }} + TAG: ${{ needs.validate.outputs.tag }} + SOURCE_SHA: ${{ needs.validate.outputs.release_sha }} + RELEASE_COMMIT_SHA: ${{ needs.prepare-release.outputs.release_commit_sha }} + run: | + set -euo pipefail + STAGING="${RUNNER_TEMP}/release-staging" + cp -r build/staging-deploy "${STAGING}/maven" + cd "${STAGING}" + + # Repository metadata is not part of a Central Portal bundle. + find maven -name 'maven-metadata.xml*' -type f -delete + + # Every staged file must live under the expected groupId and version. + BAD_PATHS="$(find maven -type f \( ! -path "maven/io/zenwave360/jsonrefparser/*" -o ! -path "*/${VERSION}/*" \) || true)" + if [[ -n "${BAD_PATHS}" ]]; then + echo "::error::Staged files outside io/zenwave360/jsonrefparser/**/${VERSION}/:" + echo "${BAD_PATHS}" + exit 1 + fi + if [[ ! -f "maven/io/zenwave360/jsonrefparser/${CENTRAL_ARTIFACT}/${VERSION}/${CENTRAL_ARTIFACT}-${VERSION}.pom" ]]; then + echo "::error::Root pom for ${VERSION} not staged; build metadata does not match the requested version." + exit 1 + fi + + NPM_TARBALL="$(basename "$(find npm -type f -name '*.tgz')")" + if [[ -z "${NPM_TARBALL}" ]]; then + echo "::error::npm tarball not found." + exit 1 + fi + + find maven npm -type f | LC_ALL=C sort | xargs -d '\n' sha256sum > SHA256SUMS + + jq -n \ + --arg repository "${GITHUB_REPOSITORY}" \ + --arg version "${VERSION}" \ + --arg tag "${TAG}" \ + --arg source_sha "${SOURCE_SHA}" \ + --arg release_commit_sha "${RELEASE_COMMIT_SHA}" \ + --arg workflow_run_id "${GITHUB_RUN_ID}" \ + --arg run_attempt "${GITHUB_RUN_ATTEMPT}" \ + --arg npm_package "${NPM_PACKAGE}" \ + --arg npm_tarball "${NPM_TARBALL}" \ + --arg sha256sums_sha256 "$(sha256sum SHA256SUMS | awk '{print $1}')" \ + '{repository: $repository, version: $version, tag: $tag, + source_sha: $source_sha, release_commit_sha: $release_commit_sha, + workflow_run_id: $workflow_run_id, run_attempt: $run_attempt, + npm_package: $npm_package, npm_tarball: $npm_tarball, + sha256sums_sha256: $sha256sums_sha256}' > release-manifest.json + + echo "Staged $(wc -l < SHA256SUMS) files." + + - name: Upload staged artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-staging + path: ${{ runner.temp }}/release-staging + if-no-files-found: error + retention-days: 14 + + # --------------------------------------------------------------------------- + # sign-and-upload-central: THE privileged Maven Central job. + # * Gated by the protected 'maven-central-upload' environment (required + # reviewer + deployments restricted to main). Secrets exist only there. + # * Performs NO checkout and runs NO Gradle/npm: only gpg, coreutils, zip + # and curl operate on the verified immutable artifacts. + # * Uploads with publishingType=USER_MANAGED (autoPublish=false): the run + # stops after upload; publication happens manually in the Portal UI. + # Residual risk (documented): the Central token itself is technically able + # to publish; the Portal button is a procedural, not cryptographic, control. + # --------------------------------------------------------------------------- + sign-and-upload-central: + name: Sign and upload Maven Central deployment (autoPublish=false) + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [validate, prepare-release, build-and-test] + environment: maven-central-upload + permissions: + contents: read # read-only token for tag verification via the API + steps: + - name: Download staged artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-staging + path: ${{ runner.temp }}/release-staging + + - name: Verify release tag against release commit and main + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.validate.outputs.tag }} + RELEASE_COMMIT_SHA: ${{ needs.prepare-release.outputs.release_commit_sha }} + run: | + set -euo pipefail + + OBJ_SHA="$(gh api "repos/${GH_REPO}/git/ref/tags/${TAG}" --jq '.object.sha')" + OBJ_TYPE="$(gh api "repos/${GH_REPO}/git/ref/tags/${TAG}" --jq '.object.type')" + if [[ "${OBJ_TYPE}" == "tag" ]]; then + COMMIT_SHA="$(gh api "repos/${GH_REPO}/git/tags/${OBJ_SHA}" --jq '.object.sha')" + else + COMMIT_SHA="${OBJ_SHA}" + fi + if [[ "${COMMIT_SHA}" != "${RELEASE_COMMIT_SHA}" ]]; then + echo "::error::Tag ${TAG} points at ${COMMIT_SHA}, expected release commit ${RELEASE_COMMIT_SHA}." + exit 1 + fi + + STATUS="$(gh api "repos/${GH_REPO}/compare/main...${COMMIT_SHA}" --jq '.status')" + if [[ "${STATUS}" != "identical" && "${STATUS}" != "behind" ]]; then + echo "::error::Release commit ${COMMIT_SHA} is not reachable from main (compare status: ${STATUS})." + exit 1 + fi + + - name: Verify artifact integrity, manifest and structure + env: + VERSION: ${{ needs.validate.outputs.version }} + TAG: ${{ needs.validate.outputs.tag }} + SOURCE_SHA: ${{ needs.validate.outputs.release_sha }} + RELEASE_COMMIT_SHA: ${{ needs.prepare-release.outputs.release_commit_sha }} + run: | + set -euo pipefail + cd "${RUNNER_TEMP}/release-staging" + + # Expected top-level entries only. + for entry in * .[!.]* ..?*; do + [[ -e "${entry}" ]] || continue + case "${entry}" in + maven|npm|SHA256SUMS|release-manifest.json) ;; + *) echo "::error::Unexpected entry in artifact: ${entry}"; exit 1 ;; + esac + done + + # No symlinks, no unexpected characters, no path traversal. + if [[ -n "$(find . -type l)" ]]; then + echo "::error::Symlinks found in downloaded artifact."; exit 1 + fi + BAD_NAMES="$(find . -type f | grep -Ev '^\./((maven/io/[A-Za-z0-9][A-Za-z0-9._/-]*)|(npm/[A-Za-z0-9][A-Za-z0-9._-]*\.tgz)|SHA256SUMS|release-manifest\.json)$' || true)" + if [[ -n "${BAD_NAMES}" ]] || find . | grep -qF '..'; then + echo "::error::Files with unexpected names or paths:"; echo "${BAD_NAMES}"; exit 1 + fi + + # Manifest must bind these artifacts to THIS release and THIS run. + check() { # check + ACTUAL="$(jq -er ".$1" release-manifest.json)" + if [[ "${ACTUAL}" != "$2" ]]; then + echo "::error::Manifest $1='${ACTUAL}' does not match expected '$2'."; exit 1 + fi + } + check repository "${GITHUB_REPOSITORY}" + check version "${VERSION}" + check tag "${TAG}" + check source_sha "${SOURCE_SHA}" + check release_commit_sha "${RELEASE_COMMIT_SHA}" + check workflow_run_id "${GITHUB_RUN_ID}" + check sha256sums_sha256 "$(sha256sum SHA256SUMS | awk '{print $1}')" + + # Every file checksums correctly, and every file is accounted for. + sha256sum --check --quiet --strict SHA256SUMS + diff <(find maven npm -type f | LC_ALL=C sort) <(awk '{print $NF}' SHA256SUMS | LC_ALL=C sort) + + echo "Verified $(wc -l < SHA256SUMS) files against SHA256SUMS and release-manifest.json." + + - name: Import signing key + env: + SIGN_KEY: ${{ secrets.SIGN_KEY }} + run: | + set -euo pipefail + umask 077 + GNUPGHOME="$(mktemp -d)" + export GNUPGHOME + echo "GNUPGHOME=${GNUPGHOME}" >> "${GITHUB_ENV}" + printf '%s' "${SIGN_KEY}" | gpg --batch --quiet --import + + - name: Sign artifacts and complete Central checksums + env: + SIGN_KEY_PASS: ${{ secrets.SIGN_KEY_PASS }} + run: | + set -euo pipefail + cd "${RUNNER_TEMP}/release-staging" + + # Every deployable file needs .asc/.md5/.sha1 for Central validation. + # Fail loudly if the staging repo ever contains a surprise file type; + # never execute downloaded content. + find maven/io -type f \ + ! -name '*.asc' ! -name '*.md5' ! -name '*.sha1' \ + ! -name '*.sha256' ! -name '*.sha512' | while read -r f; do + if [[ ! "${f}" =~ \.(pom|jar|module|klib|json)$ ]]; then + echo "::error::Refusing to sign unexpected file type: ${f}" + exit 1 + fi + printf '%s' "${SIGN_KEY_PASS}" | gpg --batch --yes --quiet \ + --pinentry-mode loopback --passphrase-fd 0 \ + --armor --detach-sign --output "${f}.asc" "${f}" + [[ -f "${f}.md5" ]] || md5sum "${f}" | awk '{print $1}' > "${f}.md5" + [[ -f "${f}.sha1" ]] || sha1sum "${f}" | awk '{print $1}' > "${f}.sha1" + done + echo "Signed $(find maven/io -name '*.asc' | wc -l) files." + + - name: Upload deployment bundle to Maven Central Portal (USER_MANAGED) + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + umask 077 + + (cd "${RUNNER_TEMP}/release-staging/maven" && zip -qr "${RUNNER_TEMP}/central-bundle.zip" io) + + # Credentials go through a curl config file, never argv. + CURL_CFG="$(mktemp)" + trap 'rm -f "${CURL_CFG}"' EXIT + printf 'header = "Authorization: Bearer %s"\n' \ + "$(printf '%s:%s' "${CENTRAL_USERNAME}" "${CENTRAL_TOKEN}" | base64 -w0)" > "${CURL_CFG}" + + # Idempotency guard: refuse to upload an already-published version. + PUBLISHED="$(curl -sS --fail-with-body -K "${CURL_CFG}" \ + "https://central.sonatype.com/api/v1/publisher/published?namespace=${CENTRAL_NAMESPACE}&name=${CENTRAL_ARTIFACT}&version=${VERSION}" \ + | jq -er '.published')" + if [[ "${PUBLISHED}" != "false" ]]; then + echo "::error::Version ${VERSION} is already published on Maven Central." + exit 1 + fi + + # USER_MANAGED == autoPublish=false: the deployment is only validated + # and staged. This workflow NEVER calls the publish operation; you + # review and click Publish in https://central.sonatype.com/publishing + DEPLOYMENT_ID="$(curl -sS --fail-with-body -K "${CURL_CFG}" -X POST \ + -F "bundle=@${RUNNER_TEMP}/central-bundle.zip" \ + "https://central.sonatype.com/api/v1/publisher/upload?publishingType=USER_MANAGED&name=${CENTRAL_ARTIFACT}-v${VERSION}")" + + { + echo "### Maven Central deployment uploaded (NOT published)" + echo "- Deployment ID: \`${DEPLOYMENT_ID}\`" + echo "- Review and click **Publish** at https://central.sonatype.com/publishing/deployments" + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Clean up signing key material + if: always() + run: | + set -euo pipefail + if [[ -n "${GNUPGHOME:-}" && -d "${GNUPGHOME}" ]]; then + find "${GNUPGHOME}" -type f -exec shred -u {} + 2>/dev/null || true + rm -rf "${GNUPGHOME}" + fi + + # --------------------------------------------------------------------------- + # create-github-release: runs AFTER the Central upload succeeded, so a GitHub + # release is never announced for artifacts that failed to reach the Portal. + # Deliberately does NOT wait for npm publication (which may be disabled or + # approved later). Uses the committed notes from the immutable release commit. + # --------------------------------------------------------------------------- + create-github-release: + name: Create GitHub release + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [validate, prepare-release, sign-and-upload-central] + permissions: + contents: write # create the GitHub release for the existing tag + steps: + - name: Checkout immutable release commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.prepare-release.outputs.release_commit_sha }} + persist-credentials: false + + - name: Create GitHub release from committed notes + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.validate.outputs.tag }} + NOTES_FILE: ${{ needs.validate.outputs.notes_file }} + run: | + set -euo pipefail + gh release create "${TAG}" --verify-tag \ + --title "${TAG}" \ + --notes-file "${NOTES_FILE}" + + # --------------------------------------------------------------------------- + # publish-npm: the privileged npm job. Skipped entirely unless the + # publishNpm input is true. + # * Gated by the protected 'npm-publish' environment: its OWN required + # reviewer, separate from Maven Central approval. No Maven Central + # credentials are available here, and no npm credentials exist at all: + # publication uses OIDC trusted publishing (id-token: write), which npm + # validates against this repository + workflow + environment. + # * No checkout; publishes the verified immutable tarball from the build + # job with --ignore-scripts, so no repository-controlled code executes. + # * Runs after the Central upload to keep the ecosystems in lockstep; you + # can approve it after clicking Publish in the Central Portal. + # --------------------------------------------------------------------------- + publish-npm: + name: Publish npm package (OIDC trusted publishing) + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [validate, prepare-release, build-and-test, sign-and-upload-central] + if: needs.validate.outputs.publish_npm == 'true' + environment: npm-publish + permissions: + contents: read + id-token: write # OIDC token for npm trusted publishing (no stored secret) + steps: + - name: Download staged artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-staging + path: ${{ runner.temp }}/release-staging + + - name: Verify artifact integrity and npm package contents + env: + VERSION: ${{ needs.validate.outputs.version }} + TAG: ${{ needs.validate.outputs.tag }} + SOURCE_SHA: ${{ needs.validate.outputs.release_sha }} + RELEASE_COMMIT_SHA: ${{ needs.prepare-release.outputs.release_commit_sha }} + run: | + set -euo pipefail + cd "${RUNNER_TEMP}/release-staging" + + # Same integrity gate as the Central job: nothing is trusted until + # verified against the manifest and checksums. + if [[ -n "$(find . -type l)" ]]; then + echo "::error::Symlinks found in downloaded artifact."; exit 1 + fi + check() { # check + ACTUAL="$(jq -er ".$1" release-manifest.json)" + if [[ "${ACTUAL}" != "$2" ]]; then + echo "::error::Manifest $1='${ACTUAL}' does not match expected '$2'."; exit 1 + fi + } + check repository "${GITHUB_REPOSITORY}" + check version "${VERSION}" + check tag "${TAG}" + check source_sha "${SOURCE_SHA}" + check release_commit_sha "${RELEASE_COMMIT_SHA}" + check workflow_run_id "${GITHUB_RUN_ID}" + check npm_package "${NPM_PACKAGE}" + check sha256sums_sha256 "$(sha256sum SHA256SUMS | awk '{print $1}')" + sha256sum --check --quiet --strict SHA256SUMS + + TARBALL="$(jq -er '.npm_tarball' release-manifest.json)" + if [[ ! "${TARBALL}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$ || ! -f "npm/${TARBALL}" ]]; then + echo "::error::Invalid or missing npm tarball '${TARBALL}'." + exit 1 + fi + + # Tarball entries must all live under package/ (no traversal), and + # the embedded package.json must match the expected name/version + # with no lifecycle scripts. + if tar -tzf "npm/${TARBALL}" | grep -Ev '^package/'; then + echo "::error::Tarball contains entries outside package/." + exit 1 + fi + PKG_JSON="$(tar -xzOf "npm/${TARBALL}" package/package.json)" + if [[ "$(jq -er '.name' <<< "${PKG_JSON}")" != "${NPM_PACKAGE}" ]]; then + echo "::error::Tarball package name does not match ${NPM_PACKAGE}." + exit 1 + fi + if [[ "$(jq -er '.version' <<< "${PKG_JSON}")" != "${VERSION}" ]]; then + echo "::error::Tarball package version does not match ${VERSION}." + exit 1 + fi + if ! jq -e '(.scripts // {}) | length == 0' <<< "${PKG_JSON}" >/dev/null; then + echo "::error::Tarball package.json declares lifecycle scripts; refusing to publish." + exit 1 + fi + + echo "TARBALL=${TARBALL}" >> "${GITHUB_ENV}" + + # npm >= 11.5.1 is required for OIDC trusted publishing; Node 24 bundles it. + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Publish to npm via OIDC trusted publishing + env: + VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + cd "${RUNNER_TEMP}/release-staging" + + NPM_VERSION="$(npm --version)" + if [[ "$(printf '%s\n' 11.5.1 "${NPM_VERSION}")" != "$(printf '%s\n' 11.5.1 "${NPM_VERSION}" | sort -V)" ]]; then + echo "::error::npm ${NPM_VERSION} < 11.5.1; OIDC trusted publishing unsupported." + exit 1 + fi + + # Duplicate guard immediately before publishing. + META="$(curl -sS "https://registry.npmjs.org/${NPM_PACKAGE_URLENC}")" + if ! jq -e 'has("versions")' <<< "${META}" >/dev/null; then + echo "::error::${NPM_PACKAGE} not found on the registry; the first publish is manual (docs/release-security.md#first-npm-publish)." + exit 1 + fi + if ! jq --arg v "${VERSION}" -e '.versions[$v] | not' <<< "${META}" >/dev/null; then + echo "::error::${NPM_PACKAGE}@${VERSION} is already published." + exit 1 + fi + + # No token anywhere: npm exchanges the GitHub OIDC token for a + # short-lived publish credential and attaches provenance + # automatically. --ignore-scripts is defense in depth on top of the + # scripts check above. + npm publish "npm/${TARBALL}" --ignore-scripts --access public + + { + echo "### npm package published" + echo "- \`${NPM_PACKAGE}@${VERSION}\` via OIDC trusted publishing (with provenance)" + echo "- https://www.npmjs.com/package/${NPM_PACKAGE}/v/${VERSION}" + } >> "${GITHUB_STEP_SUMMARY}" + + # --------------------------------------------------------------------------- + # sync-develop: post-release housekeeping. Merges the release version bumps + # from main back into develop and re-triggers snapshot publication. Receives + # no publication or signing secrets; runs entirely through the GitHub API. + # --------------------------------------------------------------------------- + sync-develop: + name: Sync main back to develop + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [validate, create-github-release] + permissions: + contents: write # merge the sync PR + pull-requests: write # create the sync PR + actions: write # dispatch publish-maven-snapshots.yml on develop + steps: + - name: Create and merge sync PR, then trigger snapshot publication + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.validate.outputs.tag }} + run: | + set -euo pipefail + + AHEAD="$(gh api "repos/${GH_REPO}/compare/develop...main" --jq '.ahead_by')" + if [[ "${AHEAD}" == "0" ]]; then + echo "develop already contains main; nothing to sync." + else + PR_NUMBER="$(gh pr list --base develop --head main --state open --json number --jq '.[0].number // empty')" + if [[ -z "${PR_NUMBER}" ]]; then + PR_URL="$(gh pr create --base develop --head main \ + --title "Sync main back to develop after ${TAG}" \ + --body "Syncs the ${TAG} release commit and next snapshot version back to develop.")" + PR_NUMBER="${PR_URL##*/}" + fi + gh pr merge "${PR_NUMBER}" --merge + fi + + # Pushes made with GITHUB_TOKEN do not trigger workflows, so snapshot + # publication for the new development version is dispatched explicitly. + gh workflow run publish-maven-snapshots.yml --ref develop diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84121fa..2d4b0b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,42 +34,9 @@ ## Release Process -### 1. Create a Release +See [RELEASING.md](RELEASING.md) for the full flow. In short: add a `release-notes/release-notes.v.md` file, then trigger the **Release from Notes** workflow from GitHub Actions with the release version, next development version, and target branch. It prepares the version bump, tags the release, uploads the deployment to Maven Central as USER_MANAGED, and creates the GitHub Release. A human still has to log into [central.sonatype.com](https://central.sonatype.com) and click **Publish** to make it live. -Trigger the **Create Gradle Release** workflow from GitHub Actions: - -1. Go to **Actions** → **Create Gradle Release** → **Run workflow** -2. Enter the release version, for example `0.2.0` -3. Enter the next development version, for example `0.3.0-SNAPSHOT` - -This workflow will: - -- Update `version` in `build.gradle.kts` to the release version -- Create a git tag `v{version}` -- Update `version` in `build.gradle.kts` to the next development version -- Push a release branch -- Create a pull request against `main` -- Enable PR auto-merge -- Push the release tag - -### 2. Publish the Release - -After the release tag is created, publish a GitHub Release: - -1. Go to **Releases** → **Draft a new release** -2. Select the tag created in step 1, for example `v0.2.0` -3. Generate release notes or write your own -4. Publish the release - -This automatically triggers the **Publish Release to Maven Central and NPM** workflow, which: - -- Checks out the released tag -- Builds and tests the project -- Publishes all Maven publications to Maven Central -- Uploads build artifacts -- ~~Publishes the JS package to npm~~, currently disabled in the workflow - -### 3. Snapshot Releases +### Snapshot Releases Snapshots are automatically built and published on pushes to `develop` and `next` through the **Build and Publish Snapshots** workflow. diff --git a/LICENSE b/LICENSE index 05c9934..f838be6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022-2023 ZenWave's Authors and Contributors +Copyright (c) 2026 ZenWave's Authors and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 96a7314..f9b0111 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,22 @@ If you work with JSON Schema, OpenAPI, or AsyncAPI specifications, you know the This library handles all of that for you. It is a full [JSON Reference](https://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03) and [JSON Pointer](https://tools.ietf.org/html/rfc6901) implementation that crawls even complex schemas and returns a simple object tree with source locations and resolved ref tracking. +## Features + +- Parses JSON, YAML, and Avro schemas, or any mix of them +- Dereferences `$ref` pointers into a plain object tree +- Cross-file references: local files, remote URLs, and classpath resources on the JVM +- Object identity: two `$ref` pointers to the same target resolve to the same object instance +- Source locations for every parsed node +- Original ref tracking for resolved objects +- Merges `allOf` arrays into a single object +- Non-mutating, graph-safe JSON Merge Patch utility that powers trait composition in [asyncapi-parser-kmp](https://github.com/ZenWave360/asyncapi-parser-kmp) +- Authentication headers and query parameters for remote loading +- Circular reference detection with resolve, skip, and fail modes +- Missing reference handling with skip and fail modes +- JVM and Node.js support through Kotlin Multiplatform +- JVM compatibility layer for existing `json-schema-ref-parser-jvm` users + ## Quick Start ### Kotlin with `RefParser` @@ -416,20 +432,21 @@ const doc = parseSchemaText("{\"type\":\"object\"}", "memory://schema.json"); console.log(doc.locations[""]); ``` -## Features +## JSON Merge Patch -- Parses JSON, YAML, and Avro schemas, or any mix of them -- Dereferences `$ref` pointers into a plain object tree -- Cross-file references: local files, remote URLs, and classpath resources on the JVM -- Object identity: two `$ref` pointers to the same target resolve to the same object instance -- Source locations for every parsed node -- Original ref tracking for resolved objects -- Merges `allOf` arrays into a single object -- Authentication headers and query parameters for remote loading -- Circular reference detection with resolve, skip, and fail modes -- Missing reference handling with skip and fail modes -- JVM and Node.js support through Kotlin Multiplatform -- JVM compatibility layer for existing `json-schema-ref-parser-jvm` users +The generic core module contains a non-mutating, graph-safe JSON Merge Patch utility: + +```kotlin +import io.zenwave360.jsonrefparser.merge.JsonMergePatch +import io.zenwave360.jsonrefparser.merge.jsonMergePatch + +val effective = JsonMergePatch.apply(target, patch) +val equivalent = jsonMergePatch(target, patch) +``` + +It implements RFC 7396 semantics: object members merge recursively, `null` removes an object member, and arrays/scalars replace atomically. The result does not alias mutable maps or lists from either input. Circular and shared parser graphs terminate safely. + +This is the same utility that powers trait composition in [asyncapi-parser-kmp](https://github.com/ZenWave360/asyncapi-parser-kmp). ## Release diff --git a/RELEASING.md b/RELEASING.md index 8b5a2bb..1300999 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,49 +1,56 @@ # Releasing -This repository publishes Kotlin Multiplatform artifacts to Maven Central through the Sonatype Central Portal. +This repository publishes Kotlin Multiplatform artifacts to Maven Central and +(optionally) the generated JS package to npm, through the `Release from Notes` +workflow. The complete security model — trust boundaries, required GitHub and +npm configuration, credential handling, residual risks — is documented in +[docs/release-security.md](docs/release-security.md). Read that before +changing anything under `.github/workflows/`. ## Artifacts -The release workflow publishes these coordinates: - -- `io.zenwave360.jsonrefparser:json-schema-ref-parser-kmp` -- `io.zenwave360.jsonrefparser:json-schema-ref-parser-kmp-jvm` -- `io.zenwave360.jsonrefparser:json-schema-ref-parser-kmp-js` - -## Repository Prerequisites - -Before the first public release, make sure all of the following are in place: - -1. The GitHub repository is public. -2. The `io.zenwave360.jsonrefparser` namespace is registered and verified in the Sonatype Central Portal. -3. A GPG keypair exists for artifact signing and the public key has been published. -4. GitHub Actions secrets are configured. - -Supported secret names used by the current GitHub workflows: - -- `CENTRAL_USERNAME` -- `CENTRAL_TOKEN` -- `SIGN_KEY` -- `SIGN_KEY_PASS` - -## Release Flow - -There are two workflows involved: - -1. `Create Gradle Release` - Updates `build.gradle.kts` to the release version, creates tag `v`, bumps to the next snapshot version, and opens a release PR. -2. `Publish Release to Maven Central` - Runs automatically when a `v*` tag is pushed and executes `./gradlew publishAndReleaseToMavenCentral`. - -## Manual Release Steps - -1. Run the `Create Gradle Release` workflow with: - - `releaseVersion`, for example `0.9.20` - - `developmentVersion`, for example `1.0.0-SNAPSHOT` -2. Verify that tag `v` was pushed. -3. Watch the `Publish Release to Maven Central` workflow. -4. After Central Portal validation and release complete, wait for Maven Central indexing. - -## Snapshot Publishing - -The `Build and Publish Snapshots` workflow uses `./gradlew publishToMavenCentral`. Keep the project version on a `-SNAPSHOT` suffix when using that workflow. +- Maven Central: `io.zenwave360.jsonrefparser:json-schema-ref-parser-kmp`, + `-jvm`, `-js` +- npm (optional): `@zenwave360/json-schema-ref-parser-kmp` + +## Release steps + +1. Write `release-notes/release-notes.v.md` (plain feature list, not + a commit log; it must mention the version) and commit it — along with any + intended release changes — to `main`. +2. Run the **Release from Notes** workflow from the `main` branch with: + - `version`, e.g. `1.0.0` (or `1.0.0-rc.1`); no `v` prefix + - `developmentVersion`, optional; defaults to the next patch `-SNAPSHOT` + - `publishNpm`, default `false` until the npm account is set up +3. The workflow validates everything, creates the release commit and tag + `v` on `main`, builds and tests (JVM, JS, Node integration tests) + without any credentials, then waits. +4. Approve the **maven-central-upload** environment. The privileged job signs + the verified artifacts and uploads the deployment as **USER_MANAGED** + (`autoPublish=false`), then creates the GitHub release and syncs `main` + back to `develop`. +5. Go to [central.sonatype.com](https://central.sonatype.com/publishing/deployments), + review the deployment, and click **Publish**. Nothing is public until you do. +6. If `publishNpm` was checked: approve the **npm-publish** environment + (typically after the Portal Publish click). The verified tarball is + published via OIDC trusted publishing with provenance — no npm token exists + anywhere. + +The first-ever npm publish is manual (OIDC trusted publishing requires an +existing package): see +[docs/release-security.md#first-npm-publish](docs/release-security.md#first-npm-publish). + +## Snapshot publishing + +The `Build and Publish Snapshots` workflow runs `./gradlew publishToMavenCentral` +on every push to `develop`/`next`. Keep the version on a `-SNAPSHOT` suffix +between releases — the workflow refuses to publish otherwise. + +## Required configuration (one-time) + +GitHub Environments `maven-central-upload` (reviewer required, `main` only, +holds `CENTRAL_USERNAME`/`CENTRAL_TOKEN`/`SIGN_KEY`/`SIGN_KEY_PASS`), +`maven-central-snapshots` (`develop`/`next`, same secrets), and `npm-publish` +(reviewer required, `main` only, no secrets); `main` and `v*` rulesets; and +read-only default workflow permissions. Details and rationale: +[docs/release-security.md](docs/release-security.md#required-github-configuration). diff --git a/build.gradle.kts b/build.gradle.kts index d00d3aa..9f6d1ef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,9 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - kotlin("multiplatform") version "2.0.21" - id("com.vanniktech.maven.publish") version "0.34.0" - id("org.jetbrains.kotlinx.kover") version "0.9.4" + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.vanniktech.maven.publish) + alias(libs.plugins.kotlinx.kover) } group = "io.zenwave360.jsonrefparser" @@ -33,25 +33,25 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("it.krzeminski:snakeyaml-engine-kmp:3.0.2") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + implementation(libs.snakeyaml.engine.kmp) + implementation(libs.kotlinx.coroutines.core) } } val commonTest by getting { dependencies { implementation(kotlin("test")) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") + implementation(libs.kotlinx.coroutines.test) } } val jvmMain by getting { dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + implementation(libs.kotlinx.coroutines.core) } } val jvmTest by getting val jsMain by getting { dependencies { - implementation("org.jetbrains.kotlin-wrappers:kotlin-node:18.16.12-pre.610") + implementation(libs.kotlin.node) } } val jsTest by getting @@ -105,6 +105,19 @@ val hasSigningCredentials = sequenceOf( "signing.secretKeyRingFile" ).any { !providers.gradleProperty(it).orNull.isNullOrBlank() } +// Local staging repository used by the release workflow: the build job publishes +// here with NO credentials (task: publishAllPublicationsToLocalStagingRepository), +// and a separate privileged job signs and uploads the result to the Central +// Portal without executing any Gradle code. See docs/release-security.md. +publishing { + repositories { + maven { + name = "localStaging" + url = uri(layout.buildDirectory.dir("staging-deploy")) + } + } +} + mavenPublishing { publishToMavenCentral() if (hasSigningCredentials) { diff --git a/docs/release-security.md b/docs/release-security.md new file mode 100644 index 0000000..816f5c2 --- /dev/null +++ b/docs/release-security.md @@ -0,0 +1,290 @@ +# Release Security Model + +This document describes how json-schema-ref-parser-kmp releases are produced, +which security boundaries protect the publication credentials (Maven Central +**and** npm), the required GitHub/npm configuration, and the residual risks. +It follows the same model as `ZenWave360/zenwave-sdk` (see that repository's +`docs/release-security.md`), adapted to Gradle/Kotlin Multiplatform and +extended with npm publication. + +## The solo-developer release flow + +```text +1. Write release-notes/release-notes.v.md and commit it (with any + intended release changes) to the protected 'main' branch. +2. Actions → "Release from Notes" → Run workflow (branch: main) + → version: e.g. 1.0.0 (required) + → developmentVersion: optional (defaults to next patch SNAPSHOT) + → publishNpm: default false (flip when npm is set up, see below) +3. The workflow automatically: validates → creates the release commit and tag + → builds and tests (JVM + JS + Node integration tests) → waits for approval + of the 'maven-central-upload' environment → signs and uploads the Central + deployment (autoPublish=false) → creates the GitHub release → syncs main + back to develop. +4. Review the deployment at https://central.sonatype.com/publishing/deployments + and click Publish. Nothing is public on Maven Central until you do. +5. (When npm is enabled) approve the 'npm-publish' environment — its own, + separate approval — and the verified tarball is published to npm via OIDC + trusted publishing with provenance. You can approve this after clicking + Publish in the Central Portal to keep the ecosystems in lockstep. +``` + +Human decision points: workflow dispatch → Central environment approval → +Portal Publish click → (optional) npm environment approval. Everything else is +automated. + +### Accepted version formats + +| Input | Accepted | Rejected | +| -------------------- | ------------------------------ | ------------------------------------------------------------------- | +| `version` | `1.0.0`, `1.0.0-rc.1` | `v1.0.0`, `1.0`, `latest`, `main`, `../x`, anything with spaces/`;` | +| `developmentVersion` | empty (auto), `1.0.1-SNAPSHOT` | anything not `X.Y.Z-SNAPSHOT` | +| `publishNpm` | boolean checkbox | — | + +Inputs are passed to scripts via environment variables and validated with a +strict regex before any other use. No branch, ref, SHA, repository or path +inputs exist. + +### Release notes convention + +Notes must exist at `release-notes/release-notes.v.md` **before** +dispatching. The path is derived from the validated version. Validation fails +if the file is missing, empty, a symlink, or never mentions the version +string. The exact same file becomes the GitHub release body. + +## Jobs, code execution and credentials + +| Job | Executes repository-controlled code? | Credentials | +| --- | --- | --- | +| `validate` | No build logic (git/grep/curl/jq only) | `GITHUB_TOKEN` read-only | +| `prepare-release` | **No** — version bump is a `sed` edit of `build.gradle.kts`, no Gradle runs | `GITHUB_TOKEN` `contents:write`, injected only into the final push step via `GIT_ASKPASS`; checkout uses `persist-credentials: false` | +| `build-and-test` | Yes — full `./gradlew build` + local staging + `npm pack` (Gradle plugins, Kotlin/JS tooling and the Node integration tests are arbitrary code execution) | `GITHUB_TOKEN` read-only; **no** Central/npm/signing secrets | +| `sign-and-upload-central` | **No** — no checkout; fixed tooling (`gpg`, `sha256sum`, `zip`, `curl`) over verified immutable artifacts | `CENTRAL_USERNAME`, `CENTRAL_TOKEN`, `SIGN_KEY`, `SIGN_KEY_PASS` from the `maven-central-upload` environment; `GITHUB_TOKEN` read-only | +| `create-github-release` | No build logic (`gh release create`) | `GITHUB_TOKEN` `contents:write` | +| `publish-npm` | **No** — no checkout; publishes the verified tarball with `--ignore-scripts` | **No stored credential at all**: OIDC (`id-token: write`) via the `npm-publish` environment | +| `sync-develop` | No checkout; GitHub API only | `GITHUB_TOKEN` `contents:write`, `pull-requests:write`, `actions:write` | + +Maven Central credentials and npm publication capability are **never present +in the same job**, and neither ever coexists with a write-capable GitHub +token. The two privileged jobs check out no repository code: the only code +running while credentials are available is the audited shell inline in the +workflow file (itself protected on `main`). + +## Trusted commit capture and the "main moved" policy + +Identical to zenwave-sdk: `github.sha` is resolved once at dispatch; the run +refuses non-`main` dispatch, verifies reachability from `origin/main`, and +threads the SHA (and derived release commit) through every job. **Policy: fail +if `main` moved** — `prepare-release` re-checks `origin/main` before its +atomic, non-fast-forward push; if you pushed mid-release, the run fails before +any tag or commit exists. + +## Release commit and tag + +The version bump is a deterministic `sed` replacement of the single top-level +`version = "..."` line in `build.gradle.kts` — deliberately **not** a Gradle +invocation, so the only job with a write token before publication executes no +repository-controlled code at all. The job fails if the file does not contain +exactly one version line, if the result does not match the expected line, or +if anything other than `build.gradle.kts` changed. Both commits (release + +next SNAPSHOT) and the annotated tag `vX.Y.Z` are pushed atomically. The +privileged jobs later re-verify tag → release-commit → reachable-from-main via +the GitHub API. + +## Artifact trust boundary + +`build-and-test` runs the wrapper-validated Gradle build (`build` = all tests +including the Node.js integration tests) and stages the exact Maven +publications with `publishAllPublicationsToLocalStagingRepository` into a +local `file://` repository — no credentials, no signing (the build only signs +when signing credentials are configured, and none are present). It also packs +the Kotlin/JS-generated npm package (`npm pack --ignore-scripts`) after +verifying its `name` and `version`. One artifact is uploaded: + +```text +release-staging/ + maven/io/zenwave360/jsonrefparser/... # unsigned Maven publications + npm/zenwave360-json-schema-ref-parser-kmp-.tgz + SHA256SUMS + release-manifest.json # binds everything to repo, version, + # tag, both SHAs, run id, tarball name +``` + +Both privileged jobs download it **by fixed name from the same run** and, +before touching anything, reject unexpected entries/symlinks/traversal, +verify every manifest field against the current run, and verify every +checksum. The Central job signs only allowlisted extensions +(`.pom`, `.jar`, `.module`, `.klib`, `.json` — the KMP publication set) and +fails loudly on any surprise file type. The npm job additionally verifies the +tarball contains only `package/` entries, that the embedded `package.json` +matches the expected name/version, and that it declares **no lifecycle +scripts**. Downloaded content is never executed. + +## Maven Central boundary + +Same as zenwave-sdk: the bundle is uploaded via the Central Portal Publisher +API with `publishingType=USER_MANAGED` (the API equivalent of +`autoPublish=false`; the vanniktech Gradle plugin is not used for the release +upload). The workflow never calls the publish operation — you click +**Publish** in the Portal. + +> The GitHub Environment approval protects access to the credentials. The +> Maven Central Portal Publish button controls the normal release process. +> Because the Central token can technically publish, the Portal button is not +> an absolute security boundary against malicious code already executing with +> that token. + +## npm publication + +### Design + +- Separate job (`publish-npm`), separate protected environment + (`npm-publish`) with its **own required reviewer** — approving the Maven + Central upload does not approve npm. +- Gated by the `publishNpm` dispatch input, **default `false`** while the npm + account does not exist. Enabling npm for a release is an explicit checkbox; + once npm is fully set up, flip the input's `default:` to `true` in a + reviewed one-line change. +- **OIDC trusted publishing only — no npm token is ever stored.** The job has + `id-token: write`; npm validates GitHub's OIDC claims (repository, workflow + file, environment) and issues a short-lived credential for that single + publish. Provenance attestations are generated automatically. This is the + strongest granularity npm offers: there is no long-lived secret to leak, + scope, or rotate. +- The job publishes the **verified immutable tarball** from the build job with + `npm publish --ignore-scripts --access public`. No checkout, no + `npm install`, no lifecycle scripts. +- Ordered after the Central upload so both ecosystems release the same + immutable release commit; approve it whenever you are ready (e.g. after + clicking Publish in the Portal). Rejecting or ignoring the approval leaves + Maven Central and the GitHub release unaffected. + +### One-time npm setup (when the account exists) {#first-npm-publish} + +OIDC trusted publishing can only be configured on an **existing** package, so +the very first publish is manual, from your machine: + +1. Create the npm account and the `zenwave360` organization (the package is + scoped: `@zenwave360/json-schema-ref-parser-kmp`). Enable 2FA. +2. Build the tarball from the release tag — never from a working tree: + `git checkout vX.Y.Z && ./gradlew build && npm pack --ignore-scripts ./build/js/packages/json-schema-ref-parser-kmp` +3. `npm publish --access public` (npm will prompt for 2FA). +4. On npmjs.com → package → Settings → **Trusted Publisher**: GitHub Actions, + organization `ZenWave360`, repository `json-schema-ref-parser-kmp`, + workflow `release-from-notes.yml`, environment `npm-publish`. +5. Package Settings → Publishing access: **Require two-factor authentication + or an automation or granular access token** (or the trusted-publisher-only + setting when available) so a stolen password alone cannot publish. +6. In GitHub, create the `npm-publish` environment (reviewer: you; deployment + branches: `main` only). No secrets are needed in it. +7. Future releases: tick `publishNpm` (and later flip its default to `true`). + +### npm residual risk + +Anyone able to get a run of this workflow's `publish-npm` job approved can +publish — the boundary is the environment's required reviewer plus npm's +OIDC claim validation (repo + workflow file + environment). There is no token +to steal; compromise requires control of protected `main` **and** an approval. + +## Required GitHub configuration + +### Environments + +| Environment | Reviewer | Deployment branches | Secrets | +| --- | --- | --- | --- | +| `maven-central-upload` | required (you) | `main` only | `CENTRAL_USERNAME`, `CENTRAL_TOKEN`, `SIGN_KEY`, `SIGN_KEY_PASS` | +| `maven-central-snapshots` | none (automatic) | `develop`, `next` | same four (ideally a separate Central token) | +| `npm-publish` | required (you) | `main` only | **none** (OIDC) | + +**After creating the environments, delete the repository-level copies of the +secrets** — repository-level secrets are readable by any workflow on any +branch, which defeats the design. Do not allow administrators to bypass +required reviewers. + +### Repository rules + +Same as zenwave-sdk: + +- **`main` ruleset**: require PR (approvals 0 while solo — you cannot approve + your own PRs; enable code-owner review once a second maintainer exists), + require status checks, block force pushes and deletion, enforce for admins, + bypass **only** for the GitHub Actions app (so `prepare-release` can push + the release commits), Settings → Actions → Workflow permissions set to + **read-only** default. +- **Tag ruleset `v*`**: creation only via the bypass actor; updates and + deletions blocked for everyone (immutable tags). +- **CODEOWNERS** (`.github/CODEOWNERS`) covers `.github/`, Gradle build files + and wrapper, `kotlin-js-store/` (Kotlin/JS yarn lockfile), + `nodejs-test-project/`, `release-notes/` and the release docs. Ineffective + until code-owner review is required by the ruleset. + +## Concurrency, duplicates and reruns + +Identical model to zenwave-sdk: + +- one `release-` concurrency group, never cancelled; +- `validate` fails on existing tag / GitHub release / Maven Central version / + npm version (when `publishNpm=true`); the privileged jobs re-check + `published` state (Central Portal API, npm registry) immediately before + uploading/publishing; +- reruns replay the captured SHA, job outputs and same-run artifacts — a rerun + can never rebuild a different commit under the same version. "Re-run all + jobs" after `prepare-release` succeeded fails fast in `validate` (tag + exists): intentional duplicate protection. Artifacts are retained 14 days. + +## Snapshot publication + +`publish-maven-snapshots.yml` publishes `-SNAPSHOT` builds on push to +`develop`/`next` via `./gradlew publishToMavenCentral` (the vanniktech plugin +routes SNAPSHOT versions to the Central snapshots repository). Hardening: +pinned actions, Gradle wrapper validation, `permissions: contents: read`, +`persist-credentials: false`, concurrency group, a version guard that refuses +non-SNAPSHOT versions, and secrets scoped to the publish step and sourced from +the `maven-central-snapshots` environment. + +> Residual risk: any code merged into the protected snapshot branch executes +> (via Gradle plugins, dependencies and Kotlin/JS tooling) with the snapshot +> publishing credentials present while `./gradlew publishToMavenCentral` runs. + +## Credential rotation and incident response + +Same procedures as zenwave-sdk (`docs/release-security.md` there): + +- rotate `CENTRAL_TOKEN` at central.sonatype.com/account; rotate the GPG key + pair and publish a revocation certificate if exposure is suspected; +- npm: nothing to rotate (OIDC); if the trusted publisher configuration is + suspected compromised, remove it on npmjs.com and re-add it; npm packages + can be deprecated (`npm deprecate`) and, within 72h, unpublished; +- check the Central Portal for deployments you did not create and drop them; + audit Actions run history and environment approval logs; +- the workflows never write secrets to the workspace, never upload key + material, shred the ephemeral `GNUPGHOME` even on failure, and pass + credentials via files/stdin rather than argv. + +## Residual risks (explicit) + +1. **The Central token can publish** — `USER_MANAGED` is procedural; see the + Maven Central boundary section. +2. **Snapshot credentials trust `develop`/`next`** — see snapshot section. +3. **npm publication trusts the environment approval + OIDC claims** — no + token exists, but an approved run of this workflow from `main` can publish. +4. **GitHub Actions app bypass on `main`** — narrowed by read-only default + workflow permissions and CODEOWNERS on `.github/`; use a dedicated GitHub + App to narrow further. +5. **Trust in `main` itself** — everything on `main` is by definition trusted; + branch protection and reviews are what make that hold. + +## Supplementary controls + +- Gradle wrapper validation (`gradle/actions/wrapper-validation`) runs before + any `./gradlew` invocation — the committed wrapper jar is executable code. +- Dependabot (`.github/dependabot.yml`): github-actions SHA pins, Gradle deps, + and the Node integration-test project. +- `kotlin-js-store/yarn.lock` locks the Kotlin/JS toolchain's npm dependency + tree; keep it committed and reviewed. +- The `nodejs-test-project` has no lockfile: its `npm install` (build job + only, no secrets) resolves freshly on every build. Consider committing a + `package-lock.json` for reproducibility. +- Consider `actionlint`/`zizmor` in CI over `.github/workflows/`, and GitHub + artifact attestations for the staged artifacts (npm provenance is already + automatic via trusted publishing). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4197829 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,18 @@ +[versions] +kotlin = "2.0.21" +kover = "0.9.4" +kotlin-node = "18.16.12-pre.610" +kotlinx-coroutines = "1.8.1" +maven-publish = "0.34.0" +snakeyaml-engine-kmp = "3.0.2" + +[libraries] +kotlin-node = { module = "org.jetbrains.kotlin-wrappers:kotlin-node", version.ref = "kotlin-node" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +snakeyaml-engine-kmp = { module = "it.krzeminski:snakeyaml-engine-kmp", version.ref = "snakeyaml-engine-kmp" } + +[plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinx-kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } +vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } diff --git a/release-notes/release-notes.v0.9.21.md b/release-notes/release-notes.v0.9.21.md new file mode 100644 index 0000000..7bc49d5 --- /dev/null +++ b/release-notes/release-notes.v0.9.21.md @@ -0,0 +1,19 @@ +# json-schema-ref-parser-kmp 0.9.21 + +- Parses JSON, YAML, and Avro schemas, or any mix of them +- Dereferences `$ref` pointers into a plain object tree +- Cross-file references: local files, remote URLs, and classpath resources on the JVM +- Object identity: two `$ref` pointers to the same target resolve to the same object instance +- Merges `allOf` arrays into a single object +- Circular reference handling with resolve, skip, and fail modes +- Missing reference handling with skip and fail modes +- Source locations for every parsed node, even after dereferencing across multiple files +- Original `$ref` tracking for resolved objects +- Authentication headers and query parameters for remote loading +- Configurable loader chains, including classpath loading on the JVM +- Loading from in-memory text, useful in tests or when the document text is already available +- `RefParser` as the primary suspend-first API for Kotlin and Node.js +- `JavaRefParser` as the blocking JVM facade for Java and Kotlin/JVM callers +- `$RefParser`/`$Refs`/`$Ref` JVM compatibility layer for existing `json-schema-ref-parser-jvm` users +- JVM and Node.js support through Kotlin Multiplatform +- RFC 7396 JSON Merge Patch utility (non-mutating, graph-safe) diff --git a/release-notes/release-notes.v0.9.22.md b/release-notes/release-notes.v0.9.22.md new file mode 100644 index 0000000..4642086 --- /dev/null +++ b/release-notes/release-notes.v0.9.22.md @@ -0,0 +1,4 @@ +# json-schema-ref-parser-kmp 0.9.22 + +- Non-mutating, graph-safe JSON Merge Patch utility that powers trait composition in [asyncapi-parser-kmp](https://github.com/ZenWave360/asyncapi-parser-kmp) +- Hardened release pipeline: releases now build from the protected `main` branch only, with credential-isolated jobs, human approval gates for Maven Central (and future npm) publication, and SHA-pinned actions — see [docs/release-security.md](https://github.com/ZenWave360/json-schema-ref-parser-kmp/blob/main/docs/release-security.md) diff --git a/src/commonMain/kotlin/io/zenwave360/jsonrefparser/RefParser.kt b/src/commonMain/kotlin/io/zenwave360/jsonrefparser/RefParser.kt index 3914e09..26c2f2d 100644 --- a/src/commonMain/kotlin/io/zenwave360/jsonrefparser/RefParser.kt +++ b/src/commonMain/kotlin/io/zenwave360/jsonrefparser/RefParser.kt @@ -121,6 +121,7 @@ class RefParser( // ----------------------------------------------------------------------- fun getParsedDocument(): ParsedDocument = ParsedDocument( + root = rawRoot ?: schema, schema = schema, locations = locations, documentLocations = documentLocations, @@ -129,6 +130,8 @@ class RefParser( hasCircularRefs = hasCircularRefs, ) + fun getRoot(): Any? = rawRoot ?: schema + // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- diff --git a/src/commonMain/kotlin/io/zenwave360/jsonrefparser/merge/JsonMergePatch.kt b/src/commonMain/kotlin/io/zenwave360/jsonrefparser/merge/JsonMergePatch.kt new file mode 100644 index 0000000..4cfe29f --- /dev/null +++ b/src/commonMain/kotlin/io/zenwave360/jsonrefparser/merge/JsonMergePatch.kt @@ -0,0 +1,103 @@ +package io.zenwave360.jsonrefparser.merge + +import kotlin.jvm.JvmStatic + +/** + * RFC 7396 JSON Merge Patch for the map/list/scalar graph produced by this parser. + * + * Both inputs are treated as immutable. The returned graph never aliases a mutable + * map or list from either input and circular/shared graphs are copied safely. + */ +object JsonMergePatch { + @JvmStatic + fun apply(target: Any?, patch: Any?): Any? { + val targetCopy = GraphCopier().copy(target) + return MergeContext().merge(targetCopy, patch) + } +} + +fun jsonMergePatch(target: Any?, patch: Any?): Any? = JsonMergePatch.apply(target, patch) + +private class MergeContext { + private val patchCopies = GraphCopier() + private val activeObjectPatches = mutableListOf() + + @Suppress("UNCHECKED_CAST") + fun merge(target: Any?, patch: Any?): Any? { + if (patch !is Map<*, *>) return patchCopies.copy(patch) + + validateObject(patch) + activeObjectPatches.lastOrNull { it.patch === patch }?.let { return it.output } + + val output = if (target is MutableMap<*, *>) { + target as MutableMap + } else { + linkedMapOf() + } + activeObjectPatches += ActivePatch(patch, output) + try { + patch.forEach { (rawKey, patchValue) -> + val key = rawKey as String + if (patchValue == null) { + output.remove(key) + } else { + output[key] = merge(output[key], patchValue) + } + } + } finally { + activeObjectPatches.removeAt(activeObjectPatches.lastIndex) + } + return output + } + + private fun validateObject(value: Map<*, *>) { + val invalidKey = value.keys.firstOrNull { it !is String } + require(invalidKey == null) { + "JSON object keys must be strings; unsupported key: ${invalidKey?.let { it::class.simpleName }}" + } + } + + private data class ActivePatch( + val patch: Map<*, *>, + val output: MutableMap, + ) +} + +private class GraphCopier { + private val copies = mutableListOf() + + fun copy(value: Any?): Any? = when (value) { + null, is String, is Boolean, is Byte, is Short, is Int, is Long, is Float, is Double -> value + is Map<*, *> -> copyMap(value) + is List<*> -> copyList(value) + else -> throw IllegalArgumentException( + "Unsupported JSON runtime type: ${value::class.simpleName ?: value::class.toString()}", + ) + } + + private fun copyMap(source: Map<*, *>): MutableMap { + existing(source)?.let { @Suppress("UNCHECKED_CAST") return it as MutableMap } + val output = linkedMapOf() + copies += CopyEntry(source, output) + source.forEach { (rawKey, child) -> + require(rawKey is String) { + "JSON object keys must be strings; unsupported key: ${rawKey?.let { it::class.simpleName }}" + } + output[rawKey] = copy(child) + } + return output + } + + private fun copyList(source: List<*>): MutableList { + existing(source)?.let { @Suppress("UNCHECKED_CAST") return it as MutableList } + val output = mutableListOf() + copies += CopyEntry(source, output) + source.forEach { output += copy(it) } + return output + } + + private fun existing(source: Any): Any? = + copies.firstOrNull { it.source === source }?.copy + + private data class CopyEntry(val source: Any, val copy: Any) +} diff --git a/src/commonMain/kotlin/io/zenwave360/jsonrefparser/model/ParsedDocument.kt b/src/commonMain/kotlin/io/zenwave360/jsonrefparser/model/ParsedDocument.kt index 234e9eb..fb2e847 100644 --- a/src/commonMain/kotlin/io/zenwave360/jsonrefparser/model/ParsedDocument.kt +++ b/src/commonMain/kotlin/io/zenwave360/jsonrefparser/model/ParsedDocument.kt @@ -3,12 +3,14 @@ package io.zenwave360.jsonrefparser.model /** * The result of a fully parsed (and optionally dereferenced) schema. * + * @param root The actual parsed top-level value, which may be a Map or List. * @param schema The fully resolved map-of-maps representation. * @param locations JSON Pointer string → source location for every node. * @param resolvedRefs Every `$ref` that was resolved during dereferencing. * @param originalAllOfs Every `allOf` array before it was merged. */ data class ParsedDocument( + val root: Any?, val schema: Map, val locations: Map, val documentLocations: Map> = emptyMap(), diff --git a/src/commonTest/kotlin/io/zenwave360/jsonrefparser/ParserBasicTest.kt b/src/commonTest/kotlin/io/zenwave360/jsonrefparser/ParserBasicTest.kt index dde1eb2..6b02693 100644 --- a/src/commonTest/kotlin/io/zenwave360/jsonrefparser/ParserBasicTest.kt +++ b/src/commonTest/kotlin/io/zenwave360/jsonrefparser/ParserBasicTest.kt @@ -49,6 +49,8 @@ class ParserBasicTest { val text = readTestFile("asyncapi/shoping-cart-avro-array/all_cart_entities.avsc") val doc = RefParser.fromText(text).parse().getParsedDocument() + assertTrue(doc.root is List<*>) + assertEquals(3, (doc.root as List<*>).size) assertNotNull(doc.schema) assertTrue(doc.locations.isNotEmpty()) } diff --git a/src/commonTest/kotlin/io/zenwave360/jsonrefparser/merge/JsonMergePatchTest.kt b/src/commonTest/kotlin/io/zenwave360/jsonrefparser/merge/JsonMergePatchTest.kt new file mode 100644 index 0000000..11c6ba7 --- /dev/null +++ b/src/commonTest/kotlin/io/zenwave360/jsonrefparser/merge/JsonMergePatchTest.kt @@ -0,0 +1,79 @@ +package io.zenwave360.jsonrefparser.merge + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame + +class JsonMergePatchTest { + @Test + fun appliesRfc7396AppendixAExamples() { + val cases = listOf( + Triple(mapOf("a" to "b"), mapOf("a" to "c"), mapOf("a" to "c")), + Triple(mapOf("a" to "b"), mapOf("b" to "c"), mapOf("a" to "b", "b" to "c")), + Triple(mapOf("a" to "b"), mapOf("a" to null), emptyMap()), + Triple(mapOf("a" to "b", "b" to "c"), mapOf("a" to null), mapOf("b" to "c")), + Triple(listOf("a", "b"), listOf("c", "d"), listOf("c", "d")), + Triple(mapOf("a" to "b"), listOf("c"), listOf("c")), + Triple(mapOf("a" to listOf("b")), mapOf("a" to "c"), mapOf("a" to "c")), + Triple(mapOf("a" to "c"), mapOf("a" to listOf("b")), mapOf("a" to listOf("b"))), + Triple(mapOf("a" to mapOf("b" to "c")), mapOf("a" to mapOf("b" to "d", "c" to null)), mapOf("a" to mapOf("b" to "d"))), + Triple(mapOf("a" to listOf(mapOf("b" to "c"))), mapOf("a" to listOf(1)), mapOf("a" to listOf(1))), + Triple(listOf("a", "b"), mapOf("a" to "b", "c" to null), mapOf("a" to "b")), + Triple(mapOf("a" to "foo"), null, null), + Triple(mapOf("a" to "foo"), "bar", "bar"), + Triple(mapOf("e" to null), mapOf("a" to 1), mapOf("e" to null, "a" to 1)), + Triple(listOf(1, 2), mapOf("a" to "b", "c" to null), mapOf("a" to "b")), + Triple(mapOf(), mapOf("a" to mapOf("bb" to mapOf("ccc" to null))), mapOf("a" to mapOf("bb" to emptyMap()))), + ) + cases.forEach { (target, patch, expected) -> + assertEquals(expected, JsonMergePatch.apply(target, patch)) + } + } + + @Test + fun doesNotMutateOrAliasInputs() { + val targetChild = linkedMapOf("kept" to true) + val patchArray = mutableListOf(1L, 2L) + val target = linkedMapOf("child" to targetChild) + val patch = linkedMapOf("array" to patchArray) + + @Suppress("UNCHECKED_CAST") + val result = JsonMergePatch.apply(target, patch) as MutableMap + assertNotSame(target, result) + assertNotSame(targetChild, result["child"]) + assertNotSame(patchArray, result["array"]) + + (result["child"] as MutableMap)["added"] = true + (result["array"] as MutableList).add(3L) + assertEquals(mapOf("kept" to true), targetChild) + assertEquals(listOf(1L, 2L), patchArray) + } + + @Test + fun preservesSharedAndCircularGraphsWithoutRecursingForever() { + val target = linkedMapOf() + target["self"] = target + val copied = JsonMergePatch.apply(target, emptyMap()) as Map + assertNotSame(target, copied) + assertSame(copied, copied["self"]) + + val patch = linkedMapOf() + patch["self"] = patch + val patched = JsonMergePatch.apply(null, patch) as Map + assertSame(patched, patched["self"]) + } + + @Test + fun rootNullAndUnsupportedTypesAreExplicit() { + assertNull(jsonMergePatch(mapOf("a" to 1), null)) + assertFailsWith { + JsonMergePatch.apply(null, mapOf("bad" to Any())) + } + assertFailsWith { + JsonMergePatch.apply(null, mapOf(1 to "bad")) + } + } +} diff --git a/src/jsMain/kotlin/NodeJsExports.kt b/src/jsMain/kotlin/NodeJsExports.kt index 9f412bc..c5a987f 100644 --- a/src/jsMain/kotlin/NodeJsExports.kt +++ b/src/jsMain/kotlin/NodeJsExports.kt @@ -20,6 +20,7 @@ fun parseSchemaText(input: String, baseUri: String = "memory://anonymous"): Any? val raw = parseText(input, normalizedBaseUri) return exportParsedDocument( ParsedDocument( + root = raw.root, schema = raw.map, locations = raw.locations, documentLocations = mapOf(raw.uri to raw.locations), @@ -53,6 +54,7 @@ fun dereferenceSchemaText( private fun exportParsedDocument(document: ParsedDocument): Any? { val result = js("{}") + result.root = convertToPlain(document.root) result.schema = convertToPlain(document.schema) result.locations = convertToPlain(document.locations) result.documentLocations = convertToPlain(document.documentLocations) diff --git a/src/jsTest/kotlin/io/zenwave360/jsonrefparser/TestUtils.kt b/src/jsTest/kotlin/io/zenwave360/jsonrefparser/TestUtils.kt index b74cb8e..ae59284 100644 --- a/src/jsTest/kotlin/io/zenwave360/jsonrefparser/TestUtils.kt +++ b/src/jsTest/kotlin/io/zenwave360/jsonrefparser/TestUtils.kt @@ -17,18 +17,7 @@ actual fun testResourceUri(path: String): String { private fun resolveTestResourcePath(path: String): String { val moduleFilePath = NodeUrlModule.fileURLToPath(js("import.meta.url") as String) val moduleDir = NodePathModule.dirname(moduleFilePath) - return NodePathModule.resolve( - moduleDir, - "..", - "..", - "..", - "..", - "..", - "src", - "commonTest", - "resources", - path, - ) + return NodePathModule.resolve(moduleDir, path) } @JsModule("node:path") diff --git a/src/jvmMain/kotlin/io/zenwave360/jsonrefparser/JavaRefParser.kt b/src/jvmMain/kotlin/io/zenwave360/jsonrefparser/JavaRefParser.kt index 1a56063..c1895fa 100644 --- a/src/jvmMain/kotlin/io/zenwave360/jsonrefparser/JavaRefParser.kt +++ b/src/jvmMain/kotlin/io/zenwave360/jsonrefparser/JavaRefParser.kt @@ -57,6 +57,8 @@ class JavaRefParser internal constructor( return this } + fun getRoot(): Any? = refParser.getRoot() + fun getParsedDocument(): ParsedDocument = refParser.getParsedDocument() internal fun toRefParser(): RefParser = refParser diff --git a/src/jvmMain/kotlin/io/zenwave360/jsonrefparser/LegacyCompatibility.kt b/src/jvmMain/kotlin/io/zenwave360/jsonrefparser/LegacyCompatibility.kt index 20e0e79..f01eff5 100644 --- a/src/jvmMain/kotlin/io/zenwave360/jsonrefparser/LegacyCompatibility.kt +++ b/src/jvmMain/kotlin/io/zenwave360/jsonrefparser/LegacyCompatibility.kt @@ -275,6 +275,7 @@ data class JsonLocation( class `$RefParser`( private val uri: String, + private val sourceText: String? = null, ) { private var resourceClassLoader: ClassLoader? = null private var compatibilityOptions = `$RefParserOptions`() @@ -284,9 +285,11 @@ class `$RefParser`( private var coreParser: RefParser? = null private var dereferenced = false - constructor(uri: URI) : this(uri.toString()) + constructor(uri: URI) : this(uri.toString(), null) - constructor(file: File) : this(file.toURI().toString()) + constructor(file: File) : this(file.toURI().toString(), null) + + constructor(json: String, uri: URI) : this(uri.toString(), json) private fun normalizedCompatibilityUri(): String { if (uri.startsWith("classpath:")) { @@ -341,7 +344,13 @@ class `$RefParser`( withAuthentication(authentication) private fun getOrCreateCoreParser(): RefParser = - coreParser ?: JavaRefParser.from(normalizedCompatibilityUri()) + coreParser ?: ( + if (sourceText != null) { + JavaRefParser.fromText(sourceText, normalizedCompatibilityUri()) + } else { + JavaRefParser.from(normalizedCompatibilityUri()) + } + ) .withOptions(compatibilityOptions.toCore()) .withAuthenticationValues(*authentication.toTypedArray()) .withResourceClassLoader(resourceClassLoader) @@ -373,6 +382,7 @@ class `$RefParser`( fun getRefs(): `$Refs` { val parsed = document ?: ParsedDocument( + root = emptyMap(), schema = emptyMap(), locations = emptyMap(), documentLocations = emptyMap(), @@ -386,6 +396,6 @@ class `$RefParser`( private fun syncFrom(parser: RefParser) { val parsed = parser.getParsedDocument() document = parsed - rootValue = parser.rawRoot ?: parsed.schema + rootValue = parsed.root } } diff --git a/src/jvmTest/java/io/zenwave360/jsonrefparser/JavaRefParserJavaTest.java b/src/jvmTest/java/io/zenwave360/jsonrefparser/JavaRefParserJavaTest.java index f8a956f..1fb3943 100644 --- a/src/jvmTest/java/io/zenwave360/jsonrefparser/JavaRefParserJavaTest.java +++ b/src/jvmTest/java/io/zenwave360/jsonrefparser/JavaRefParserJavaTest.java @@ -36,10 +36,21 @@ public void javaFacadeSupportsFromText() { .parse() .getParsedDocument(); + assertEquals("object", ((Map) doc.getRoot()).get("type")); assertEquals("object", doc.getSchema().get("type")); assertTrue(doc.getLocations().containsKey("")); } + @Test + public void javaFacadeExposesTopLevelArrayRoot() { + Object root = JavaRefParser.from(new File("src/commonTest/resources/asyncapi/sdk-javaType/avros/all_cart_entities.avsc")) + .parse() + .getRoot(); + + assertTrue(root instanceof java.util.List); + assertEquals(3, ((java.util.List) root).size()); + } + @Test public void javaFacadeSupportsDefaultLoaderPatching() { ParsedDocument doc = JavaRefParser.from("classpath:openapi/openapi-petstore.yml") diff --git a/src/jvmTest/kotlin/io/zenwave360/jsonrefparser/LegacyRefsCompanionBranchTest.kt b/src/jvmTest/kotlin/io/zenwave360/jsonrefparser/LegacyRefsCompanionBranchTest.kt index 2943e42..2bd0a36 100644 --- a/src/jvmTest/kotlin/io/zenwave360/jsonrefparser/LegacyRefsCompanionBranchTest.kt +++ b/src/jvmTest/kotlin/io/zenwave360/jsonrefparser/LegacyRefsCompanionBranchTest.kt @@ -18,6 +18,7 @@ class LegacyRefsCompanionBranchTest { val bindingValue = linkedMapOf("type" to "object") val doc = ParsedDocument( + root = mapOf("value" to publicValue), schema = mapOf("value" to publicValue), locations = mapOf("" to SourceLocation("memory://schema.yml", 0, 0, 0, 0)), resolvedRefs = listOf( @@ -61,6 +62,7 @@ class LegacyRefsCompanionBranchTest { val refs = `$Refs`.from( ParsedDocument( + root = mapOf("info" to mapOf("title" to "Example")), schema = mapOf("info" to mapOf("title" to "Example")), locations = locations, documentLocations = mapOf("memory://schema.yml" to locations), diff --git a/src/jvmTest/kotlin/io/zenwave360/jsonrefparser/RefParserJvmTest.kt b/src/jvmTest/kotlin/io/zenwave360/jsonrefparser/RefParserJvmTest.kt index cbd3b6f..836f048 100644 --- a/src/jvmTest/kotlin/io/zenwave360/jsonrefparser/RefParserJvmTest.kt +++ b/src/jvmTest/kotlin/io/zenwave360/jsonrefparser/RefParserJvmTest.kt @@ -135,6 +135,30 @@ class RefParserJvmTest { } } + @Test + fun `legacy parser accepts json text with base uri constructor`() { + val json = """ + { + "openapi": "3.0.1", + "info": { + "title": "Inline Doc", + "version": "1.0.0" + } + } + """.trimIndent() + + val doc = `$RefParser`(json, URI.create("memory://inline/openapi.json")) + .parse() + .getRefs() + + val schema = doc.schema() as Map<*, *> + assertEquals("3.0.1", schema["openapi"]) + assertEquals( + "memory://inline/openapi.json", + doc.getJsonLocationRange("$")!!.first.file, + ) + } + @Test fun `java ref parser loads classpath resources from injected jar classloader`() { val tempRoot = Files.createTempDirectory("java-ref-parser-classpath-loader-jar-test")