diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a8fa1c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: ci + +# Runs the test suite on every pull request, and on main after a merge. +on: + pull_request: + push: + branches: [main] + +permissions: {} + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (node ${{ matrix.node }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + node: [18, 20, 22] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm test diff --git a/.github/workflows/pr-notify.yml b/.github/workflows/pr-notify.yml new file mode 100644 index 0000000..b92d255 --- /dev/null +++ b/.github/workflows/pr-notify.yml @@ -0,0 +1,32 @@ +name: pr-notify + +# Posts to Slack when a PR lands on main. Opening a PR notifies nobody — +# this channel reports what actually merged. +on: + pull_request: + branches: [main] + types: [closed] + +permissions: {} + +jobs: + notify: + name: Notify Slack + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.merged }} + + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + + steps: + - name: Notify Slack (merged to main) + if: ${{ env.SLACK_WEBHOOK_URL != '' }} + run: | + TEXT=$(jq -n \ + --arg t "$PR_TITLE" --arg u "$PR_URL" --arg n "$PR_NUMBER" --arg a "$PR_AUTHOR" \ + '{text: ("*plain-forge*: <" + $u + "|#" + $n + " " + $t + "> merged to `main` :white_check_mark: — by *" + $a + "*.")}') + curl -s -X POST "$SLACK_WEBHOOK_URL" -H 'Content-type: application/json' --data "$TEXT" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4922b9a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,126 @@ +name: publish + +# Manual release. Bump the version in package.json first, merge it, then run +# this workflow from the Actions tab against main. +# +# Auth is OIDC (npm trusted publishing) — no NPM_TOKEN secret. The trusted +# publisher must be configured once on npmjs.com for this repo and this exact +# workflow filename (publish.yml). +on: + workflow_dispatch: + +# Least privilege by default: jobs get no GITHUB_TOKEN scopes unless they +# opt in below. +permissions: {} + +concurrency: + group: publish + cancel-in-progress: false + +jobs: + test: + name: Test + runs-on: ubuntu-latest + permissions: + contents: read + + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm test + + - name: Notify Slack (tests failed) + if: ${{ failure() && env.SLACK_WEBHOOK_URL != '' }} + run: | + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-type: application/json' \ + --data "{\"text\":\"*plain-forge*: release halted — tests FAILED :x:. Nothing was published to npm. See <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|the run log>.\"}" + + publish: + name: Publish to npm + needs: test + runs-on: ubuntu-latest + permissions: + contents: write # push the version tag, create the release + id-token: write # OIDC token for trusted publishing + + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + steps: + - name: Refuse to publish from anywhere but main + if: github.ref != 'refs/heads/main' + run: | + echo "::error::releases are cut from main, not ${{ github.ref_name }}" + exit 1 + + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 # trusted publishing needs >= 22.14 + cache: npm + + - name: Use an npm that supports trusted publishing + run: | + npm install -g npm@latest + npm --version + + - run: npm ci + + - name: Read version + id: pkg + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Refuse if this version is already on npm + run: | + if npm view "plain-forge@${{ steps.pkg.outputs.version }}" version >/dev/null 2>&1; then + echo "::error::plain-forge@${{ steps.pkg.outputs.version }} is already published — bump the version in package.json first" + exit 1 + fi + + - name: Notify Slack (start) + if: ${{ env.SLACK_WEBHOOK_URL != '' }} + run: | + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-type: application/json' \ + --data "{\"text\":\"*plain-forge*: release of \`v${{ steps.pkg.outputs.version }}\` started by *${{ github.actor }}* — publishing to npm...\"}" + + # No NODE_AUTH_TOKEN: npm exchanges the GitHub OIDC token for a + # short-lived npm token. Provenance is attached automatically. + - name: Publish + run: npm publish --access public + + - name: Tag the release + env: + VERSION: ${{ steps.pkg.outputs.version }} + run: | + git tag "v$VERSION" + git push origin "v$VERSION" + + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.pkg.outputs.version }} + run: gh release create "v$VERSION" --title "v$VERSION" --generate-notes + + - name: Notify Slack (success) + if: ${{ success() && env.SLACK_WEBHOOK_URL != '' }} + run: | + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-type: application/json' \ + --data "{\"text\":\"*plain-forge*: \`v${{ steps.pkg.outputs.version }}\` published to npm :white_check_mark:. Install with \`npx plain-forge@latest update\`.\"}" + + - name: Notify Slack (failure) + if: ${{ failure() && env.SLACK_WEBHOOK_URL != '' }} + run: | + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-type: application/json' \ + --data "{\"text\":\"*plain-forge*: publishing \`v${{ steps.pkg.outputs.version }}\` to npm FAILED :x:. See <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|the run log>.\"}" diff --git a/CLAUDE.md b/CLAUDE.md index 833e56d..501fe0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,8 @@ So a change here is almost always one of: (a) editing the installer CLI, or (b) ## Commands ```bash -npm test # full suite: node --test "test/**/*.test.mjs" -node --test --test-name-pattern="" "test/**/*.test.mjs" # run a single test by name +npm test # full suite: node --test test/*.test.mjs +node --test --test-name-pattern="" test/*.test.mjs # run a single test by name node --test test/cli.test.mjs # run a single test file ``` diff --git a/package.json b/package.json index 5afa7c9..e095b07 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "scripts": { "build": "tsx bin/forge-build.ts", "clean": "tsx bin/forge-build.ts --clean", - "test": "node --test \"test/**/*.test.mjs\"" + "test": "node --test test/*.test.mjs" }, "devDependencies": { "@types/node": "^22.10.0",