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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
245 changes: 98 additions & 147 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,196 +8,147 @@ 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

- 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 <<EOF
$VERSION ($RELEASE_DATE)
EOF

if [ -n "$FEATURES" ]; then
cat >> release_notes.md <<EOF

## Features

$FEATURES
EOF
fi

if [ -n "$FIXES" ]; then
cat >> release_notes.md <<EOF

## Bug Fixes

$FIXES
EOF
fi

if [ -n "$OTHER" ]; then
cat >> release_notes.md <<EOF

## Changes

$OTHER
EOF
fi
mkdir -p "$RUNNER_TEMP/package"
npm pack --json --pack-destination "$RUNNER_TEMP/package" > "$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 <<EOF
publish:
name: Publish approved tarball
needs: build
runs-on: ubuntu-latest
environment: npm-publish
permissions:
contents: read
id-token: write
env:
EXPECTED_NAME: '@formo/cli'
EXPECTED_VERSION: ${{ needs.build.outputs.version }}
steps:
- name: Download package candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: npm-package
path: package

## Install
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '24.15.0'
package-manager-cache: false

\`\`\`bash
npm install -g @formo/cli@$VERSION
\`\`\`
EOF
- name: Install exact npm publishing client
run: npm install --global npm@12.0.1

- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
- name: Verify tarball identity and lifecycle hooks
run: |
mapfile -t TARBALLS < <(find "$PWD/package" -maxdepth 1 -name '*.tgz' -type f)
test "${#TARBALLS[@]}" -eq 1 || { echo "Expected exactly one tarball"; exit 1; }
mkdir inspect
tar -xzf "${TARBALLS[0]}" -C inspect
PACKAGE_JSON=inspect/package/package.json node <<'NODE'
const fs = require('node:fs')
const pkg = JSON.parse(fs.readFileSync(process.env.PACKAGE_JSON, 'utf8'))
if (pkg.name !== process.env.EXPECTED_NAME) throw new Error(`Unexpected package: ${pkg.name}`)
if (pkg.version !== process.env.EXPECTED_VERSION) throw new Error(`Unexpected version: ${pkg.version}`)
for (const hook of ['preinstall', 'install', 'postinstall']) {
if (pkg.scripts?.[hook]) throw new Error(`Forbidden lifecycle hook: ${hook}`)
}
NODE
test ! -f inspect/package/binding.gyp || { echo "Forbidden implicit install hook: binding.gyp"; exit 1; }
echo "tarball=${TARBALLS[0]}" >> "$GITHUB_ENV"

- name: Publish with OIDC provenance
run: npm publish "$tarball" --provenance --access public

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prefix the tarball path before npm publish

When the tag workflow reaches this publish step, find package ... stores a value like package/formo-cli-1.2.1.tgz in tarball; passing that unqualified one-slash spec to npm publish is not treated as the downloaded local tarball. npm's package-spec docs show local tarballs as ./my-package.tgz while username/project is GitHub shorthand (https://docs.npmjs.com/cli/v11/using-npm/package-spec/), and npm publish --dry-run package/foo.tgz resolves as a GitHub repo, so releases will fail before publishing. Use ./${TARBALLS[0]} or an absolute path from find.

Useful? React with 👍 / 👎.


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
8 changes: 8 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
"dist",
"README.md"
],
"publishConfig": {
"access": "public",
"provenance": true
},
"scripts": {
"build": "tsc",
"prepublishOnly": "pnpm build",
Expand Down
3 changes: 2 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
minimumReleaseAge: 2880
minimumReleaseAge: 10080
blockExoticSubdeps: true
trustPolicy: no-downgrade

overrides:
serialize-javascript: '>=7.0.5'
Expand Down