diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e45d3ad --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +# Publishes to npm when a GitHub Release is published, so shipping a version is +# "tag and release" rather than someone running npm publish from a laptop. +# +# Authentication uses npm trusted publishing (OIDC): GitHub Actions proves its +# identity to the registry directly, so there is no long-lived token stored in +# repository secrets or on anyone's machine. It requires a one-time setup on +# npmjs.com — see "Releasing" in the README. + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + name: Publish to npm + runs-on: ubuntu-latest + permissions: + contents: read + # Required for OIDC. Without it the registry has nothing to verify. + id-token: write + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: '22' + registry-url: https://registry.npmjs.org + cache: npm + + # Trusted publishing needs npm 11.5.1 or newer, which is more recent than + # the npm bundled with some Node versions. The printed version makes it + # obvious in the log if this ever regresses. + - name: Use an npm that supports trusted publishing + run: | + npm install -g npm@latest + npm --version + + - name: Install dependencies + run: npm ci + + # A release tagged v1.2.3 must publish 1.2.3. Catching a mismatch here + # costs seconds; catching it afterwards is not possible, because a + # published npm version can never be replaced. + - name: Check the tag matches package.json + if: github.event_name == 'release' + env: + TAG: ${{ github.event.release.tag_name }} + run: | + version="$(node -p "require('./package.json').version")" + if [ "${TAG#v}" != "$version" ]; then + echo "::error::Release tag '$TAG' does not match package.json version '$version'." + echo "Bump the version and retag, or delete and recreate the release." >&2 + exit 1 + fi + echo "Publishing $version from tag $TAG." + + # The tests run through the prepublishOnly hook, so a failing suite stops + # the publish. Trusted publishing attests provenance automatically, so no + # --provenance flag is needed. + - name: Publish + run: npm publish diff --git a/README.md b/README.md index 7c54197..4bc2ff3 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,39 @@ The tests cover the pure logic — size parsing and formatting, access-URL rewri Every push and pull request runs the suite on Node 18, 20 and 22 via GitHub Actions, along with a syntax check and an audit of production dependencies. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +## Releasing + +Releases publish from CI, so no npm token exists on anyone's machine or in +repository secrets. Authentication uses npm +[trusted publishing](https://docs.npmjs.com/trusted-publishers): GitHub Actions +proves its identity to the registry over OIDC, and npm attests the package's +provenance automatically. + +To ship a version: + +```bash +npm version minor # or patch / major - commits and tags +git push --follow-tags +``` + +Then publish a GitHub Release for that tag. [`release.yml`](.github/workflows/release.yml) +installs, verifies the tag matches `package.json`, runs the tests through the +`prepublishOnly` hook, and publishes. A tag that disagrees with the version +fails before anything reaches the registry, which matters because a published +npm version can never be replaced. + +### One-time setup + +Trusted publishing has to be enabled on the registry side once, by a package +maintainer: + +1. Open the package on npmjs.com, then **Settings → Trusted Publisher**. +2. Choose GitHub Actions and enter the organization (`rahmanow`), the repository + (`shadowtools`), and the workflow filename (`release.yml`). + +Until that is configured, the workflow's publish step fails with an +authentication error — which is the safe direction to fail in. + ## Certificate pinning Outline servers use a self-signed TLS certificate for the Management API, so the usual chain validation cannot apply and the tool disables it (`rejectUnauthorized: false`). On its own that would leave the connection open to a man-in-the-middle: anything that can answer on the server's address would be trusted.