diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6c5049e..2d559bb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,52 @@ +# Managed by netresearch/.github/templates/skill/ +# +# Declares only the ecosystems every skill consumer is guaranteed to have: +# github-actions (CI workflows) and composer (every skill repo ships a +# composer.json for split-licensing / Packagist distribution). +# +# npm and devcontainers are OPT-IN: a repo that actually has package.json +# fixtures (e.g. skills//references/examples/**) or a devcontainer adds +# the block below to its own dependabot.yml and lists `.github/dependabot.yml` +# under `intentional-drift:` in .github/template.yaml so the template sync +# stops managing the file. Declaring an ecosystem without its manifest makes +# the Dependabot run fail with `dependency_file_not_found`. +# +# Opt-in npm (use Dependabot's plural `directories:` to consolidate multiple +# fixture dirs into one grouped PR): +# - package-ecosystem: npm +# directories: +# - /skills//references/examples/ +# schedule: +# interval: weekly +# day: monday +# open-pull-requests-limit: 5 +# groups: +# npm: +# patterns: ['*'] +# cooldown: +# default-days: 7 version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly + day: monday + open-pull-requests-limit: 5 groups: github-actions: - patterns: - - "*" + patterns: ['*'] + cooldown: + default-days: 7 + + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + composer: + patterns: ['*'] + cooldown: + default-days: 7 diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..a388f6b --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,15 @@ +documentation: + - changed-files: + - any-glob-to-any-file: ['**/*.md', 'docs/**/*', '**/SKILL.md', 'README.md'] +ci: + - changed-files: + - any-glob-to-any-file: ['.github/**/*', 'Makefile'] +dependencies: + - changed-files: + - any-glob-to-any-file: ['composer.json', 'composer.lock', 'package.json', 'package-lock.json', 'bun.lock', 'bun.lockb', 'yarn.lock', '.devcontainer/**/*'] +skill: + - changed-files: + - any-glob-to-any-file: ['skills/**/*', '.claude-plugin/**/*', 'plugin.json'] +evals: + - changed-files: + - any-glob-to-any-file: ['**/evals/**/*', '**/eval/**/*', '**/*.eval.yaml', '**/*.eval.yml'] diff --git a/.github/template.yaml b/.github/template.yaml new file mode 100644 index 0000000..2f5bdc5 --- /dev/null +++ b/.github/template.yaml @@ -0,0 +1,11 @@ +# Managed by netresearch/.github/templates/php-module/ +# Drift from the template is blocking CI in this repo (check-template-drift.yml). +# Record explicit exceptions under intentional-drift[] to unblock. +# +# For PHP modules (Magento 2 / Shopware 6 / composer SDKs). CI surface is the +# security/quality reusables with explicit per-call-site permissions (immune to +# a restricted default_workflow_permissions). CodeQL is via GitHub default setup +# (a repo setting), so no codeql.yml here. Add a test workflow per repo as +# needed and list it under intentional-drift. +template: php-module +intentional-drift: [] diff --git a/.github/workflows/auto-merge-deps.yml b/.github/workflows/auto-merge-deps.yml index b14fb2e..6b6c8ad 100644 --- a/.github/workflows/auto-merge-deps.yml +++ b/.github/workflows/auto-merge-deps.yml @@ -1,7 +1,10 @@ name: Auto-merge dependency PRs on: - pull_request: + # auto-merge only calls `gh pr merge` via the reusable with the base-repo + # token; it never checks out or runs PR head code, so pull_request_target + # (required for a write token on Dependabot/Renovate fork PRs) is safe here. + pull_request_target: # zizmor: ignore[dangerous-triggers] permissions: {} diff --git a/.github/workflows/check-template-drift.yml b/.github/workflows/check-template-drift.yml new file mode 100644 index 0000000..d805565 --- /dev/null +++ b/.github/workflows/check-template-drift.yml @@ -0,0 +1,17 @@ +name: Template Drift + +on: + pull_request: + push: + branches: [main, master] + merge_group: + +permissions: {} + +jobs: + drift: + uses: netresearch/.github/.github/workflows/check-template-drift.yml@main + with: + template: php-module + permissions: + contents: read diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..f8a5853 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,17 @@ +name: Labeler + +on: + # labeler only applies labels via the reusable using the base-repo token; it + # never checks out or runs PR head code. pull_request_target is required to + # label fork PRs. + pull_request_target: # zizmor: ignore[dangerous-triggers] + types: [opened, synchronize, reopened] + +permissions: {} + +jobs: + labeler: + uses: netresearch/.github/.github/workflows/labeler.yml@main + permissions: + contents: read + pull-requests: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 505bced..11a604a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,34 +1,22 @@ name: Release -# Triggered by signed-tag push (`v*`). Delegates the entire release to the -# org's `release-composer-package` reusable workflow, which validates the -# tag (semver, annotated/signed), validates `composer.json`, and creates -# the GitHub Release atomically with auto-generated notes. -# -# Packagist syncs the new version via webhook on tag push, independent of -# this workflow. +# Skill release pipeline (composer/npm package publish + Sigstore cosign + +# SHA256SUMS attestation) via the skill-repo-skill reusable. The reusable's +# release job declares contents/id-token/attestations: write; the calling job +# grants exactly that union. id-token + attestations are required for the +# OIDC-backed Sigstore signing and the GitHub native attestation API. on: push: tags: - 'v*' - workflow_dispatch: - inputs: - tag: - description: "Tag to release (e.g. v2.1.0). Used for backfills." - required: true - type: string permissions: {} jobs: release: - uses: netresearch/.github/.github/workflows/release-composer-package.yml@main + uses: netresearch/skill-repo-skill/.github/workflows/release.yml@main permissions: - contents: write - with: - # `inputs.tag` is only populated by workflow_dispatch; on tag-push the - # `inputs` context is empty, so we fall back to `github.ref_name` (the - # pushed tag). This makes the contract explicit at the caller boundary - # instead of relying on the reusable workflow's empty-string fallback. - tag: ${{ inputs.tag || github.ref_name }} + contents: write # release upload + id-token: write # OIDC for sigstore (cosign + attest) + attestations: write # GitHub native attestation API diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..83700db --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,23 @@ +name: OpenSSF Scorecard + +# Supply-chain posture scoring (branch protection, pinned actions, token +# permissions, …). Runs on default-branch push and on a weekly schedule; +# results upload to the code-scanning dashboard. + +on: + push: + branches: [main, master] + schedule: + - cron: '0 0 * * 0' + workflow_dispatch: + +permissions: {} + +jobs: + scorecard: + uses: netresearch/.github/.github/workflows/scorecard.yml@main + permissions: + contents: read + security-events: write + id-token: write + actions: read diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..1271882 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,39 @@ +name: Security + +# Aggregated security scans: secret scanning (gitleaks), dependency review on +# PRs, and a composer audit (every PHP module ships a composer.json). +# +# Top-level `permissions: {}` denies everything by default; each reusable +# caller job re-declares the exact union its reusable's jobs require, so the +# token passed to each reusable is fully explicit and never relies on the +# repo default. This is the same pattern proven in netresearch/.github's +# go-app template (top {} + per-job security-events: write). + +on: + push: + branches: [main, master] + pull_request: + +permissions: {} + +jobs: + gitleaks: + uses: netresearch/.github/.github/workflows/gitleaks.yml@main + permissions: + contents: read + security-events: write + secrets: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + + dependency-review: + if: github.event_name == 'pull_request' + uses: netresearch/.github/.github/workflows/dependency-review.yml@main + permissions: + contents: read + pull-requests: write + + composer-audit: + uses: netresearch/typo3-ci-workflows/.github/workflows/security.yml@main + permissions: + contents: read + security-events: write diff --git a/src/AgentsMdGenerator.php b/src/AgentsMdGenerator.php index 2dcc7c5..919dfc1 100644 --- a/src/AgentsMdGenerator.php +++ b/src/AgentsMdGenerator.php @@ -119,7 +119,8 @@ public function updateAgentsMd(string $agentsMdPath, array $skills): void } if (!rename($tempPath, $agentsMdPath)) { - @unlink($tempPath); + // cleanup of our own freshly-created temp file (random name) on atomic-write rename failure; path is not user input + @unlink($tempPath); // nosemgrep: php.lang.security.unlink-use.unlink-use throw new \RuntimeException(sprintf('Failed to rename temporary file to: %s', $agentsMdPath)); } } diff --git a/src/Lock/SkillLockIo.php b/src/Lock/SkillLockIo.php index 850ac43..4880fd1 100644 --- a/src/Lock/SkillLockIo.php +++ b/src/Lock/SkillLockIo.php @@ -53,7 +53,8 @@ public function write(SkillLockFile $lock): void throw new \RuntimeException(sprintf('Failed writing temporary skills lock: %s', $temp)); } if (!rename($temp, $path)) { - @unlink($temp); + // cleanup of our own freshly-created temp file (random name) on atomic-write rename failure; path is not user input + @unlink($temp); // nosemgrep: php.lang.security.unlink-use.unlink-use throw new \RuntimeException(sprintf('Failed replacing skills lock: %s', $path)); } } diff --git a/src/Trust/TrustStore.php b/src/Trust/TrustStore.php index 028eadf..2ea6080 100644 --- a/src/Trust/TrustStore.php +++ b/src/Trust/TrustStore.php @@ -115,7 +115,8 @@ public function saveAllowSkills(array $rules): void return; } if (!@rename($tempPath, $this->composerJsonPath)) { - @unlink($tempPath); + // cleanup of our own freshly-created temp file (random name) on atomic-write rename failure; path is not user input + @unlink($tempPath); // nosemgrep: php.lang.security.unlink-use.unlink-use $this->io->writeError(sprintf( 'Failed to atomically replace %s with trust decisions.', $this->composerJsonPath, diff --git a/src/Util/ComposerJsonDirectSkillsWriter.php b/src/Util/ComposerJsonDirectSkillsWriter.php index 372cacb..3a975ce 100644 --- a/src/Util/ComposerJsonDirectSkillsWriter.php +++ b/src/Util/ComposerJsonDirectSkillsWriter.php @@ -62,7 +62,8 @@ public function upsertSource(string $composerJsonPath, SourceEntry $entry): void throw new \RuntimeException(sprintf('Failed writing temp composer.json: %s', $temp)); } if (!rename($temp, $composerJsonPath)) { - @unlink($temp); + // cleanup of our own freshly-created temp file (random name) on atomic-write rename failure; path is not user input + @unlink($temp); // nosemgrep: php.lang.security.unlink-use.unlink-use throw new \RuntimeException(sprintf('Failed replacing composer.json: %s', $composerJsonPath)); } } @@ -119,7 +120,8 @@ public function removeSkillOrSource(string $composerJsonPath, string $name, bool throw new \RuntimeException(sprintf('Failed writing temp composer.json: %s', $temp)); } if (!rename($temp, $composerJsonPath)) { - @unlink($temp); + // cleanup of our own freshly-created temp file (random name) on atomic-write rename failure; path is not user input + @unlink($temp); // nosemgrep: php.lang.security.unlink-use.unlink-use throw new \RuntimeException(sprintf('Failed replacing composer.json: %s', $composerJsonPath)); } } diff --git a/src/Util/FilesystemUtil.php b/src/Util/FilesystemUtil.php index eec8185..57f9a00 100644 --- a/src/Util/FilesystemUtil.php +++ b/src/Util/FilesystemUtil.php @@ -64,7 +64,8 @@ public static function removeDirectoryTree(string $dir, ?IOInterface $io = null) if (is_dir($path) && !rmdir($path)) { self::reportFsFailure($io, 'rmdir', $path); } - } elseif (file_exists($path) && !unlink($path)) { + // recursive cleanup of plugin-managed directory contents; path from directory iteration, not user input + } elseif (file_exists($path) && !unlink($path)) { // nosemgrep: php.lang.security.unlink-use.unlink-use self::reportFsFailure($io, 'unlink', $path); } }