diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index ed21e512..682f743d 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -54,8 +54,10 @@ jobs: # is published under the pushed git tag. If they disagree, install.sh/install.ps1 # and the npm postinstall all build a download URL that 404s. Fail fast so a # forgotten version bump can never publish mis-versioned, un-installable assets. + # Matches on the ref (not event_name) because release-please dispatches this + # workflow at the tag ref — its GITHUB_TOKEN-created tag cannot fire `push`. - name: Check tag matches package.json version - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} + if: ${{ startsWith(github.ref, 'refs/tags/v') }} shell: bash run: | pkg_version="$(node -p "require('./package.json').version")" @@ -78,13 +80,15 @@ jobs: path: dist/release/* if-no-files-found: error - # On a `v*` tag push, publish the packaged archive + .sha256 as GitHub Release - # assets so the documented `scripts/install.sh` / `install.ps1` path (which reads - # /releases/latest) works. Uses the preinstalled `gh` (no new action dependency); - # each matrix OS creates the release if absent (best-effort) then uploads its own - # assets with --clobber. NOTE: maintainers must push the tag — this never tags. + # On a `v*` tag ref (pushed manually, or dispatched at the tag by the + # release-please workflow), publish the packaged archive + .sha256 as GitHub + # Release assets so the documented `scripts/install.sh` / `install.ps1` path + # (which reads /releases/latest) works. Uses the preinstalled `gh` (no new + # action dependency); each matrix OS creates the release if absent + # (best-effort — release-please normally creates it first) then uploads its + # own assets with --clobber. Tags come from merging the release-please PR. - name: Publish to GitHub Release - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} + if: ${{ startsWith(github.ref, 'refs/tags/v') }} shell: bash env: GH_TOKEN: ${{ github.token }} @@ -92,3 +96,83 @@ jobs: tag="${GITHUB_REF_NAME}" gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber + + # Publishes the npm wrapper (@gitlawb/zero) once every platform's release + # assets are live — the package's postinstall downloads its binary from the + # GitHub Release, so publishing before the assets exist would ship a package + # that fails to install. + # + # Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's + # npmjs.com settings must list this repo + workflow file as a trusted + # publisher, and provenance is then generated automatically. Note npm only + # allows configuring a trusted publisher on an EXISTING package, so the very + # first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap. + publish-npm: + name: Publish npm package + needs: package + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # OIDC token exchange for npm trusted publishing + provenance + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + # No registry-url here on purpose: setup-node's registry-url writes an + # .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the + # unset env var. Trusted publishing needs no token at all. + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + + # OIDC trusted publishing needs npm >= 11.5.1. Use the npm bundled with + # the pinned Node toolchain (Node 24 ships >= 11.5.1) rather than pulling + # npm@latest from the registry inside the publish path — an unpinned + # install there would be avoidable supply-chain exposure. Fail fast if a + # future runner/toolchain change ever regresses the bundled npm. + - name: Check npm supports trusted publishing + run: | + npm --version | node -e ' + const version = require("fs").readFileSync(0, "utf8").trim(); + const [major, minor, patch] = version.split(".").map(Number); + const ok = + major > 11 || + (major === 11 && (minor > 5 || (minor === 5 && patch >= 1))); + if (!ok) { + console.error( + `::error::bundled npm ${version} is too old for OIDC trusted ` + + `publishing (needs >= 11.5.1) — bump node-version in this job.`, + ); + process.exit(1); + } + console.log(`npm ${version} supports trusted publishing.`); + ' + + # Probe the exact URLs postinstall will build (internal/release/release.go + # asset-name scheme) as an anonymous client. This also blocks publishing + # while the repo is still private — private release assets 404 without + # auth, exactly as they would for an installing user. + - name: Verify release assets are downloadable + shell: bash + run: | + version="$(node -p "require('./package.json').version")" + base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}" + for asset in \ + "zero-v${version}-linux-x64.tar.gz" \ + "zero-v${version}-linux-arm64.tar.gz" \ + "zero-v${version}-macos-x64.tar.gz" \ + "zero-v${version}-macos-arm64.tar.gz" \ + "zero-v${version}-windows-x64.zip"; do + for url in "${base}/${asset}" "${base}/${asset}.sha256"; do + if ! curl -fsSLI -o /dev/null "$url"; then + echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2 + exit 1 + fi + done + done + echo "all release assets for v${version} are publicly downloadable." + + - name: Publish to npm + run: npm publish --access public diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..f9ada4fb --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,35 @@ +name: Release Please + +on: + push: + branches: + - main + +permissions: + contents: write # push the release-PR branch, create the tag + GitHub Release + pull-requests: write # open/update the release PR + issues: write # create/apply release-tracking labels on the release PR + actions: write # dispatch release-artifacts.yml once a release is cut + +jobs: + release-please: + name: Release PR / tag + runs-on: ubuntu-latest + steps: + - name: Run release-please + id: release + uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0 + + # Tags created with the default GITHUB_TOKEN never fire `on: push: tags` + # workflows (GitHub suppresses workflows triggered by workflow-created + # events), so release-artifacts.yml would not run for a release-please + # tag. workflow_dispatch invoked through the API is exempt from that + # suppression — dispatch the artifacts build at the new tag explicitly. + - name: Build and publish release artifacts + if: ${{ steps.release.outputs.release_created }} + env: + GH_TOKEN: ${{ github.token }} + run: | + gh workflow run release-artifacts.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "${{ steps.release.outputs.tag_name }}" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..914fa934 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "node", + "include-component-in-tag": false, + "bootstrap-sha": "02f0c0929bec65f5fc41bdcab3eb518d2b0b329a", + "packages": { + ".": { + "package-name": "@gitlawb/zero", + "release-as": "0.1.0" + } + } +}