From cba0582103606974c266723d9501fc7017322d56 Mon Sep 17 00:00:00 2001 From: Jason Kummerl Date: Wed, 22 Jan 2025 11:42:50 -0500 Subject: [PATCH] Fix up CI/CD --- .github/FUNDING.yml | 1 + .github/dependabot.yml | 9 + .github/workflows/cache-cleanup.yml | 31 +++ .github/workflows/codeql.yml | 34 +++ .github/workflows/coverage.yml | 25 -- .github/workflows/coveralls.yml | 63 +++++ .github/workflows/lint-check.yml | 45 +++ .github/workflows/npm-publish.yml | 340 +++++++++++++++++++++++ .github/workflows/publish.yml | 36 --- .github/workflows/stale-pr.yml | 32 +++ .github/workflows/validate-workflows.yml | 94 +++++++ 11 files changed, 649 insertions(+), 61 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/cache-cleanup.yml create mode 100644 .github/workflows/codeql.yml delete mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/coveralls.yml create mode 100644 .github/workflows/lint-check.yml create mode 100644 .github/workflows/npm-publish.yml delete mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/stale-pr.yml create mode 100644 .github/workflows/validate-workflows.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5faffc9 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [humanspeak] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5c5329e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + labels: + - dependencies diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml new file mode 100644 index 0000000..46c5168 --- /dev/null +++ b/.github/workflows/cache-cleanup.yml @@ -0,0 +1,31 @@ +name: Cache Cleanup + +permissions: + actions: read + +on: + schedule: + - cron: 0 0 1 * * # Monthly cleanup + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Cleanup + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: main + run: | + gh extension install actions/gh-actions-cache + + echo "Fetching list of cache key" + cacheKeys=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeys + do + gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm + done diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..36d38fb --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,34 @@ +name: CodeQL + +permissions: + security-events: write + contents: read + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + schedule: + - cron: 0 0 * * 0 # Run weekly + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event_name == 'schedule' || github.event.pull_request.head.repo.full_name != github.repository + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 4c03a2d..0000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: coveralls test coverage - -on: - push: - branches: - - main - pull_request: - release: - types: - - created - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - run: npm i svelte - - run: npm ci - - run: npm test - - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.NPM_GITHUB_TOKEN }} diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml new file mode 100644 index 0000000..f9fd5cb --- /dev/null +++ b/.github/workflows/coveralls.yml @@ -0,0 +1,63 @@ +name: Coveralls + +permissions: + contents: read + packages: read + +on: + schedule: + # Runs at 00:00 on Sunday + - cron: 0 0 * * 0 + workflow_dispatch: # Allows manual triggering + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + token: ${{ secrets.ACTIONS_KEY }} + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install + run: npm ci + + - name: Test + run: npm test --coverage + + - name: Upload coverage to Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: node-${{ matrix.node-version }} + parallel: true + if: matrix.node-version == '22' + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node-version }}- + ${{ runner.os }}-node- + + finish-coverage: + needs: build + runs-on: ubuntu-latest + if: ${{ always() }} + steps: + - name: Coveralls Finished + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true diff --git a/.github/workflows/lint-check.yml b/.github/workflows/lint-check.yml new file mode 100644 index 0000000..9b6af08 --- /dev/null +++ b/.github/workflows/lint-check.yml @@ -0,0 +1,45 @@ +# This workflow enforces code quality standards by running ESLint checks +# on all Pull Requests targeting main or staging branches. +# +# It will: +# 1. Run on every PR to main/staging +# 2. Check all relevant files +# 3. Fail if there are any linting errors/warnings +# 4. Block PR merging until all lint issues are resolved +# +# The lint configuration is defined in the project's ESLint config files + +name: Lint Check + +permissions: + contents: read + +on: + pull_request: + branches: + - main + paths: + - '!docs/**' + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: ./package.json + + - name: Install dependencies + run: npm ci + + - name: Run lint check + run: npm run lint diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..9144169 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,340 @@ +# 1. Workflow metadata +name: Publish to NPM + +permissions: + contents: read + packages: read + pull-requests: read + +# 2. Trigger conditions +on: + pull_request: + types: [closed] + branches: + - main + paths-ignore: + - .github/** + - docs/** + - PRD.md + - README.md + workflow_dispatch: + inputs: + # trunk-ignore(checkov/CKV_GHA_7): We need manual version control for releases + version_bump: + description: Version bump type (major/minor/patch) or skip + required: true + type: choice + options: + - skip + - patch + - minor + - major + custom_message: + description: Release message + required: false + type: string + +jobs: + # 3. Testing jobs (grouped together) + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + token: ${{ secrets.ACTIONS_KEY }} + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install + run: | + npm ci + npm run build + + - name: Test + run: npm test --coverage + + - name: Upload Vitest Results + if: always() + uses: trunk-io/analytics-uploader@main + with: + junit-paths: junit-vitest.xml + org-slug: ${{ secrets.TRUNK_ORG_SLUG }} + token: ${{ secrets.TRUNK_TOKEN }} + + - name: Upload coverage to Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: node-${{ matrix.node-version }} + parallel: true + if: matrix.node-version == '22' + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node-version }}- + ${{ runner.os }}-node- + + # 4. Coverage reporting (depends on tests) + coverage-report: + needs: [build] + runs-on: ubuntu-latest + if: ${{ always() }} + steps: + - name: Coveralls Finished + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true + + # 5. Publishing job (main deployment logic) + publish-github-packages: + needs: [build, coverage-report] + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + issues: write + pull-requests: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Use Node.js - 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + scope: '@humanspeak' + + - name: Install + run: npm ci + + - name: Check Publishing Status + id: publish-check + env: + EVENT_NAME: ${{ github.event_name }} + IS_MERGED: ${{ github.event.pull_request.merged }} + HAS_SKIP_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'skip-publish') }} + INPUT_VERSION: ${{ github.event.inputs.version_bump }} + run: | + # Validate event name first + if [[ "$EVENT_NAME" != "pull_request" && "$EVENT_NAME" != "workflow_dispatch" ]]; then + echo "Invalid event type" + exit 1 + fi + + # Check publishing conditions + if [[ "$EVENT_NAME" == "pull_request" ]]; then + if [[ "$IS_MERGED" != "true" ]]; then + echo "PR not merged, skipping publish" + echo "should_publish=false" >> $GITHUB_OUTPUT + elif [[ "$HAS_SKIP_LABEL" == "true" ]]; then + echo "Publishing skipped due to skip-publish label" + echo "should_publish=false" >> $GITHUB_OUTPUT + else + echo "should_publish=true" >> $GITHUB_OUTPUT + fi + elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$INPUT_VERSION" == "skip" ]]; then + echo "Publishing skipped via manual selection" + echo "should_publish=false" >> $GITHUB_OUTPUT + else + echo "should_publish=true" >> $GITHUB_OUTPUT + fi + + - name: Create Comment on Skip + if: github.event_name == 'pull_request' && steps.publish-check.outputs.should_publish == 'false' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.ACTIONS_KEY }} + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '⏭️ NPM publishing was skipped due to the `skip-publish` label.' + }) + + - name: Import GPG key + if: steps.publish-check.outputs.should_publish == 'true' + id: import_gpg + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.ACTIONS_GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.ACTIONS_GPG_PASSPHRASE }} + git_user_signingkey: true + git_commit_gpgsign: true + git_tag_gpgsign: true + git_config_global: true + + - name: Determine version bump type + if: steps.publish-check.outputs.should_publish == 'true' + id: version-type + env: + HAS_MAJOR: ${{ contains(github.event.pull_request.labels.*.name, 'major') }} + HAS_MINOR: ${{ contains(github.event.pull_request.labels.*.name, 'minor') }} + INPUT_VERSION: ${{ github.event.inputs.version_bump }} + run: | + # Function to validate version bump type + validate_bump_type() { + local bump="$1" + case "$bump" in + "major"|"minor"|"patch"|"skip") echo "$bump" ;; + *) echo "patch" ;; + esac + } + + # Determine and validate bump type + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + BUMP_TYPE=$(validate_bump_type "$INPUT_VERSION") + if [ "$BUMP_TYPE" = "skip" ]; then + echo "Publishing skipped via manual selection" + echo "should_publish=false" >> $GITHUB_OUTPUT + exit 0 + fi + elif [ "$HAS_MAJOR" = "true" ]; then + BUMP_TYPE="major" + elif [ "$HAS_MINOR" = "true" ]; then + BUMP_TYPE="minor" + else + BUMP_TYPE="patch" + fi + + echo "bump=$BUMP_TYPE" >> $GITHUB_OUTPUT + + - name: Bump version + if: steps.publish-check.outputs.should_publish == 'true' + id: version + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + BUMP_TYPE: ${{ steps.version-type.outputs.bump }} + GITHUB_TOKEN: ${{ secrets.ACTIONS_KEY }} + GPG_KEY_ID: ${{ steps.import_gpg.outputs.keyid }} + run: | + # Validate GPG key ID format first + if [[ ! "$GPG_KEY_ID" =~ ^[A-F0-9]{16}$ ]]; then + echo "Invalid GPG key ID format" + exit 1 + fi + + # Configure git with validated credentials + git config --global user.name "GitHub Actions Bot" + git config --global user.email "jason@humanspeak.com" + git config --global commit.gpgsign true + git config --global user.signingkey "$GPG_KEY_ID" + + # Set up authentication for push + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" + + # Validate bump type for security + case "$BUMP_TYPE" in + "major"|"minor"|"patch") ;; + *) + echo "Invalid version bump type" + exit 1 + ;; + esac + + # Get the new version number + NEW_VERSION=$(npm version "$BUMP_TYPE" --no-git-tag-version) + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + # Escape special characters in PR title and URL + ESCAPED_TITLE=$(echo "$PR_TITLE" | sed 's/[`$"\]/\\&/g') + ESCAPED_URL=$(echo "$PR_URL" | sed 's/[`$"\]/\\&/g') + + # Commit the version changes + git add package.json package-lock.json + git commit -m "Bump version to ${NEW_VERSION} [skip ci]" + + # Create an annotated tag with release notes + git tag -a "${NEW_VERSION}" -m "Release ${NEW_VERSION} + + Changes in this Release: + - ${ESCAPED_TITLE} + + PR: ${ESCAPED_URL}" + + # Push changes + git push + git push --tags + + - name: Create Release + if: steps.publish-check.outputs.should_publish == 'true' + id: create_release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.new_version }} + name: Release ${{ steps.version.outputs.new_version }} + body: | + Changes in this Release + ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.custom_message || github.event.pull_request.title }} + + ${{ github.event_name == 'pull_request' && format('For more details, see the [Pull Request]({0})', github.event.pull_request.html_url) || '' }} + draft: false + prerelease: false + make_latest: true + generate_release_notes: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish + if: steps.publish-check.outputs.should_publish == 'true' + run: | + # Ensure we're publishing to the correct scope + rm -f ./.npmrc + npm config set @humanspeak:registry https://registry.npmjs.org/ + npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_GITHUB_TOKEN }} + + - name: Cleanup on failure + if: failure() && steps.create_release.outcome == 'success' + env: + RELEASE_VERSION: ${{ steps.version.outputs.new_version }} + GITHUB_TOKEN: ${{ secrets.ACTIONS_KEY }} + run: | + # Validate version format first + if [[ ! "$RELEASE_VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid version format: $RELEASE_VERSION" + exit 1 + fi + + # Proceed with cleanup only if validation passes + gh release delete "$RELEASE_VERSION" --yes + git tag -d "$RELEASE_VERSION" + git push --delete origin "$RELEASE_VERSION" + + - name: Notify on failure + if: failure() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.ACTIONS_KEY }} + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '❌ Release workflow failed. Please check the [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})' + }) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index c88f796..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,36 +0,0 @@ -# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created -# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages - -name: publish - -on: - release: - types: [published] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - run: npm ci - - run: npm test - - publish-gpr: - needs: build - runs-on: ubuntu-latest - permissions: - packages: write - contents: read - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - registry-url: https://npm.pkg.github.com/ - - run: npm ci - - run: npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_GITHUB_TOKEN }} diff --git a/.github/workflows/stale-pr.yml b/.github/workflows/stale-pr.yml new file mode 100644 index 0000000..7acc318 --- /dev/null +++ b/.github/workflows/stale-pr.yml @@ -0,0 +1,32 @@ +name: Mark stale PRs + +permissions: + issues: write + pull-requests: write +on: + schedule: + - cron: 30 1 * * * # Runs at 1:30 AM UTC daily + +jobs: + stale: + runs-on: ubuntu-latest + + steps: + - uses: actions/stale@v9 + with: + # PR specific settings + stale-pr-message: This PR is being marked as stale due to 30 days of inactivity. It will be closed in 5 days if no further activity occurs. + close-pr-message: This PR was closed due to inactivity. Please feel free to reopen if this is still relevant. + days-before-pr-stale: 30 + days-before-pr-close: 5 + stale-pr-label: stale + exempt-pr-labels: no-stale,security,dependencies + + # Skip issues - we only want to track PRs + days-before-issue-stale: -1 + days-before-issue-close: -1 + + # Additional settings + remove-stale-when-updated: true + delete-branch: false + enable-statistics: true diff --git a/.github/workflows/validate-workflows.yml b/.github/workflows/validate-workflows.yml new file mode 100644 index 0000000..c05ca1c --- /dev/null +++ b/.github/workflows/validate-workflows.yml @@ -0,0 +1,94 @@ +name: Validate Workflows + +permissions: + contents: read + +on: + pull_request: + paths: + - .github/workflows/** + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Install zizmor + run: cargo install --force zizmor + + - name: Validate all workflows + working-directory: .github/workflows + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BASE_REF: ${{ github.base_ref }} + run: | + # Exit on any error + set -euo pipefail + + # Initialize error flag + has_errors=0 + + # Validate inputs + if [ -z "${BASE_REF:-}" ]; then + echo "::error::BASE_REF is not set" + exit 1 + fi + + # Properly escape the base ref for use in git commands + base_ref=$(printf '%q' "$BASE_REF") + + # Get list of changed files with error handling + changed_files=$(git diff --name-only "origin/${base_ref}" 2>/dev/null | \ + grep -E '^\.github/workflows/[^/]+\.yml$' || true) + + if [ -z "$changed_files" ]; then + echo "No workflow files changed" + exit 0 + fi + + # Loop through changed workflow files + while IFS= read -r file; do + [ -z "$file" ] && continue + + # Safely handle filenames + filename=$(basename -- "$file") + + # Skip non-yaml files + if [[ ! "$filename" =~ \.(ya?ml)$ ]]; then + continue + } + + echo "Validating $filename..." + + if ! zizmor "$filename" 2>/dev/null; then + echo "::error::Validation failed for $filename" + has_errors=1 + fi + done <<< "$changed_files" + + # Exit with error if any validation failed + if [ "$has_errors" -eq 1 ]; then + echo "::error::One or more workflow validations failed" + exit 1 + fi + + echo "All workflows validated successfully"