diff --git a/.github/release-proposals/publish.yml b/.github/release-proposals/publish.yml new file mode 100644 index 0000000..9e3cde5 --- /dev/null +++ b/.github/release-proposals/publish.yml @@ -0,0 +1,69 @@ +# PROPOSAL ONLY. Move to .github/workflows/publish.yml only after access approval. +name: Publish npm package +on: + release: + types: [published] +permissions: + contents: read +concurrency: + group: npm-release-${{ github.event.release.tag_name }} + cancel-in-progress: false +jobs: + publish: + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + actions: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + ref: ${{ github.event.release.tag_name }} + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 26 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.3 + - name: Validate metadata and registry state + id: metadata + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + node --test scripts/check-release-guard.mjs + node scripts/release-check.mjs "$RELEASE_TAG" --registry + - name: Require successful CI on this exact source + env: + GH_TOKEN: ${{ github.token }} + run: | + source_sha="$(git rev-parse HEAD)" + test "$source_sha" = "$(git rev-parse "$GITHUB_SHA^{commit}")" + git merge-base --is-ancestor "$source_sha" "origin/main" + gh api "repos/$GITHUB_REPOSITORY/actions/workflows/lint.yml/runs?head_sha=$source_sha&per_page=100" > "$RUNNER_TEMP/ci.json" + jq -e --arg sha "$source_sha" '[.workflow_runs[] | select(.head_sha == $sha and .conclusion == "success" and .event == "push" and .head_branch == "main")] | length > 0' "$RUNNER_TEMP/ci.json" + - run: bun install --frozen-lockfile + - name: Check and build + run: bun run check + - name: Pack the checked output + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + mkdir -p "$RUNNER_TEMP/release" + bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/release/package.tgz" + node scripts/release-check.mjs "$RELEASE_TAG" + tar -tzf "$RUNNER_TEMP"/release/*.tgz + tar -xOf "$RUNNER_TEMP"/release/*.tgz package/package.json > "$RUNNER_TEMP/packed-package.json" + node --input-type=module -e 'import { readFileSync } from "node:fs"; const source = JSON.parse(readFileSync("package.json")); const packed = JSON.parse(readFileSync(process.env.RUNNER_TEMP + "/packed-package.json")); if (packed.name !== source.name || packed.version !== source.version || packed.private) throw new Error("Packed package identity differs from checked source")' + - name: Publish checked tarball with npm OIDC + if: steps.metadata.outputs.should_publish == 'true' + working-directory: ${{ runner.temp }} + env: + PRERELEASE: ${{ github.event.release.prerelease }} + run: | + node -e 'const v=process.argv[1].split(".").map(Number); if(v[0]<11 || (v[0]===11 && (v[1]<5 || (v[1]===5 && v[2]<1)))) throw new Error("npm >=11.5.1 required")' "$(npm --version)" + dist_tag=latest + if [ "$PRERELEASE" = true ]; then dist_tag=next; fi + npm publish "$RUNNER_TEMP"/release/*.tgz --ignore-scripts --access public --tag "$dist_tag" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 67a9378..9c70507 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -18,16 +18,16 @@ jobs: uses: dependabot/fetch-metadata@v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - + - name: Auto-approve PR run: gh pr review --approve "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + - name: Auto-merge PR if: ${{ steps.metadata.outputs.update-type != 'version-update:semver-major' }} run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 01b5738..404ed5d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,16 +17,19 @@ jobs: lint-and-build: runs-on: ubuntu-latest timeout-minutes: 15 - + steps: - name: Checkout code uses: actions/checkout@v7.0.1 - + + - name: Test release metadata guard + run: node --test scripts/check-release-guard.mjs + - name: Setup Bun uses: oven-sh/setup-bun@v2.2.0 with: bun-version: 1.4.3 - + - name: Cache Bun packages uses: actions/cache@v6.1.0 with: @@ -35,7 +38,7 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - + # Source checks run independently before the package build. - parallel: - name: Run Biome lint diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index fe7ba32..c1692d7 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -9,16 +9,16 @@ on: jobs: build: runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v7.0.1 - + - name: Setup Bun uses: oven-sh/setup-bun@v2.2.0 with: bun-version: 1.4.3 - + - name: Cache Bun packages uses: actions/cache@v6.1.0 with: @@ -27,6 +27,6 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - + - name: Build Storybook - run: bun run build-storybook \ No newline at end of file + run: bun run build-storybook diff --git a/docs/actions-publishing.md b/docs/actions-publishing.md new file mode 100644 index 0000000..0b48591 --- /dev/null +++ b/docs/actions-publishing.md @@ -0,0 +1,44 @@ +# Actions publishing + +The release guard validates stable `v` tags, refuses private +packages and mismatched npm lock metadata, and stops on registry errors. A 200 +response means the version is immutable; it never forces another upload. + +Run `node --test scripts/check-release-guard.mjs` and +`node scripts/release-check.mjs v` without publishing. +`--registry` adds a read-only check of the exact public npm version. + +## Approval proposal + +The complete workflow is in [publish.yml](../.github/release-proposals/publish.yml). +It is outside `.github/workflows` and cannot run. After explicit approval, move it +to `.github/workflows/publish.yml` (replacing an existing publisher where present). +It uses published GitHub releases, checks the exact tagged source against successful +CI, installs the frozen lockfile, checks/builds, packs and publishes the checked +tarball. It does not create versions, tags or GitHub releases. Stable releases use +`latest`; prereleases are excluded. + +Approval must cover the workflow job's `contents: read`, `actions: read`, and +`id-token: write`, plus this npm trusted-publisher binding: + +- npm package: `baseui-data-table` +- GitHub owner: `kahwee` +- repository: `baseui-tanstack-table` +- workflow filename: `publish.yml` +- environment: blank +- allowed action: direct `npm publish` only; no extra dist-tag management grant + +Use GitHub-hosted runners and npm >=11.5.1; no npm secret is needed. Configure and +read back the binding in the package owner's npm settings after approval. Existing +secret presence and a local login do not prove CI publish access. Do not revoke +existing credentials or change package security settings as part of this proposal. +See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). + +No publishing action or registry trust was enabled by this change. Run the full +existing CI after review. Authentication remains untested until a separately +authorized real release; a package dry run does not validate OIDC exchange. + +The package endpoint returned HTTP 404 during this audit. Confirm package-name +ownership and perform a separately authorized first publication before configuring +trusted publishing; a missing name is not a reservation. No bootstrap upload was +attempted here. diff --git a/scripts/check-release-guard.mjs b/scripts/check-release-guard.mjs new file mode 100644 index 0000000..79959d4 --- /dev/null +++ b/scripts/check-release-guard.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { registryState, validateRelease } from './release-check.mjs' + +const pkg = { name: 'example-package', version: '1.2.3' } +test('matches tag, rejects private, prerelease, mismatched tag and lock', () => { + assert.equal(validateRelease(pkg, 'v1.2.3'), '1.2.3') + for (const [candidate, tag, lock] of [ + [{ ...pkg, private: true }, 'v1.2.3'], + [{ ...pkg, version: '1.2.3-rc.1' }, 'v1.2.3-rc.1'], + [pkg, 'v1.2.4'], + [pkg, 'main'], + [pkg, 'v1.2.3', { version: '1.2.2', packages: { '': { version: '1.2.2' } } }], + ]) + assert.throws(() => validateRelease(candidate, tag, lock)) + assert.equal( + validateRelease(pkg, 'v1.2.3', { version: '1.2.3', packages: { '': { version: '1.2.3' } } }), + '1.2.3', + ) +}) +test('only a registry 404 permits publication; 200 is immutable', async () => { + assert.equal(await registryState(pkg, async () => ({ status: 404 })), 'new') + assert.equal( + await registryState(pkg, async () => ({ status: 200, json: async () => pkg })), + 'published', + ) + for (const status of [401, 403, 429, 500, 503]) { + await assert.rejects(registryState(pkg, async () => ({ status }))) + } + await assert.rejects( + registryState(pkg, async () => { + throw new Error('timeout') + }), + ) + await assert.rejects( + registryState(pkg, async () => ({ + status: 200, + json: async () => ({ ...pkg, version: '9.9.9' }), + })), + ) +}) + +test('CLI validates real repository metadata and fails invalid fixture releases', () => { + const script = fileURLToPath(new URL('./release-check.mjs', import.meta.url)) + const repo = resolve(dirname(script), '..') + const actual = JSON.parse(readFileSync(resolve(repo, 'package.json'), 'utf8')) + assert.equal(spawnSync(process.execPath, [script, `v${actual.version}`], { cwd: repo }).status, 0) + const directory = mkdtempSync(resolve(tmpdir(), 'release-guard-')) + try { + const output = resolve(directory, 'output') + const run = (candidate, tag, lock) => { + writeFileSync(resolve(directory, 'package.json'), JSON.stringify(candidate)) + if (lock) writeFileSync(resolve(directory, 'package-lock.json'), JSON.stringify(lock)) + else rmSync(resolve(directory, 'package-lock.json'), { force: true }) + return spawnSync(process.execPath, [script, tag], { + cwd: directory, + env: { ...process.env, GITHUB_OUTPUT: output }, + }) + } + assert.equal(run(pkg, 'v1.2.3').status, 0) + assert.match(readFileSync(output, 'utf8'), /package_version=1.2.3\nshould_publish=true/) + assert.equal(run(pkg, 'v1.2.4').status, 1) + assert.equal(run({ ...pkg, private: true }, 'v1.2.3').status, 1) + assert.equal( + run(pkg, 'v1.2.3', { version: '1.2.2', packages: { '': { version: '1.2.2' } } }).status, + 1, + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs new file mode 100644 index 0000000..914a643 --- /dev/null +++ b/scripts/release-check.mjs @@ -0,0 +1,54 @@ +import { appendFileSync, readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +export function validateRelease(pkg, tag, lock) { + if (pkg.private) throw new Error('Private packages cannot be published') + if (!pkg.name || !/^\d+\.\d+\.\d+$/.test(pkg.version)) { + throw new Error('A stable package name and version are required') + } + if (tag !== `v${pkg.version}`) { + throw new Error('Release tag must match package.json version') + } + if (lock && (lock.version !== pkg.version || lock.packages?.['']?.version !== pkg.version)) { + throw new Error('Root package-lock version must match package.json') + } + return pkg.version +} + +export async function registryState(pkg, request = fetch) { + const url = `https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/${pkg.version}` + const response = await request(url, { signal: AbortSignal.timeout(15000) }) + if (response.status === 404) return 'new' + if (response.status === 200) { + const published = await response.json() + if (published.name !== pkg.name || published.version !== pkg.version) { + throw new Error('Registry returned inconsistent package metadata') + } + return 'published' + } + throw new Error(`Registry lookup failed (HTTP ${response.status}); publication stopped`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const pkg = JSON.parse(readFileSync('package.json', 'utf8')) + let lock + try { + lock = JSON.parse(readFileSync('package-lock.json', 'utf8')) + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + const version = validateRelease(pkg, process.argv[2], lock) + const state = process.argv.includes('--registry') ? await registryState(pkg) : 'unchecked' + console.log(`Validated ${pkg.name}@${version}; registry: ${state}`) + if (process.env.GITHUB_OUTPUT) { + appendFileSync( + process.env.GITHUB_OUTPUT, + `package_version=${version}\nshould_publish=${state !== 'published'}\n`, + ) + } + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +}