From 27948791475ef69d115e82c5c8910544d862a1ed Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 8 Sep 2026 09:27:52 -0700 Subject: [PATCH] ci: slim release-please heads and stop compiling on main Recipe A: ci.yml and security.yml no longer compile on push to main. post-merge.yml keeps Scorecard dispatch only. Recipe G: release-please* heads skip the 3-OS unit, build, integration, and UI matrix. The required ci gate still reports via a cheap ubuntu tsc --noEmit stand-in. CodeQL analysis stays named and echoes on those heads. FOSSA is path-filtered on push and pull_request. Notes: apply-release-notes.sh reads release-note- first, then legacy RELEASE_NOTES.md on the tag checkout. Signed-off-by: Sebastien Tardif --- .github/workflows/apply-release-notes.yml | 44 +++++++ .github/workflows/ci.yml | 45 ++++++- .github/workflows/fossa.yml | 11 ++ .github/workflows/post-merge.yml | 8 +- .github/workflows/release.yml | 13 +-- .github/workflows/security.yml | 15 ++- AGENTS.md | 4 +- scripts/apply-release-notes.sh | 119 +++++++++++++++++++ scripts/test_apply_release_notes.py | 136 ++++++++++++++++++++++ scripts/test_workflow_triggers.py | 98 ++++++++++++++++ 10 files changed, 474 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/apply-release-notes.yml create mode 100755 scripts/apply-release-notes.sh create mode 100755 scripts/test_apply_release_notes.py create mode 100755 scripts/test_workflow_triggers.py diff --git a/.github/workflows/apply-release-notes.yml b/.github/workflows/apply-release-notes.yml new file mode 100644 index 0000000..cb4acfa --- /dev/null +++ b/.github/workflows/apply-release-notes.yml @@ -0,0 +1,44 @@ +# Cheap apply of curated notes onto a GitHub Release. +# Reads branch release-note- or vars RELEASE_NOTES + +# RELEASE_NOTES_TAG. Deletes the notes branch after a successful +# apply. Does not compile and does not start vsce/ovsx. +name: Apply release notes + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. patchloom-v0.7.0)" + required: true + type: string + +concurrency: + group: apply-release-notes-${{ inputs.tag }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + apply: + name: Apply notes + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + RELEASE_NOTES: ${{ vars.RELEASE_NOTES }} + RELEASE_NOTES_TAG: ${{ vars.RELEASE_NOTES_TAG }} + steps: + - name: Harden runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Apply curated notes + run: bash scripts/apply-release-notes.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4790fb3..7d0ea18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,10 @@ name: ci +# Recipe A: product compile stays on PR / merge_group / dispatch. +# Do not add push to main (that is a second matrix). Scorecard default-branch +# runs stay in post-merge.yml. Recipe G: release-please* heads skip the +# 3-OS + integration + UI matrix and run a cheap ubuntu tsc instead. on: - push: - branches: [main] pull_request: merge_group: workflow_dispatch: @@ -44,7 +46,10 @@ jobs: unit-test: needs: [changes] - if: always() && (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') + if: > + always() + && (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') + && (github.event_name != 'pull_request' || !startsWith(github.head_ref, 'release-please')) strategy: fail-fast: false matrix: @@ -96,7 +101,7 @@ jobs: fi - name: Update coverage badge - if: matrix.os == 'ubuntu-latest' && github.event_name == 'push' && github.ref == 'refs/heads/main' + if: matrix.os == 'ubuntu-latest' && github.event_name == 'workflow_dispatch' continue-on-error: true uses: schneegans/dynamic-badges-action@28b0fa8bdeb46170ac397105ece0c1fe58f68910 # v1.9.0 with: @@ -109,7 +114,10 @@ jobs: build: needs: [changes] - if: always() && (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') + if: > + always() + && (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') + && (github.event_name != 'pull_request' || !startsWith(github.head_ref, 'release-please')) runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -139,6 +147,7 @@ jobs: && (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') && (needs.unit-test.result == 'success' || needs.unit-test.result == 'skipped') && (needs.build.result == 'success' || needs.build.result == 'skipped') + && (github.event_name != 'pull_request' || !startsWith(github.head_ref, 'release-please')) strategy: fail-fast: false matrix: @@ -185,6 +194,29 @@ jobs: if: runner.os != 'Linux' run: npx extest run-tests './out-uitest/test/ui/*.test.js' --extensions_dir .vscode-test/extensions + # Recipe G: version-bump PRs already ran the full matrix on the feature PR. + # Keep a cheap ubuntu compile so a broken package.json / tsconfig still fails. + release-please-check: + if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-please') + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: ./.github/actions/setup-node + + - name: Typecheck (release-please stand-in) + run: npx tsc --noEmit + ci: # Final gate job (if: always()). This is the recommended pattern (see patchloom # and attune) so that individual matrix jobs can use the changes filter without @@ -194,12 +226,13 @@ jobs: - unit-test - build - integration-test + - release-please-check runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: All CI jobs passed (or were correctly skipped for docs-only change) run: | - results=("${{ needs.unit-test.result }}" "${{ needs.build.result }}" "${{ needs.integration-test.result }}") + results=("${{ needs.unit-test.result }}" "${{ needs.build.result }}" "${{ needs.integration-test.result }}" "${{ needs.release-please-check.result }}") for r in "${results[@]}"; do if [[ "$r" != "success" && "$r" != "skipped" ]]; then echo "FAILED: dependency reported '$r'" diff --git a/.github/workflows/fossa.yml b/.github/workflows/fossa.yml index 2ebc721..3d313f0 100644 --- a/.github/workflows/fossa.yml +++ b/.github/workflows/fossa.yml @@ -3,7 +3,17 @@ name: FOSSA license scan on: push: branches: [main] + paths: + - "package.json" + - "package-lock.json" + - "src/**" + - ".github/workflows/fossa.yml" pull_request: + paths: + - "package.json" + - "package-lock.json" + - "src/**" + - ".github/workflows/fossa.yml" workflow_dispatch: permissions: @@ -16,6 +26,7 @@ concurrency: jobs: fossa: name: License compliance + if: github.event_name != 'pull_request' || !startsWith(github.head_ref, 'release-please') env: FOSSA_API_KEY: ${{ secrets.FOSSA_API_KEY }} runs-on: ubuntu-latest diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml index f454778..e39163a 100644 --- a/.github/workflows/post-merge.yml +++ b/.github/workflows/post-merge.yml @@ -26,12 +26,14 @@ jobs: with: egress-policy: audit - - name: Trigger workflows on main + # Recipe A: do not dispatch ci.yml or security.yml on a green squash. + # The feature PR (or merge_group) already ran those. A second matrix + # is waste. Scorecard wants a default-branch run; keep that dispatch. + # Do not set require-up-to-date to justify dropping this file (#156). + - name: Trigger Scorecard on main env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh workflow run ci.yml --ref main --repo "${{ github.repository }}" - gh workflow run security.yml --ref main --repo "${{ github.repository }}" gh workflow run scorecard.yml --ref main --repo "${{ github.repository }}" - name: Auto-update open PR branches diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e993ce6..ddeaa22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,15 +94,14 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload "${{ needs.release-please.outputs.tag_name }}" patchloom.vsix - name: Apply custom release notes + continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if [ -f RELEASE_NOTES.md ]; then - echo "Custom release notes found, updating release body..." - gh release edit "${{ needs.release-please.outputs.tag_name }}" --notes-file RELEASE_NOTES.md - else - echo "No custom release notes, using auto-generated notes" - fi + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.release-please.outputs.tag_name }} + RELEASE_NOTES: ${{ vars.RELEASE_NOTES }} + RELEASE_NOTES_TAG: ${{ vars.RELEASE_NOTES_TAG }} + run: bash scripts/apply-release-notes.sh # See ~/.grok/skills/vsce-publish/SKILL.md (or /vsce-publish) for the full # one-time browser setup (aex.dev.azure.com, PAT scopes, Open VSX agreement # + create-namespace + ownership claim), secret names, and troubleshooting. diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4054bad..9ba8e04 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,8 +1,8 @@ name: security +# Recipe A: no third CodeQL on every push to main. PR / merge_group +# already scanned the tree. Weekly schedule keeps the default branch warm. on: - push: - branches: [main] pull_request: merge_group: schedule: @@ -97,27 +97,38 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 permissions: + actions: read + contents: read security-events: write steps: + - name: Release-please stand-in + if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-please') + run: echo 'version-bump PR; CodeQL already ran on the feature PR' + - name: Harden runner + if: github.event_name != 'pull_request' || !startsWith(github.head_ref, 'release-please') uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit - name: Checkout + if: github.event_name != 'pull_request' || !startsWith(github.head_ref, 'release-please') uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Initialize CodeQL + if: github.event_name != 'pull_request' || !startsWith(github.head_ref, 'release-please') uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: javascript-typescript,actions - name: Autobuild + if: github.event_name != 'pull_request' || !startsWith(github.head_ref, 'release-please') uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - name: Perform CodeQL analysis + if: github.event_name != 'pull_request' || !startsWith(github.head_ref, 'release-please') uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 dependency-review: diff --git a/AGENTS.md b/AGENTS.md index f4611c2..4ee73c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ test/ scripts/ hide-test-vscode.sh macOS: patch test VS Code to suppress window activation guard-no-release-merge.sh Hard abort if agent tries to merge a release PR without approval + apply-release-notes.sh Apply release-note- (or legacy RELEASE_NOTES.md) onto a GitHub Release .github/ CODEOWNERS Owner for all files copilot-instructions.md Pointer to AGENTS.md for GitHub Copilot @@ -92,7 +93,8 @@ scripts/ fossa.yml FOSSA license compliance scan links.yml Markdown link checker (lychee) pat-expiry.yml Monthly VSCE PAT expiry reminder - post-merge.yml Trigger CI/security/scorecard on main after auto-merge + post-merge.yml Scorecard dispatch after squash (not ci/security) + apply-release-notes.yml Cheap apply of release-note- onto a GitHub Release pr-title.yml Semantic PR title validation scorecard.yml OpenSSF Scorecard analysis (weekly + on push) release.yml Release: release-please + .vsix packaging and upload diff --git a/scripts/apply-release-notes.sh b/scripts/apply-release-notes.sh new file mode 100755 index 0000000..9014464 --- /dev/null +++ b/scripts/apply-release-notes.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Apply curated notes to an existing GitHub Release, then delete the +# notes branch. Notes are not on main. +# +# Sources, first match: +# 1. NOTES_FILE (tests) +# 2. RELEASE_NOTES.md on branch release-note- +# (tag patchloom-v0.6.0 or v0.6.0 -> release-note-0.6.0) +# 3. Actions vars RELEASE_NOTES + RELEASE_NOTES_TAG (tag must match) +# 4. RELEASE_NOTES.md in the current checkout (legacy main-file path) +# +# Missing source is a no-op (auto changelog stays). After a successful +# apply from the notes branch, that branch is deleted. Variables are +# left in place; the tag pin stops them applying to a later cut. +set -euo pipefail + +TAG="${TAG:-}" +REPO="${GH_REPO:-${GITHUB_REPOSITORY:-}}" +DRY_RUN="${DRY_RUN:-0}" +DELETE_BRANCH="${DELETE_BRANCH:-1}" +NOTES_FILE="${NOTES_FILE:-}" +RELEASE_NOTES="${RELEASE_NOTES:-}" +RELEASE_NOTES_TAG="${RELEASE_NOTES_TAG:-}" + +semver="" +if [[ "${TAG}" =~ ^patchloom-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + semver="${TAG#patchloom-v}" +elif [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + semver="${TAG#v}" +else + echo "TAG must look like patchloom-vX.Y.Z or vX.Y.Z: ${TAG}" >&2 + exit 1 +fi + +if [ -z "${REPO}" ]; then + echo "GH_REPO or GITHUB_REPOSITORY required" >&2 + exit 1 +fi + +branch="${NOTES_BRANCH:-release-note-${semver}}" +loaded_from_branch=0 +source_label="" + +tmp="$(mktemp)" +cleanup() { rm -f "${tmp}"; } +trap cleanup EXIT + +tag_matches_pin() { + local pin="$1" + if [ -z "${pin}" ]; then + return 1 + fi + if [ "${pin}" = "${TAG}" ] || [ "${pin}" = "${semver}" ] \ + || [ "${pin}" = "v${semver}" ] || [ "${pin}" = "patchloom-v${semver}" ]; then + return 0 + fi + return 1 +} + +if [ -n "${NOTES_FILE}" ] && [ -f "${NOTES_FILE}" ]; then + echo "PLAN: file ${NOTES_FILE}" + cp "${NOTES_FILE}" "${tmp}" + source_label="file:${NOTES_FILE}" +elif [ -n "${GH_TOKEN:-}" ]; then + echo "PLAN: fetch RELEASE_NOTES.md from ${branch}" + if gh api "repos/${REPO}/contents/RELEASE_NOTES.md?ref=${branch}" \ + -H "Accept: application/vnd.github.raw" >"${tmp}"; then + loaded_from_branch=1 + source_label="branch:${branch}" + else + : >"${tmp}" + echo "PLAN: no notes branch ${branch}" + fi +fi + +if [ ! -s "${tmp}" ] && [ -n "${RELEASE_NOTES}" ] \ + && tag_matches_pin "${RELEASE_NOTES_TAG}"; then + echo "PLAN: Actions variable RELEASE_NOTES (pin ${RELEASE_NOTES_TAG})" + printf '%s\n' "${RELEASE_NOTES}" >"${tmp}" + source_label="variable" +fi + +if [ ! -s "${tmp}" ] && [ -f RELEASE_NOTES.md ]; then + echo "PLAN: legacy RELEASE_NOTES.md in checkout" + cp RELEASE_NOTES.md "${tmp}" + source_label="file:RELEASE_NOTES.md" +fi + +if [ ! -s "${tmp}" ]; then + echo "OK: no curated notes for ${TAG}; leaving auto notes" + exit 0 +fi + +if [ "${DRY_RUN}" = "1" ]; then + echo "DRY_RUN: would apply ${source_label} to ${TAG}" + echo "BYTES: $(wc -c <"${tmp}" | tr -d ' ')" + if [ "${loaded_from_branch}" = "1" ] && [ "${DELETE_BRANCH}" = "1" ]; then + echo "DRY_RUN: would delete branch ${branch}" + fi + exit 0 +fi + +if ! gh release view "${TAG}" --repo "${REPO}" >/dev/null 2>&1; then + echo "FAIL: release ${TAG} does not exist" >&2 + exit 1 +fi + +echo "DO: gh release edit ${TAG} from ${source_label}" +gh release edit "${TAG}" --repo "${REPO}" --notes-file "${tmp}" +echo "OK: applied notes to ${TAG}" + +if [ "${loaded_from_branch}" = "1" ] && [ "${DELETE_BRANCH}" = "1" ]; then + echo "DO: delete ${branch}" + if gh api -X DELETE "repos/${REPO}/git/refs/heads/${branch}"; then + echo "DONE: deleted ${branch}" + else + echo "WARN: could not delete ${branch}; delete it by hand" >&2 + fi +fi diff --git a/scripts/test_apply_release_notes.py b/scripts/test_apply_release_notes.py new file mode 100755 index 0000000..f4794c6 --- /dev/null +++ b/scripts/test_apply_release_notes.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Tests for scripts/apply-release-notes.sh.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent / "apply-release-notes.sh" + + +def run_script( + env: dict[str, str], cwd: Path | None = None +) -> subprocess.CompletedProcess[str]: + merged = os.environ.copy() + merged.pop("GH_TOKEN", None) + merged.pop("GITHUB_TOKEN", None) + merged.pop("RELEASE_NOTES", None) + merged.pop("RELEASE_NOTES_TAG", None) + merged.update(env) + return subprocess.run( + ["bash", str(SCRIPT)], + capture_output=True, + text=True, + env=merged, + cwd=cwd, + check=False, + ) + + +class ApplyReleaseNotesTests(unittest.TestCase): + def test_rejects_bad_tag(self) -> None: + r = run_script({"TAG": "canact-v0.1.2", "GH_REPO": "patchloom/patchloom-vscode"}) + self.assertNotEqual(r.returncode, 0) + self.assertIn("patchloom-vX.Y.Z", r.stderr) + + def test_accepts_patchloom_tag(self) -> None: + r = run_script( + { + "TAG": "patchloom-v0.6.0", + "GH_REPO": "patchloom/patchloom-vscode", + "DRY_RUN": "1", + } + ) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("leaving auto notes", r.stdout) + + def test_accepts_v_tag(self) -> None: + r = run_script( + { + "TAG": "v0.6.0", + "GH_REPO": "patchloom/patchloom-vscode", + "DRY_RUN": "1", + } + ) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("leaving auto notes", r.stdout) + + def test_notes_file_dry_run(self) -> None: + with tempfile.TemporaryDirectory() as td: + notes = Path(td) / "RELEASE_NOTES.md" + notes.write_text("patchloom 0.6.0 notes\n", encoding="utf-8") + r = run_script( + { + "TAG": "patchloom-v0.6.0", + "GH_REPO": "patchloom/patchloom-vscode", + "DRY_RUN": "1", + "NOTES_FILE": str(notes), + } + ) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("DRY_RUN: would apply file:", r.stdout) + self.assertNotIn("would delete branch", r.stdout) + + def test_empty_file_is_noop(self) -> None: + with tempfile.TemporaryDirectory() as td: + notes = Path(td) / "RELEASE_NOTES.md" + notes.write_text("", encoding="utf-8") + r = run_script( + { + "TAG": "patchloom-v0.6.0", + "GH_REPO": "patchloom/patchloom-vscode", + "DRY_RUN": "1", + "NOTES_FILE": str(notes), + } + ) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("leaving auto notes", r.stdout) + + def test_variable_requires_matching_tag(self) -> None: + r = run_script( + { + "TAG": "patchloom-v0.6.0", + "GH_REPO": "patchloom/patchloom-vscode", + "DRY_RUN": "1", + "RELEASE_NOTES": "hello", + "RELEASE_NOTES_TAG": "0.5.0", + } + ) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("leaving auto notes", r.stdout) + + def test_variable_matches_semver_pin(self) -> None: + r = run_script( + { + "TAG": "patchloom-v0.6.0", + "GH_REPO": "patchloom/patchloom-vscode", + "DRY_RUN": "1", + "RELEASE_NOTES": "hello notes\n", + "RELEASE_NOTES_TAG": "0.6.0", + } + ) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("DRY_RUN: would apply variable", r.stdout) + + def test_legacy_checkout_file(self) -> None: + with tempfile.TemporaryDirectory() as td: + Path(td, "RELEASE_NOTES.md").write_text("legacy notes\n", encoding="utf-8") + r = run_script( + { + "TAG": "patchloom-v0.6.0", + "GH_REPO": "patchloom/patchloom-vscode", + "DRY_RUN": "1", + }, + cwd=Path(td), + ) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("legacy RELEASE_NOTES.md", r.stdout) + self.assertIn("DRY_RUN: would apply file:RELEASE_NOTES.md", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_workflow_triggers.py b/scripts/test_workflow_triggers.py new file mode 100755 index 0000000..ede903e --- /dev/null +++ b/scripts/test_workflow_triggers.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Lock Recipe A, Recipe G stand-ins, and required check names.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" + +REQUIRED_NAMES = ( + "ci", + "npm audit", + "Trivy vulnerability scan", + "Gitleaks secret detection", + "DCO sign-off", + "CodeQL analysis", +) + + +def _on_block(text: str) -> str: + start = text.index("\non:") + rest = text[start + 1 :] + end = rest.index("\njobs:") + block = rest[:end] + lines = [] + for line in block.splitlines(): + stripped = line.split("#", 1)[0].rstrip() + if stripped: + lines.append(stripped) + return "\n".join(lines) + + +class WorkflowTriggerTests(unittest.TestCase): + def test_required_check_names_still_exist(self) -> None: + blob = "\n".join( + p.read_text(encoding="utf-8") for p in WORKFLOWS.glob("*.yml") + ) + for name in REQUIRED_NAMES: + self.assertIn(f"name: {name}", blob, name) + + def test_ci_has_no_push_compile(self) -> None: + on_block = _on_block((WORKFLOWS / "ci.yml").read_text(encoding="utf-8")) + self.assertIn("pull_request:", on_block) + self.assertIn("merge_group:", on_block) + self.assertIn("workflow_dispatch:", on_block) + self.assertNotIn("push:", on_block) + self.assertNotIn("tags:", on_block) + + def test_ci_has_release_please_stand_in(self) -> None: + text = (WORKFLOWS / "ci.yml").read_text(encoding="utf-8") + self.assertIn("startsWith(github.head_ref, 'release-please')", text) + self.assertIn("npx tsc --noEmit", text) + self.assertIn("release-please-check", text) + + def test_security_has_no_push_to_main(self) -> None: + on_block = _on_block((WORKFLOWS / "security.yml").read_text(encoding="utf-8")) + self.assertIn("pull_request:", on_block) + self.assertIn("merge_group:", on_block) + self.assertIn("schedule:", on_block) + self.assertIn("workflow_dispatch:", on_block) + self.assertNotIn("push:", on_block) + + def test_codeql_stand_in_is_quoted(self) -> None: + text = (WORKFLOWS / "security.yml").read_text(encoding="utf-8") + self.assertIn( + "echo 'version-bump PR; CodeQL already ran on the feature PR'", + text, + ) + self.assertNotIn('echo "OK:', text) + + def test_fossa_paths_on_push_and_pr(self) -> None: + text = (WORKFLOWS / "fossa.yml").read_text(encoding="utf-8") + on_block = _on_block(text) + self.assertEqual(on_block.count("package.json"), 2) + self.assertEqual(on_block.count(".github/workflows/fossa.yml"), 2) + self.assertIn("startsWith(github.head_ref, 'release-please')", text) + + def test_post_merge_does_not_dispatch_ci_or_security(self) -> None: + text = (WORKFLOWS / "post-merge.yml").read_text(encoding="utf-8") + self.assertNotIn("gh workflow run ci.yml", text) + self.assertNotIn("gh workflow run security.yml", text) + self.assertIn("gh workflow run scorecard.yml", text) + + def test_release_stays_same_workflow(self) -> None: + text = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") + on_block = _on_block(text) + self.assertIn("push:", on_block) + self.assertIn("branches: [main]", on_block) + self.assertIn("workflow_dispatch:", on_block) + self.assertNotIn("pull_request:", on_block) + self.assertIn("scripts/apply-release-notes.sh", text) + self.assertNotIn("gh workflow run Release", text) + + +if __name__ == "__main__": + unittest.main()