Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/release-proposals/publish.yml
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 3 additions & 3 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
11 changes: 7 additions & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/storybook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -27,6 +27,6 @@ jobs:

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build Storybook
run: bun run build-storybook
run: bun run build-storybook
44 changes: 44 additions & 0 deletions docs/actions-publishing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Actions publishing

The release guard validates stable `v<package.json version>` 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<version>` 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.
76 changes: 76 additions & 0 deletions scripts/check-release-guard.mjs
Original file line number Diff line number Diff line change
@@ -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 })
}
})
54 changes: 54 additions & 0 deletions scripts/release-check.mjs
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading