Files
ECC/.github/workflows/release.yml
T
Affaan MustafaandGitHub 0348d7b6d7 chore(release): 2.2.3, ECC-only naming in pi/core and a longer npm publish wait (#3329)
pi/core and the bundled blueprint skill now refer to the project as ECC only;
the generated README no longer expands the name. Profile contents, package
name and paths are unchanged (123 skills, 24 commands).

The release workflows poll npm for up to five minutes after publish before
verifying the artifact. In 2.2.2 the check gave up after about 30 seconds while
npm was still processing, failing the run before latest was promoted and the
GitHub Release was created.

Version metadata synced to 2.2.3 with CHANGELOG, translated release headings
and release notes; skill-triggers registryDigest updated for the blueprint text.
2026-10-01 17:49:51 -05:00

296 lines
14 KiB
YAML

name: Release
on:
push:
tags: ['v*']
permissions:
actions: read
checks: read
contents: read
jobs:
verify:
name: Verify Release
runs-on: ubuntu-latest
outputs:
release_sha: ${{ steps.release_gate.outputs.release_sha }}
tag_object_sha: ${{ steps.release_gate.outputs.tag_object_sha }}
already_published: ${{ steps.npm_publish_state.outputs.already_published }}
dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }}
publish_tag: ${{ steps.npm_publish_state.outputs.publish_tag }}
package_name: ${{ steps.npm_publish_state.outputs.package_name }}
package_version: ${{ steps.npm_publish_state.outputs.package_version }}
package_file: ${{ steps.pack.outputs.package_file }}
package_sha256: ${{ steps.pack.outputs.package_sha256 }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Require the release commit to equal origin main
run: |
git fetch origin main --no-tags
RELEASE_COMMIT=$(git rev-parse HEAD)
MAIN_COMMIT=$(git rev-parse origin/main)
if [ "$RELEASE_COMMIT" != "$MAIN_COMMIT" ]; then
echo "::error::The release commit must equal origin/main exactly"
exit 1
fi
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Verify signed tag and exact-SHA CI gates
id: release_gate
env:
GITHUB_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
run: RELEASE_SHA="$(git rev-parse HEAD)" node scripts/ci/verify-release-gates.js
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Run supply-chain IOC scan
run: npm run security:ioc-scan
- name: Verify OpenCode package payload
run: node tests/scripts/build-opencode.test.js
- name: Validate version tag
run: |
if ! [[ "${REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid version tag format. Expected vX.Y.Z or vX.Y.Z-prerelease"
exit 1
fi
env:
REF_NAME: ${{ github.ref_name }}
- name: Verify package version matches tag
env:
TAG_NAME: ${{ github.ref_name }}
run: |
TAG_VERSION="${TAG_NAME#v}"
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)"
echo "Run: ./scripts/release.sh $TAG_VERSION"
exit 1
fi
- name: Verify VERSION matches tag
env:
TAG_NAME: ${{ github.ref_name }}
run: |
TAG_VERSION="${TAG_NAME#v}"
FILE_VERSION=$(tr -d '[:space:]' < VERSION)
if [ "$TAG_VERSION" != "$FILE_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match VERSION ($FILE_VERSION)"
exit 1
fi
# pi/core/package.json derives its version from VERSION, so a current
# profile also proves pi/core is in sync with the tag.
- name: Verify pi/core profile is current
run: node scripts/build-pi-core.js --check
- name: Verify release metadata stays in sync
run: node tests/plugin-manifest.test.js
- name: Check npm publish state
id: npm_publish_state
run: |
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
NPM_DIST_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'latest'")
NPM_PUBLISH_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'staged'")
set +e
NPM_LOOKUP=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>&1)
NPM_STATUS=$?
set -e
if [ "$NPM_STATUS" -eq 0 ]; then
echo "already_published=true" >> "$GITHUB_OUTPUT"
elif printf '%s\n' "$NPM_LOOKUP" | grep -q 'E404'; then
echo "already_published=false" >> "$GITHUB_OUTPUT"
else
echo "::error::npm registry lookup failed; refusing to infer that the version is unpublished"
printf '%s\n' "$NPM_LOOKUP"
exit "$NPM_STATUS"
fi
echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "package_version=${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT"
echo "dist_tag=${NPM_DIST_TAG}" >> "$GITHUB_OUTPUT"
echo "publish_tag=${NPM_PUBLISH_TAG}" >> "$GITHUB_OUTPUT"
- name: Use reviewed release notes
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
RELEASE_VERSION="${RELEASE_TAG#v}"
RELEASE_NOTES="docs/releases/${RELEASE_VERSION}/release-notes.md"
if [ ! -f "$RELEASE_NOTES" ]; then
echo "::error::Missing reviewed release notes for ${RELEASE_VERSION}: ${RELEASE_NOTES}"
exit 1
fi
cp "$RELEASE_NOTES" release_body.md
- name: Pack npm artifact
id: pack
run: |
npm pack --json > npm-pack.json
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const entries = Array.isArray(data) ? data : [data]; const file = entries.find(entry => /^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(entry?.filename || ''))?.filename; if (!file) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ecc-release-artifacts
path: |
release_body.md
${{ steps.pack.outputs.package_file }}
tests/ci/packed-artifact-lifecycle.js
if-no-files-found: error
- name: Verify existing npm artifact matches candidate
if: steps.npm_publish_state.outputs.already_published == 'true'
env:
ECC_RELEASE_PACKAGE: ${{ steps.pack.outputs.package_file }}
run: |
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
REGISTRY_INTEGRITY=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" dist.integrity)
ECC_REGISTRY_INTEGRITY="$REGISTRY_INTEGRITY" node -e "const crypto = require('crypto'); const fs = require('fs'); const expected = process.env.ECC_REGISTRY_INTEGRITY; if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(expected || '')) throw new Error('Invalid registry integrity'); const actual = 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(process.env.ECC_RELEASE_PACKAGE)).digest('base64'); if (actual !== expected) throw new Error('Existing npm artifact does not match tested candidate')"
lifecycle:
name: Packed Lifecycle (${{ matrix.os }})
needs: verify
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.x'
- name: Download exact packed artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ecc-release-artifacts
path: release-artifacts
- name: Verify packed install lifecycle
env:
ECC_RELEASE_PACKAGE: release-artifacts/${{ needs.verify.outputs.package_file }}
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
run: node release-artifacts/tests/ci/packed-artifact-lifecycle.js
publish:
name: Publish Release
runs-on: ubuntu-latest
needs: [verify, lifecycle]
permissions:
contents: write
id-token: write
steps:
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ecc-release-artifacts
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Verify artifact before publish
env:
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
run: node -e "const crypto = require('crypto'); const fs = require('fs'); const file = process.env.ECC_RELEASE_PACKAGE; const expected = process.env.ECC_RELEASE_SHA256; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); if (!/^[a-f0-9]{64}$/.test(expected || '')) throw new Error('Invalid packed SHA-256'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one downloaded archive'); const actual = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if (actual !== expected) throw new Error('Downloaded publish artifact SHA-256 mismatch')"
- name: Bind gate source to triggering commit
env:
EVENT_SHA: ${{ github.sha }}
VERIFIED_RELEASE_SHA: ${{ needs.verify.outputs.release_sha }}
run: node -e "const actual = process.env.EVENT_SHA; const expected = process.env.VERIFIED_RELEASE_SHA; if (typeof actual !== 'string' || actual.length !== 40 || !/^[a-f0-9]{40}$/.test(actual) || expected !== actual) throw new Error('Verified release differs from triggering commit')"
- name: Checkout verified gate source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
path: release-gate-source
persist-credentials: false
sparse-checkout: scripts/ci/verify-release-gates.js
sparse-checkout-cone-mode: false
# This read-only API check uses the existing publish job token. It is a
# snapshot; preventing subsequent tag movement requires protected tags.
- name: Recheck verified tag before publish
env:
GITHUB_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
RELEASE_SHA: ${{ needs.verify.outputs.release_sha }}
RELEASE_TAG_OBJECT_SHA: ${{ needs.verify.outputs.tag_object_sha }}
run: node release-gate-source/scripts/ci/verify-release-gates.js --tag-only
- name: Publish npm package
if: needs.verify.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
NPM_PUBLISH_TAG: ${{ needs.verify.outputs.publish_tag }}
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_PUBLISH_TAG}"
- name: Verify published npm artifact
env:
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
PACKAGE_NAME: ${{ needs.verify.outputs.package_name }}
PACKAGE_VERSION: ${{ needs.verify.outputs.package_version }}
run: |
REGISTRY_INTEGRITY=""
for ATTEMPT in $(seq 1 30); do
set +e
REGISTRY_INTEGRITY=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" dist.integrity 2>&1)
NPM_STATUS=$?
set -e
if [ "$NPM_STATUS" -eq 0 ]; then
break
fi
if [ "$ATTEMPT" -eq 30 ]; then
echo "::error::Published npm artifact was not readable after five minutes"
printf '%s\n' "$REGISTRY_INTEGRITY"
exit "$NPM_STATUS"
fi
sleep 10
done
ECC_REGISTRY_INTEGRITY="$REGISTRY_INTEGRITY" node -e "const crypto = require('crypto'); const fs = require('fs'); const expected = process.env.ECC_REGISTRY_INTEGRITY; if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(expected || '')) throw new Error('Invalid published registry integrity'); const actual = 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(process.env.ECC_RELEASE_PACKAGE)).digest('base64'); if (actual !== expected) throw new Error('Published npm artifact does not match tested candidate')"
- name: Promote verified npm version
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
PACKAGE_NAME: ${{ needs.verify.outputs.package_name }}
PACKAGE_VERSION: ${{ needs.verify.outputs.package_version }}
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
run: npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" "${NPM_DIST_TAG}"
- name: Create GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
body_path: release_body.md
generate_release_notes: false
prerelease: ${{ contains(github.ref_name, '-') }}
make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }}