diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef0632a..81dcbc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -40,6 +42,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -65,6 +69,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -96,6 +102,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14ea331..28ea9fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,48 +8,37 @@ on: permissions: {} jobs: - publish: - # npm provenance publishing only supports GitHub-hosted Actions runners. - # Keep this job on ubuntu-latest unless npm adds self-hosted provenance support. + build: + name: Build and inspect package runs-on: ubuntu-latest permissions: - contents: write # Required to create the GitHub release - id-token: write # Required for OIDC trusted publishing - # Workflow-context values are bound to env here and referenced as - # shell variables ($TAG/$REPO/$COMMIT_SHA) in run: blocks instead of - # `${{ }}` interpolation, so an attacker-controlled tag name cannot be - # injected into a script body. Tag pushes are attacker-controllable: - # anyone able to push a v* tag triggers this workflow. + contents: read + outputs: + version: ${{ steps.version.outputs.version }} env: TAG: ${{ github.ref_name }} - REPO: ${{ github.repository }} COMMIT_SHA: ${{ github.sha }} steps: - name: Validate tag format - # Fail closed before any other step runs. A strict semver gate - # rejects a tag containing shell metacharacters ($(), backticks, - # ;, |) so it never reaches a later run: block. run: | if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then - echo "❌ Error: Tag '$TAG' is not a valid vMAJOR.MINOR.PATCH semver tag" - echo "Releases must be triggered by a strict semver tag, e.g. v1.2.3 or v1.2.3-rc.1" + echo "Error: '$TAG' is not a strict semver release tag" exit 1 fi - echo "✅ Tag format validated: $TAG" - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - - name: Verify tag is on main branch + - name: Verify tag is on main run: | git fetch origin main - if ! git merge-base --is-ancestor "$COMMIT_SHA" origin/main; then - echo "❌ Tag is not on the main branch — aborting release" + git merge-base --is-ancestor "$COMMIT_SHA" origin/main || { + echo "Error: release tags must point to a commit on main" exit 1 - fi - echo "✅ Tag is on main branch" + } - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -57,147 +46,109 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: "24.15.0" + node-version: '24.15.0' cache: 'pnpm' - # No registry-url - using OIDC trusted publishing instead - - - name: Update npm for trusted publishing - run: npm install -g npm@latest - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Extract version from tag + - name: Verify package version id: version run: | - # $GITHUB_REF is a runner-provided env var (safe shell - # expansion, not template interpolation); the tag was strictly - # validated above, so VERSION is a clean semver string. - VERSION="${GITHUB_REF#refs/tags/v}" + VERSION="${TAG#v}" + PACKAGE_VERSION=$(node -p "require('./package.json').version") + test "$VERSION" = "$PACKAGE_VERSION" || { + echo "Error: tag $TAG does not match package.json $PACKAGE_VERSION" + exit 1 + } echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Publishing version: $VERSION" - - name: Verify tag matches package.json version - env: - VERSION: ${{ steps.version.outputs.version }} + - name: Build and test run: | - PKG_VERSION=$(node -p "require('./package.json').version") - if [ "$VERSION" != "$PKG_VERSION" ]; then - echo "❌ Tag v$VERSION does not match package.json version $PKG_VERSION" - exit 1 - fi - echo "✅ Version confirmed: $VERSION" - - - name: Build - run: pnpm build - - - name: Test - run: pnpm test + pnpm build + pnpm test env: TEST_TOKEN: ${{ secrets.TEST_TOKEN }} - - name: Publish to npm - run: npm publish --provenance - # Explicitly use --provenance flag for clarity - # OIDC trusted publishing (id-token: write) enables automatic provenance generation - - - name: Generate release notes - id: release_notes - env: - VERSION: ${{ steps.version.outputs.version }} + - name: Pack and inspect publish contents run: | - PREV_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^${TAG}$" | head -1) - RELEASE_DATE=$(date +%Y-%m-%d) - - if [ -n "$PREV_TAG" ]; then - # Put the hash first and separate it from the subject with a real - # tab. The loop below validates the hash before making a link, so - # malformed log output cannot silently publish an empty URL. - COMMITS=$(git log "${PREV_TAG}..HEAD" --pretty=format:'%h%x09%s' --no-merges) - else - COMMITS=$(git log --pretty=format:'%h%x09%s' --no-merges) - fi - - FEATURES="" - FIXES="" - OTHER="" - - while IFS=$'\t' read -r hash message; do - [ -z "$message" ] && continue - if [[ ! $hash =~ ^[0-9a-f]+$ ]]; then - echo "❌ Could not parse commit hash for release-note entry: $message" >&2 - exit 1 - fi - if [[ $message =~ \(#([0-9]+)\) ]]; then - PR_NUM="${BASH_REMATCH[1]}" - CLEAN_MESSAGE=$(echo "$message" | sed -E 's/ ?\(#[0-9]+\)//') - PR_LINK="[#$PR_NUM](https://github.com/${REPO}/pull/$PR_NUM)" - COMMIT_LINK="[$hash](https://github.com/${REPO}/commit/$hash)" - ITEM="$CLEAN_MESSAGE ($PR_LINK) ($COMMIT_LINK)" - else - COMMIT_LINK="[$hash](https://github.com/${REPO}/commit/$hash)" - ITEM="$message ($COMMIT_LINK)" - fi - - if [[ $message =~ ^feat(\([^\)]+\))?: ]]; then - STRIPPED=$(echo "$ITEM" | sed -E 's/^feat(\([^)]+\))?: //') - FEATURES="${FEATURES}- ${STRIPPED} - " - elif [[ $message =~ ^fix(\([^\)]+\))?: ]]; then - STRIPPED=$(echo "$ITEM" | sed -E 's/^fix(\([^)]+\))?: //') - FIXES="${FIXES}- ${STRIPPED} - " - else - OTHER="${OTHER}- ${ITEM} - " - fi - done <<< "$COMMITS" - - cat > release_notes.md <> release_notes.md <> release_notes.md <> release_notes.md < "$RUNNER_TEMP/pack.json" + PACK_JSON="$RUNNER_TEMP/pack.json" node <<'NODE' + const fs = require('node:fs') + const reports = JSON.parse(fs.readFileSync(process.env.PACK_JSON, 'utf8')) + if (reports.length !== 1) throw new Error(`Expected one tarball, got ${reports.length}`) + const forbidden = reports[0].files + .map(({ path }) => path) + .filter((path) => /(^|\/)(\.env|.*\.(pem|key|p12|pfx))$/i.test(path)) + if (forbidden.length) throw new Error(`Sensitive files in package: ${forbidden.join(', ')}`) + console.log(reports[0].files.map(({ path }) => path).join('\n')) + NODE + + - name: Upload immutable package candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-package + path: ${{ runner.temp }}/package/*.tgz + if-no-files-found: error + retention-days: 7 - cat >> release_notes.md <> "$GITHUB_ENV" + + - name: Publish with OIDC provenance + run: npm publish "$tarball" --provenance --access public + + release: + name: Create GitHub release + needs: [build, publish] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create release notes + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: - body_path: release_notes.md - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + generate_release_notes: true diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..941c60a --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,8 @@ +* @yosriady + +# Publishing identity and dependency resolution are security boundaries. +/.github/workflows/ @yosriady +/package.json @yosriady +/pnpm-lock.yaml @yosriady +/pnpm-workspace.yaml @yosriady +/scripts/ @yosriady diff --git a/package.json b/package.json index a6749b9..f0341f3 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "dist", "README.md" ], + "publishConfig": { + "access": "public", + "provenance": true + }, "scripts": { "build": "tsc", "prepublishOnly": "pnpm build", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7884d19..487935d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ -minimumReleaseAge: 2880 +minimumReleaseAge: 10080 blockExoticSubdeps: true +trustPolicy: no-downgrade overrides: serialize-javascript: '>=7.0.5'