diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 17f6f86..1ba9750 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "github-release", - "version": "0.2.1", + "version": "0.3.0", "description": "Create GitHub repositories for Agent Skills and release them right the first time: version gate, deterministic host packages, draft-first publishing, published-asset verification and a per-host install log.", "author": { "name": "Jesse Raber", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 4d599f2..3d2b886 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "github-release", - "version": "0.2.1", + "version": "0.3.0", "description": "Create GitHub repositories for Agent Skills and release them right the first time: version gate, deterministic host packages, draft-first publishing, published-asset verification and a per-host install log.", "skills": "./skills/", "author": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51999e9..680418e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,15 @@ # CI template from the github-release skill. Copy to .github/workflows/ci.yml. # Runs the same checks the tag workflow runs, so problems surface before a tag exists, # and proves that Windows and Ubuntu builds produce identical archives. +# Pushed release branches get CI before a PR exists; workflow_dispatch reruns it on demand. +# Action pins: full commit SHA + the release tag they were resolved from (checked 2026-10-09). name: CI on: push: - branches: [main] + branches: [main, 'release/**', 'v*'] pull_request: + workflow_dispatch: permissions: contents: read @@ -22,26 +25,25 @@ jobs: - {os: ubuntu-22.04, python: '3.8'} # oldest supported Python; drop this leg if the runner image or setup-python retires 3.8 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python }} - name: Tests shell: bash run: if [ -d tests ]; then python -m unittest discover -s tests -v; else echo "no tests directory"; fi - - name: Gate checks (versions agree, notes exist, workflow is draft-first) + - name: Gate checks (versions agree, notes lint, record encoding, workflow is draft-first) shell: bash run: | - SKILL=$(python -c "import json;print(json.load(open('packaging/packages.json'))['skill'])") - VERSION=$(python -c "import re;print(re.search(r'^ version: \"([^\"]+)\"', open('skills/$SKILL/SKILL.md', encoding='utf-8').read(), re.M)[1])") + VERSION=$(python packaging/build_packages.py --describe | python -c 'import json,sys; print(json.load(sys.stdin)["version"])') python packaging/check_versions.py --repo . --config packaging/version_files.json \ - --version "$VERSION" --workflow .github/workflows/release.yml + --version "$VERSION" --previous-from-changelog --workflow .github/workflows/release.yml - name: Candidate build (byte-verified, not published) shell: bash run: python packaging/build_packages.py "$RUNNER_TEMP/candidate" --status "CI candidate, not published" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: sums-${{ matrix.os }}-${{ matrix.python }} path: ${{ runner.temp }}/candidate/SHA256SUMS.txt @@ -49,38 +51,46 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.11' - name: Frontmatter and OpenAI skill validator (pinned) run: | - python -m pip install --disable-pip-version-check PyYAML - SKILL=$(python -c "import json;print(json.load(open('packaging/packages.json'))['skill'])") + printf '%s\n' 'PyYAML==6.0.2 --hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85' > "$RUNNER_TEMP/validation-requirements.txt" + python -m pip install --disable-pip-version-check --only-binary=:all: --require-hashes -r "$RUNNER_TEMP/validation-requirements.txt" + SKILL=$(python packaging/build_packages.py --describe | python -c 'import json,sys; print(json.load(sys.stdin)["skill"])') python - "$SKILL" <<'PY' import sys, yaml + sys.path.insert(0, 'packaging') + from build_packages import frontmatter name = sys.argv[1] - fm = yaml.safe_load(open(f'skills/{name}/SKILL.md', encoding='utf-8').read().split('---')[1]) - assert fm.get('name') == name, fm.get('name') - assert fm.get('description') and len(fm['description']) <= 1024 + fm = yaml.safe_load(frontmatter(open(f'skills/{name}/SKILL.md', 'rb').read())[0]) + if not isinstance(fm, dict) or fm.get('name') != name: + sys.exit('frontmatter name does not match configured skill') + desc = fm.get('description') + if not isinstance(desc, str) or not desc.strip() or len(desc) > 1024: + sys.exit('description missing or over 1024 chars') print('Frontmatter OK') PY curl --fail --silent --show-error --location \ https://raw.githubusercontent.com/openai/skills/49f948faa9258a0c61caceaf225e179651397431/skills/.system/skill-creator/scripts/quick_validate.py \ --output /tmp/openai-quick-validate.py + echo "6cc9dc3199c935916cf6f73fcbbbb0e3bb1b58c8f5109fefa499978908164f51 /tmp/openai-quick-validate.py" | sha256sum --check python /tmp/openai-quick-validate.py "skills/$SKILL" same-bytes: needs: test runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: sums - name: Windows and Ubuntu builds must be byte-identical run: | ls sums diff sums/sums-ubuntu-latest-3.11/SHA256SUMS.txt sums/sums-windows-latest-3.11/SHA256SUMS.txt - echo "Identical archives on Ubuntu and Windows" + diff sums/sums-ubuntu-latest-3.11/SHA256SUMS.txt sums/sums-ubuntu-22.04-3.8/SHA256SUMS.txt + echo "Identical archives across all three OS/Python combinations" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f1d6fd..d18e376 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,8 @@ # Copy to .github/workflows/release.yml. On a pushed tag vX.Y.Z it gates, builds, # and creates a DRAFT release. A person publishes the draft after verifying it. # It never replaces assets of an existing release. -# Action and validator pins are copied from multi-agent-folder-cleanup v1.6.1; -# review them before first use. +# Actions are pinned by full commit SHA with the release tag in a comment (resolved +# 2026-10-09); the validator is pinned by commit. Review pins before first use. name: Release on: @@ -17,20 +17,26 @@ on: required: true permissions: - contents: write + contents: read jobs: - release: + build: + outputs: + version: ${{ steps.ver.outputs.version }} + skill: ${{ steps.ver.outputs.skill }} + artifact-id: ${{ steps.packages.outputs.artifact-id }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # later steps run downloaded code; do not leave the write token in .git - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.11' - name: Install validation dependency - run: python -m pip install --disable-pip-version-check PyYAML + run: | + printf '%s\n' 'PyYAML==6.0.2 --hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85' > "$RUNNER_TEMP/validation-requirements.txt" + python -m pip install --disable-pip-version-check --only-binary=:all: --require-hashes -r "$RUNNER_TEMP/validation-requirements.txt" - name: Resolve version and skill id: ver @@ -39,47 +45,44 @@ jobs: run: | if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then VERSION="$INPUT_VERSION"; else VERSION="${GITHUB_REF_NAME#v}"; fi if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then echo "Refusing non-semver version: $VERSION"; exit 1; fi - SKILL=$(python3 -c "import json;print(json.load(open('packaging/packages.json'))['skill'])") + SKILL=$(python packaging/build_packages.py --describe | python -c 'import json,sys; print(json.load(sys.stdin)["skill"])') echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "skill=$SKILL" >> "$GITHUB_OUTPUT" - - name: Refuse if a release for this tag already exists - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + - name: Versions agree, notes lint (no candidate wording), record encoding env: - GH_TOKEN: ${{ github.token }} - run: | - if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then - echo "A release for $GITHUB_REF_NAME already exists. Published bytes are never replaced; ship a new version." - exit 1 - fi - - - name: Versions agree and notes exist + VERSION: ${{ steps.ver.outputs.version }} run: | python3 packaging/check_versions.py --repo . --config packaging/version_files.json \ - --version "${{ steps.ver.outputs.version }}" --workflow .github/workflows/release.yml + --version "$VERSION" --previous-from-changelog --workflow .github/workflows/release.yml - name: Validate skill frontmatter + env: + SKILL: ${{ steps.ver.outputs.skill }} run: | - python3 - "${{ steps.ver.outputs.skill }}" <<'EOF' + python3 - "$SKILL" <<'EOF' import sys, yaml + sys.path.insert(0, 'packaging') + from build_packages import frontmatter name = sys.argv[1] - text = open(f'skills/{name}/SKILL.md', encoding='utf-8').read() - if not text.startswith('---'): - sys.exit('SKILL.md has no YAML frontmatter') - fm = yaml.safe_load(text.split('---')[1]) - if fm.get('name') != name: - sys.exit(f"frontmatter name {fm.get('name')!r} does not match {name!r}") - if not fm.get('description') or len(fm['description']) > 1024: + fm = yaml.safe_load(frontmatter(open(f'skills/{name}/SKILL.md', 'rb').read())[0]) + if not isinstance(fm, dict) or fm.get('name') != name: + sys.exit('frontmatter name does not match configured skill') + desc = fm.get('description') + if not isinstance(desc, str) or not desc.strip() or len(desc) > 1024: sys.exit('description missing or over 1024 chars') print('Frontmatter OK') EOF - - name: Run OpenAI skill validator (pinned) + - name: Run OpenAI skill validator (commit and SHA-256 pinned) + env: + SKILL: ${{ steps.ver.outputs.skill }} run: | curl --fail --silent --show-error --location \ https://raw.githubusercontent.com/openai/skills/49f948faa9258a0c61caceaf225e179651397431/skills/.system/skill-creator/scripts/quick_validate.py \ --output /tmp/openai-quick-validate.py - python /tmp/openai-quick-validate.py skills/${{ steps.ver.outputs.skill }} + echo "6cc9dc3199c935916cf6f73fcbbbb0e3bb1b58c8f5109fefa499978908164f51 /tmp/openai-quick-validate.py" | sha256sum --check + python /tmp/openai-quick-validate.py "skills/$SKILL" - name: Run tests run: if [ -d tests ]; then python -m unittest discover -s tests -v; else echo "no tests directory"; fi @@ -89,27 +92,57 @@ jobs: set -euo pipefail rm -rf dist python3 packaging/build_packages.py dist --status "release build ${GITHUB_REF_NAME}" - rm -f dist/BUILD_EVIDENCE.json cat dist/SHA256SUMS.txt if find dist -name __pycache__ | grep -q .; then echo "__pycache__ leaked"; exit 1; fi - name: Upload build artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + id: packages + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: packages-${{ steps.ver.outputs.version }} path: dist/ - - name: Create DRAFT release + draft: + needs: build + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.build.outputs.artifact-id }} + path: dist/ + merge-multiple: true + - name: Verify downloaded build artifact + env: + VERSION: ${{ needs.build.outputs.version }} + run: python3 packaging/verify_release.py --assets dist --version "$VERSION" + + - name: Refuse if a release for this tag already exists if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') env: GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + echo "A release for $GITHUB_REF_NAME already exists. Published bytes are never replaced; ship a new version." + exit 1 + fi + + - name: Create DRAFT release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.build.outputs.version }} + SKILL: ${{ needs.build.outputs.skill }} run: | set -euo pipefail - VERSION="${{ steps.ver.outputs.version }}" NOTES="packaging/RELEASE_NOTES_v$VERSION.md" if [ ! -f "$NOTES" ]; then echo "Missing $NOTES; refusing to create a release without reviewed notes."; exit 1; fi gh release create "$GITHUB_REF_NAME" --draft --verify-tag \ - --title "${{ steps.ver.outputs.skill }} v$VERSION" \ + --title "$SKILL v$VERSION" \ --notes-file "$NOTES" \ dist/*.zip dist/SHA256SUMS.txt - echo "Draft created. Verify the downloaded assets (publish.md section 3); only then publish it (publish.md section 4)." + echo "Draft created. Verify the downloaded assets before publishing." diff --git a/CHANGELOG.md b/CHANGELOG.md index 962b916..f8f96d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,42 @@ # Changelog -## 0.2.1 (draft, not released) +## 0.3.0 + +Minor: new gate checks, a notes and record lint, a new helper and changed helper output. + +Release hardening: +- Strict checksum manifests, opt-in missing GitHub digest fallback, candidate evidence/manifest verification and explicit local-evidence handling. +- Quoted-comment-safe literal workflow lint, automatic prior-version checking, build/draft job permission separation, locked validation inputs, retained build evidence and all CI matrix checksum comparisons. +- Builder 1.0.2: path/link containment, regular-file ZIP metadata, anchored frontmatter, fail-closed Opal multiline handling, case-consistent exclusions and Python template compilation. Identical helper smoke checks are reused; no performance percentage is claimed. +- Opal install-version evidence uses its supported body report; builder and skill versions have separate lifecycles. Standalone template drift checks include `.gitattributes`. + +Release process (lessons from the multi-agent-folder-cleanup release history, project rows R-051..R-065): + +- Notes lint in `check_versions.py`: the notes file FAILs on candidate/"not released" status wording, TBD/TODO, template placeholders, empty bullets and pre-written publish dates; WARNs on internal IDs (session UUIDs, tracker rows). HTML comments are ignored. The release workflow runs it before creating the draft. Notes template rewritten to match (R-051, R-060). +- Record lint in `check_versions.py` (and therefore CI): `HOST_INSTALL_LOG.md`, `CHANGELOG.md`, `README.md` and release notes must be UTF-8 without BOM, without NUL bytes, LF only; one install-log header. `--no-records` skips it (R-063). +- `check_versions.py --drift-against `: lists repository copies of helpers, workflows and the install-log header that differ from the skill (gate G14, R-062). +- `verify_release.py`: prints `downloaded N of M listed assets` first; reports release author and title and FAILs a release not created by the workflow or with a title not ending ` v`; `--notes FILE` compares the release body with the notes file and accepts only an appended dated "Known issues" section (R-052, R-059, R-061). +- Gate: version-bump table (patch/minor/major); G0 reads and writes a project's "Release in progress" marker before the first push; G1 checks commit author = owner identity; G1c tag only the release PR's merge commit; G3 notes lint; G3b record lint; G5 test exit code recorded on its own line, `blocked` is not-run with the CI job cited; G13 one-host candidate trial; G14 template drift. Release clones set the owner's git identity; candidates are pushed or bundled before building; synced clones are inspected with `--no-optional-locks` and bridge writes are re-hashed (R-053, R-054, R-057, R-058, R-062, R-064, R-065). +- Publish: approval block states "Immutable releases: ON/OFF" and asks once when OFF; cloud refusals (merge, tag push, publish, branch delete, settings) are expected, from two sources (proxy/API and the session safety classifier) — one combined owner command block; asset download iterates the release JSON and asserts the count; post-publish body edits are append-only; hand-created releases forbidden; web-UI uploads fall under the blob-SHA check (R-052, R-054, R-055, R-056, R-059, R-061, R-062). +- Record: candidate trial counts as the host install when its hash equals the published asset; host replies saved verbatim as evidence files; rows appended with an explicit UTF-8 method and read back; Claude Code directory-marketplace source is the load path; never judge install age by mtime (R-046, R-048, R-057, R-063). +- Host matrix and check prompt: Gemini chat (rejects skills with scripts) vs Gemini Spark (loads them) — the Gemini package targets Spark; install channels and expected load paths for Claude app, ChatGPT web, Grok, Copilot; T3 falls back to the loaded line where frontmatter is hidden; T4 counts a helper version only from shown output (R-042..R-045). +- CI template runs on pushed `release/**` and `v*` branches and on `workflow_dispatch`; actions pinned to current majors by commit SHA: checkout v7.0.1, setup-python v7.0.0, upload-artifact v7.0.2, download-artifact v8.0.2 (R-017, R-047). + +Install backups can no longer shadow a new install (R-066; evidence: multi-agent-folder-cleanup 1.7.1 into Codex standalone, 2026-10-09 — all 22 files matched the release and helpers reported 1.7.1, but Codex loaded 1.7.0 from a backup inside `.codex/skills/_backups/`): + +- Rule: backups go beside a host's skills folder, never inside it; otherwise rename the backup's `SKILL.md` to `SKILL.backup-not-loaded.md`. All hosts. New hard rule 8 in SKILL.md. +- Install procedure (verify-and-record.md): required duplicate-name check after every install — exactly one `SKILL.md` with the skill's name, at the new version; more than one is a failed install. Owner restarts the host before the in-host check. +- Record: new `Host load` field — `confirmed in host` only after an in-host check (skill view, "reveal in folder", arrival line); otherwise `files verified; host load not checked`. Install-log template gains the column; check prompt T2 fails on a backup path. +- host-matrix.md: scanned folders and backup locations per host (Codex, Antigravity, Claude Code). +- New helper `scripts/check_skill_duplicates.py` (read-only, standard library) with regression tests, including a backup inside the scanned folder. + +## 0.2.1 - Gemini Apps package: leave out files without an extension. Gemini's skill uploader rejected 0.2.0 ("The skill folder contains a file with an unsupported file type") because of `templates/gitattributes`; the same package without it was accepted (2026-10-08). - Builder 1.0.1: new `exclude_no_suffix` key in `packages.json`, plus a host invariant that refuses extensionless files in `gemini-apps-only` regardless of config. Regression test added. - host-matrix, packaging and INSTALL docs record what Gemini accepted (`.py`, `.json`, `.md`, `.txt`) and the open question of whether scripts run there. -## 0.2.0 (draft, not released) +## 0.2.0 Portfolio and install lessons (R-023..R-035, R-038..R-040; evidence in the project's 2026-10-08 scan and install records): @@ -17,7 +47,7 @@ Portfolio and install lessons (R-023..R-035, R-038..R-040; evidence in the proje - Loaded line now names the SKILL.md path, so checks can spot shadow copies. - Repo setup: auto-delete head branches and social-preview/Website fields as defaults; `safe.directory` note for tool-owned clones. -## 0.1.0 (draft, not released) +## 0.1.0 - First version: modes Setup, Prepare, Gate, Publish, Verify, Record. - Draft-first publishing; never replace published bytes; separate owner approvals for each GitHub action. diff --git a/HOST_INSTALL_LOG.md b/HOST_INSTALL_LOG.md index dda23a9..cacc11a 100644 --- a/HOST_INSTALL_LOG.md +++ b/HOST_INSTALL_LOG.md @@ -19,3 +19,8 @@ Evidence: `observed` (agent saw it), `user-reported`, or a path to a screenshot | 2026-10-08 23:48-23:50 EDT | 0.2.0 (published) | github-release-0.2.0-claude-code-plugin.zip (4c1a598e1bdf) | Claude Code / owner PC, user scope | Claude Code plugin via directory marketplace `jesseraber-github-release`, source moved to `%USERPROFILE%\.claude\local-marketplaces\github-release` (outside OneDrive; R-041) | Re-point marketplace: hash-verified ZIP extracted to new path, marketplace remove/add, reinstall, file compare, fresh-session load | loaded-smoke-pass; supersedes the 23:31 row's source path; marketplace source 28/28 and cache 0.2.0 28/28 vs ZIP, 0 missing, 0 extra | `Loaded GitHub Release v0.2.0 (SKILL.md at C:\Users\JesseRaber\.claude\local-marketplaces\github-release\skills\github-release\SKILL.md)` | observed; `claude -p` fresh session; session dc8fe785 T005 | Claude session dc8fe785-5fdc-4d28-8476-654133a6caf2 | | 2026-10-08 ~23:5x EDT | 0.2.0 (published) | github-release-0.2.0-gemini-apps-only.zip (published asset) | Gemini Apps / owner account | Gemini skill upload (UI) | Upload | rejected: "The skill folder contains a file with an unsupported file type. Please remove it and try again." | none | user-reported error text | owner; diagnosed by Claude session 01J7MzndUA7hmG1BaegLPWa3 | | 2026-10-09 ~00:00 EDT | 0.2.0 test build (not a release asset) | test-A-no-gitattributes.zip (71495d265da0) = 0.2.0 Gemini ZIP minus templates/gitattributes | Gemini Apps / owner account | Gemini skill upload (UI) | Upload | accepted; left installed; behavioral/script-run check not done; to be superseded by the 0.2.1 Gemini asset | not reported | user-reported | owner; test ZIP built by Claude session 01J7MzndUA7hmG1BaegLPWa3 | + +## From 0.3.0 (rows with Host load column, per verify-and-record.md) + +| Date (TZ) | Version | Asset (sha256 first 12) | Host / account | Channel | Action | Result | Displayed | Evidence | Host load | By | +|---|---|---|---|---|---|---|---|---|---|---| diff --git a/README.md b/README.md index 8aa0d72..c0ee4c0 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,14 @@ An Agent Skill that teaches AI agents to set up skill repositories and release t | Claude Code `/plugin install` | `…-claude-code-plugin.zip` | | Codex / ChatGPT plugin | `…-codex-chatgpt-plugin.zip` | | Microsoft Copilot agent, Grok | `…-microsoft-copilot-agent-only.zip` | -| Gemini Apps | `…-gemini-apps-only.zip` | +| Gemini Spark (not regular Gemini chat, which rejects skills with scripts) | `…-gemini-apps-only.zip` | | Opal | `…-opal-only.zip` | ## The flow it enforces ```mermaid flowchart LR - P[Prepare\ncandidate + checks] --> G[Gate\nG0-G12, read-only] + P[Prepare\ncandidate + checks] --> G[Gate\nG0-G14, read-only] G -->|owner approves tag| T[Tag pushed\nworkflow builds DRAFT] T --> V[Verify draft\nbytes == candidate] V -->|owner approves publish| R[Published\nimmutable] diff --git a/packaging/INSTALL-claude-code.md b/packaging/INSTALL-claude-code.md index beb85e4..6f53f73 100644 --- a/packaging/INSTALL-claude-code.md +++ b/packaging/INSTALL-claude-code.md @@ -10,3 +10,5 @@ Extract the outer `github-release/` folder to a permanent location, then in Clau ``` Or add the GitHub repository as a marketplace once it exists. + +When replacing an older copy, keep the backup outside every folder the app scans for skills (or rename its `SKILL.md` to `SKILL.backup-not-loaded.md`), then restart the app before checking which version it loaded. diff --git a/packaging/INSTALL-codex-chatgpt.md b/packaging/INSTALL-codex-chatgpt.md index 38198f6..3110538 100644 --- a/packaging/INSTALL-codex-chatgpt.md +++ b/packaging/INSTALL-codex-chatgpt.md @@ -3,3 +3,5 @@ For a standalone skill upload use `-UNIVERSAL-skill.zip`. This archive is a skills-only OpenAI plugin: `.codex-plugin/plugin.json` plus `skills/github-release/`. Extract the outer `github-release/` folder to a permanent location and add it to a local plugin marketplace with Plugin Creator in ChatGPT Work mode or Codex. + +When replacing an older copy, keep the backup outside every folder the app scans for skills (or rename its `SKILL.md` to `SKILL.backup-not-loaded.md`), then restart the app before checking which version it loaded. diff --git a/packaging/INSTALL-microsoft-copilot.md b/packaging/INSTALL-microsoft-copilot.md index ff1b799..c6d251f 100644 --- a/packaging/INSTALL-microsoft-copilot.md +++ b/packaging/INSTALL-microsoft-copilot.md @@ -6,4 +6,4 @@ This package has no PowerShell files. The Python helpers need only the standard A successful upload proves only package acceptance. Test with a read-only request such as "Is this repository ready to tag?". -Expected loaded version: **GitHub Release v0.2.1**. +Expected loaded version: **GitHub Release v0.3.0**. diff --git a/packaging/INSTALL-universal.md b/packaging/INSTALL-universal.md index a624c12..80cbeb7 100644 --- a/packaging/INSTALL-universal.md +++ b/packaging/INSTALL-universal.md @@ -6,13 +6,21 @@ | Claude Code `/plugin install` (not the Claude app uploader) | `…-claude-code-plugin.zip` | | Codex / ChatGPT plugin | `…-codex-chatgpt-plugin.zip` | | Microsoft Copilot agent skill upload, **Grok** | `…-microsoft-copilot-agent-only.zip` | -| Gemini Apps skill upload | `…-gemini-apps-only.zip` | +| Gemini Spark skill upload (regular Gemini chat rejects skills with scripts) | `…-gemini-apps-only.zip` | | Opal skill import | `…-opal-only.zip` | The folder `github-release/` **is** the skill. Keep `SKILL.md`, `references/`, `scripts/` and `templates/` together. Host-specific packages leave some files out: Gemini Apps omits the `.yml` workflow templates (precaution) and `templates/gitattributes` (Gemini rejects files without an extension); Opal keeps only `SKILL.md` and `references/*.md`, so the helper scripts and templates are not available there. +## Updating an installed copy + +Many apps (Codex observed) load **every** `SKILL.md` anywhere under their skills folder. Never keep the old copy inside that folder: move it beside it (for example `~/.codex/skill-backups/`), or rename its `SKILL.md` to `SKILL.backup-not-loaded.md`. Then check that only one copy carries the name and restart the app: + +```bash +python github-release/scripts/check_skill_duplicates.py ~/.codex/skills --name github-release --expect-version 0.3.0 +``` + ## Claude app Settings -> Capabilities -> Skills -> Upload skill, with the Universal ZIP. @@ -25,11 +33,11 @@ Point the system prompt or context loader at `github-release/SKILL.md`. > Is my skill repository ready to tag v1.0.0? -A loaded skill states **Mode: Gate** first and reports **Loaded GitHub Release v0.2.1 (SKILL.md at )**. +A loaded skill states **Mode: Gate** first and reports **Loaded GitHub Release v0.3.0 (SKILL.md at )**. ## Requirements -Python 3.8+, standard library only, for `scripts/check_versions.py` and `scripts/verify_release.py`. Both are read-only and need no network. +Python 3.8+, standard library only, for `scripts/check_versions.py`, `scripts/verify_release.py` and `scripts/check_skill_duplicates.py`. All are read-only and need no network. ## Verify what you downloaded diff --git a/packaging/RELEASE_NOTES_v0.3.0.md b/packaging/RELEASE_NOTES_v0.3.0.md new file mode 100644 index 0000000..1867fe4 --- /dev/null +++ b/packaging/RELEASE_NOTES_v0.3.0.md @@ -0,0 +1,58 @@ +# github-release 0.3.0 + +Minor release. It turns lessons from a real skill's release history into gate checks and helper output, and fixes install backups that could shadow a new install. + +## Release verification and packaging + +- Checksum verification rejects empty, malformed and duplicate manifests and unexpected files. Missing GitHub digests require an explicit option; local archive hashes are still checked. The verifier also checks the saved build evidence and checksum manifest when comparing the local build. +- Workflow lint preserves quoted comment characters and refuses unsupported release mutation commands. CI compares checksums across all three configured OS/Python combinations. +- Build validation uses read permissions, locked dependency hashes and a SHA-256-checked upstream validator. A separate tag-only job creates the draft and preserves build evidence in the downloadable workflow artifact. +- Packaging refuses linked or escaping source and archive paths, writes regular-file ZIP metadata, checks Python templates and rejects unsupported multiline Opal descriptions without losing text. Case-insensitive exclusions agree with host checks. + +## Changes + +**Installs** + +- Backups can no longer shadow a new install. Backups go beside a host's skills folder, never inside it. A required duplicate-name check runs after every install, using the new helper `check_skill_duplicates.py`. The install log gains a `Host load` column: `confirmed in host` only after a check inside the restarted host. +- A trial install of the release archive counts as the host install when its hash equals the published asset. +- Host replies are saved word for word as evidence files. Install-log rows are appended with an explicit UTF-8 write and read back. + +**Gate and helpers** + +- `check_versions.py` lints the release notes. It fails on leftover status wording that says the release is unfinished, on placeholders, empty bullets and publish dates written ahead of time. It warns on internal IDs. The release workflow runs it before it creates the draft. +- `check_versions.py` also lints record files (install log, changelog, README, notes): UTF-8 without BOM, no NUL bytes, LF line endings. +- `check_versions.py --drift-against` (given the skill folder) lists repository copies of helpers and workflows that no longer match the skill. +- `verify_release.py` reports `downloaded N of M listed assets` first. It fails a release not created by the workflow or with the wrong title. `--notes` compares the release body with the notes file; the only allowed later edit is an appended, dated "Known issues" section. +- New gate rules: a patch/minor/major bump table; the project's "release in progress" marker is written before the first push; release commits use the owner's identity; the tag goes only on the release PR's merge commit; the test exit code is recorded on its own line; a one-host trial of the exact release archive; a template drift check. + +**Publishing** + +- Every publish approval states whether immutable releases are on, and asks once when they are off. +- Merge, tag push, publish, branch delete and settings are expected to be refused from cloud agent sessions. The skill hands the owner one combined command block. +- Asset downloads follow the release's own asset list and check the count. Releases created by hand are not allowed. + +**Hosts and CI** + +- Gemini: regular Gemini chat rejects skills that contain scripts; Gemini Spark loads them. The Gemini package targets Spark. +- Install channels and expected load paths are documented for Claude app, ChatGPT web, Grok and Microsoft Copilot. The install check falls back to the loaded line where a host hides the frontmatter, and counts a helper version only from output it shows. +- The CI template also runs on pushed release branches and on manual dispatch. GitHub Actions are pinned to current major versions by commit SHA. + +## Packages + +| Archive | For | +|---|---| +| `github-release-0.3.0-UNIVERSAL-skill.zip` | Claude app upload, ChatGPT/Codex standalone skills, Antigravity, local models, any skills folder | +| `github-release-0.3.0-claude-code-plugin.zip` | Claude Code `/plugin install` | +| `github-release-0.3.0-codex-chatgpt-plugin.zip` | Codex / ChatGPT plugin | +| `github-release-0.3.0-microsoft-copilot-agent-only.zip` | Microsoft Copilot agent upload, Grok | +| `github-release-0.3.0-gemini-apps-only.zip` | Gemini Spark (regular Gemini chat rejects skills with scripts) | +| `github-release-0.3.0-opal-only.zip` | Opal import | + +Verify downloads with `sha256sum -c SHA256SUMS.txt`. + +## Publication boundary + +Installed host copies are updated separately; this release does not change any host by itself. + +--- +Built by [Jesse Raber](https://jesseraber.net) · [YouTube](https://www.youtube.com/@Jesse_Raber) diff --git a/packaging/build_packages.py b/packaging/build_packages.py index 63ef4a2..6ace422 100644 --- a/packaging/build_packages.py +++ b/packaging/build_packages.py @@ -18,14 +18,14 @@ import argparse import hashlib import json -from pathlib import Path +from pathlib import Path, PurePosixPath import re import subprocess import sys import tempfile import zipfile -__version__ = '1.0.1' +__version__ = '1.0.2' STAMP = (2026, 1, 1, 0, 0, 0) REPO = Path(__file__).resolve().parents[1] OPAL_SIZE_WARN = 48 * 1024 # observed Opal per-file rejection ~48-79 KB (approximate, 2026-10-03) @@ -61,20 +61,58 @@ def read_config(path): return cfg +def safe_relative(rel): + if (not isinstance(rel, str) or not rel or '\\' in rel or ':' in rel + or rel.startswith('/') or any(p in ('', '.', '..') for p in rel.rstrip('/').split('/'))): + raise ValueError('Unsafe relative path: %r' % rel) + return rel + + +def repo_path(rel): + """Refuse traversal and linked ancestors, including Windows junctions.""" + safe_relative(rel) + path = REPO + for part in PurePosixPath(rel).parts: + path = path / part + if path.is_symlink() or (hasattr(path, 'is_junction') and path.is_junction()): + raise ValueError('Refusing linked repo path: %s' % path) + if path.exists() and getattr(path.lstat(), 'st_file_attributes', 0) & 0x400: + raise ValueError('Refusing reparse point: %s' % path) + try: + path.resolve().relative_to(REPO.resolve()) + except ValueError: + raise ValueError('Refusing external repo path: %s' % rel) + return path + + +def frontmatter(data): + text = data.decode('utf-8').replace('\r\n', '\n') + lines = text.splitlines(keepends=True) + if not lines or lines[0] != '---\n': + raise ValueError('SKILL.md needs line-delimited YAML frontmatter') + end = next((i for i in range(1, len(lines)) if lines[i].rstrip('\n') == '---'), None) + if end is None: + raise ValueError('SKILL.md missing closing frontmatter delimiter') + return ''.join(lines[1:end]), ''.join(lines[end + 1:]) + + def skill_version(skill_dir, name): - text = (skill_dir / 'SKILL.md').read_text(encoding='utf-8') - m = re.search(r'^name:\s*(\S+)\s*$', text, re.M) - if not m or m.group(1) != name: + text, _ = frontmatter((skill_dir / 'SKILL.md').read_bytes()) + names = re.findall(r'^name:[ \t]*(\S+)[ \t]*$', text, re.M) + if names != [name]: raise SystemExit('SKILL.md name does not match packages.json skill: %r' % name) - m = re.search(r'^ version: "([^"]+)"', text, re.M) - if not m: + metadata = re.search(r'^metadata:[ \t]*\n((?:[ \t]+[^\n]*\n|\n)+)', text + '\n', re.M) + versions = re.findall(r'^ version: "(\d+\.\d+\.\d+)"[ \t]*$', metadata.group(1), re.M) if metadata else [] + if len(versions) != 1: raise SystemExit('SKILL.md needs metadata.version as: version: "X.Y.Z"') - return m.group(1) + return versions[0] def source_files(skill_dir): + repo_path(skill_dir.relative_to(REPO).as_posix()) files = {} for p in sorted(skill_dir.rglob('*')): + repo_path(p.relative_to(REPO).as_posix()) if '__pycache__' in p.parts or p.suffix == '.pyc' or not p.is_file(): continue if p.is_symlink(): @@ -85,36 +123,61 @@ def source_files(skill_dir): def excluded(rel, spec): return (rel.startswith(tuple(spec.get('exclude_prefix', []))) - or rel.endswith(tuple(spec.get('exclude_suffix', []))) + or rel.lower().endswith(tuple(s.lower() for s in spec.get('exclude_suffix', []))) or rel in spec.get('exclude_files', []) - or (spec.get('exclude_no_suffix') and '.' not in rel.rsplit('/', 1)[-1])) + or (spec.get('exclude_no_suffix') and not PurePosixPath(rel).suffix)) def opal_skill_md(data): """Opal accepts only name and description in the frontmatter, LF endings.""" - text = data.decode('utf-8').replace('\r\n', '\n') - _, front, body = text.split('---\n', 2) + front, body = frontmatter(data) keep = [line for line in front.splitlines() if line.startswith(('name:', 'description:'))] - if len(keep) != 2: + if len(keep) != 2 or not any(line.startswith('name:') for line in keep) or not any(line.startswith('description:') for line in keep): raise ValueError('SKILL.md frontmatter needs single-line name and description') for line in keep: value = line.split(':', 1)[1].strip() if not value or value[0] in '>|': raise ValueError('Opal needs single-line, non-empty name and description: %r' % line) + if value[0] in "\"'": + quote, i, closed = value[0], 1, False + while i < len(value): + if quote == '"' and value[i] == '\\': + i += 2 + continue + if value[i] == quote: + if quote == "'" and value[i:i + 2] == "''": + i += 2 + continue + tail = value[i + 1:].strip() + closed = not tail or tail.startswith('#') + break + i += 1 + if not closed: + raise ValueError('Opal requires a complete single-line quoted scalar: %r' % line) + active = None + for line in front.splitlines(): + if not line.strip() or line.lstrip().startswith('#'): + continue + if not line[0].isspace(): + active = line.split(':', 1)[0] + elif active in ('name', 'description'): + raise ValueError('Opal requires single-line %s; multiline continuation refused' % active) return ('---\n' + '\n'.join(keep) + '\n---\n' + body).encode('utf-8') def build_one(spec, files, name): root = name + '/' layout = spec['layout'] - repo = lambda rel: (REPO / rel).read_bytes() + safe_relative(name) + repo = lambda rel: repo_path(rel).read_bytes() kept = {n: d for n, d in files.items() if not excluded(n, spec)} if layout == 'skill': items = {root + n: d for n, d in kept.items()} elif layout == 'plugin': items = {root + 'skills/' + name + '/' + n: d for n, d in kept.items()} meta = spec['metadata'] - for p in sorted((REPO / meta).glob('*.json')): + for p in sorted(repo_path(meta).glob('*.json')): + repo_path(p.relative_to(REPO).as_posix()) items[root + meta + '/' + p.name] = p.read_bytes() elif layout == 'opal': items = {root + 'SKILL.md': opal_skill_md(files['SKILL.md'])} @@ -123,15 +186,20 @@ def build_one(spec, files, name): items[root + n] = d.replace(b'\r\n', b'\n') elif layout == 'subtree': src, dst = spec['from'], spec['to'] + safe_relative(src) + safe_relative(dst) items = {dst + n[len(src):]: d for n, d in kept.items() if n.startswith(src)} else: raise SystemExit('unknown layout ' + layout) for target, source in spec.get('add', {}).items(): + safe_relative(target) items[root + target] = repo(source) return items, spec.get('folder_entries', layout != 'opal') def write_zip(archive, items, folder_entries): + for name in items: + safe_relative(name) names = sorted(items) dirs = set() if folder_entries: @@ -149,12 +217,13 @@ def write_zip(archive, items, folder_entries): info = zipfile.ZipInfo(n, date_time=STAMP) info.create_system = 3 info.compress_type = zipfile.ZIP_DEFLATED - info.external_attr = 0o644 << 16 + info.external_attr = 0o100644 << 16 z.writestr(info, items[n]) return dirs -def verify(archive, flavor, spec, items, dirs, version, smoke, warnings, smoked): +def verify(archive, flavor, spec, items, dirs, version, smoke, warnings, smoked, checked=None): + checked = set() if checked is None else checked with zipfile.ZipFile(archive) as z: check(z.testzip() is None) listed = z.namelist() @@ -168,7 +237,7 @@ def verify(archive, flavor, spec, items, dirs, version, smoke, warnings, smoked) bad = [n for n in files_only if n.lower().endswith(banned)] check(not bad, ('host invariant: %s must not contain %s' % (flavor, banned), bad)) if flavor in HOST_NO_EXTENSIONLESS: - bad = [n for n in files_only if '.' not in n.rsplit('/', 1)[-1]] + bad = [n for n in files_only if not PurePosixPath(n).suffix] check(not bad, ('host invariant: %s must not contain files without an extension' % flavor, bad)) if spec['layout'] == 'opal': check(all(n.endswith('.md') for n in files_only)) @@ -180,36 +249,54 @@ def verify(archive, flavor, spec, items, dirs, version, smoke, warnings, smoked) warnings.append('%s: %s is %d bytes; Opal rejected files above ~48-79 KB (observed)' % (archive.name, n, len(z.read(n)))) return - scripts = [n for n in listed if '/scripts/' in n and n.endswith('.py')] + scripts = [n for n in files_only if n.endswith('.py')] + for n in scripts: + key = ('compile', digest(z.read(n))) + if key not in checked: + compile(z.read(n), n, 'exec') + checked.add(key) + scripts = [n for n in scripts if '/scripts/' in n and PurePosixPath(n).name in smoke] + for n in scripts: + if ('smoke', PurePosixPath(n).name, digest(z.read(n))) in checked: + smoked.add(PurePosixPath(n).name) + scripts = [n for n in scripts if ('smoke', PurePosixPath(n).name, digest(z.read(n))) not in checked] if not scripts: return with tempfile.TemporaryDirectory(prefix='skill-package-') as tmp: z.extractall(tmp) # members were constructed above, not accepted from input for n in scripts: path = Path(tmp) / n - subprocess.run([sys.executable, '-m', 'py_compile', str(path)], check=True) - if path.name in smoke: + if '/scripts/' in n and path.name in smoke: smoked.add(path.name) out = subprocess.run([sys.executable, str(path), '--version'], capture_output=True, text=True, check=True) check(out.stdout.strip().endswith(version), (n, out.stdout)) + checked.add(('smoke', path.name, digest(z.read(n)))) def main(): parser = argparse.ArgumentParser() - parser.add_argument('output') + parser.add_argument('output', nargs='?') + parser.add_argument('--describe', action='store_true', help='print skill name and version without building') parser.add_argument('--config', default=str(REPO / 'packaging/packages.json')) parser.add_argument('--status', default='local candidate, not published') parser.add_argument('--version', action='version', version='build_packages ' + __version__) args = parser.parse_args() cfg = read_config(Path(args.config)) name = cfg['skill'] - skill_dir = REPO / 'skills' / name + if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', name): + raise SystemExit('Invalid skill name') + skill_dir = repo_path('skills/' + name) version = skill_version(skill_dir, name) + if args.describe: + print(json.dumps({'skill': name, 'version': version})) + return + if not args.output: + parser.error('output directory required unless --describe is used') for spec in cfg['packages'].values(): meta = spec.get('metadata') if meta: - got = json.loads((REPO / meta / 'plugin.json').read_text(encoding='utf-8'))['version'] + got = json.loads(repo_path(meta + '/plugin.json').read_text(encoding='utf-8'))['version'] check(got == version, (meta, got, version)) files = source_files(skill_dir) output = Path(args.output).resolve() @@ -217,6 +304,7 @@ def main(): warnings = [] smoke = set(cfg.get('smoke_version_scripts', [])) smoked = set() + checked = set() evidence = {'skill': name, 'version': version, 'status': args.status, 'source_files': {n: digest(b) for n, b in files.items()}, 'packages': {}} unknown = set(cfg['packages']) - KNOWN_PACKAGES - set(cfg.get('extra_packages', [])) @@ -227,7 +315,7 @@ def main(): items, folder_entries = build_one(spec, files, name) archive = output / ('%s-%s-%s.zip' % (name, version, flavor)) dirs = write_zip(archive, items, folder_entries) - verify(archive, flavor, spec, items, dirs, version, smoke, warnings, smoked) + verify(archive, flavor, spec, items, dirs, version, smoke, warnings, smoked, checked) evidence['packages'][archive.name] = {'sha256': digest(archive.read_bytes()), 'files': len(items), 'byte_verified': True} missing = smoke - smoked diff --git a/packaging/check_versions.py b/packaging/check_versions.py index 0ade3e6..880406f 100644 --- a/packaging/check_versions.py +++ b/packaging/check_versions.py @@ -3,12 +3,16 @@ Checks that every version touch point names the new version, lists leftover mentions of the previous version outside history files, confirms the release -notes file exists, and lints the release workflow for draft-first publishing. +notes file exists and reads as a released note (no candidate wording, no +placeholders, no pre-written dates, no internal IDs), checks record files for +encoding damage, lints the release workflow for draft-first publishing, and +optionally lists repository copies that drifted from the skill's templates. Usage: python check_versions.py --repo . --version 1.2.0 [--previous 1.1.0] [--config packaging/version_files.json] [--workflow .github/workflows/release.yml] + [--drift-against path/to/skills/github-release] Exit 0 when every check passes, 1 when any fails, 2 on usage errors. Standard library only; no network; never writes to the repository. @@ -20,13 +24,41 @@ import os from pathlib import Path import re +import shlex import sys -__version__ = '0.2.1' +__version__ = '0.3.0' SEMVER = re.compile(r'^\d+\.\d+\.\d+$') TEXT_SUFFIXES = {'.md', '.py', '.json', '.yml', '.yaml', '.txt', '.ps1', '.toml', '.cfg'} SKIP_DIRS = {'.git', '__pycache__', 'node_modules', 'dist'} DEFAULT_HISTORY = ['CHANGELOG.md', 'packaging/RELEASE_NOTES_v*.md', 'HOST_INSTALL_LOG.md'] +DEFAULT_RECORDS = ['HOST_INSTALL_LOG.md', 'CHANGELOG.md', 'README.md', 'packaging/RELEASE_NOTES_v*.md'] +# Wording that must never reach a published release body (observed on immutable releases). +NOTES_FORBIDDEN = [ + (r'\(candidate\)|status:\s*(local |release )?candidate|\bcandidate[,;]? not\b', 'candidate status wording'), + (r'\bnot (yet )?released\b', '"not released" wording'), + (r'\bnot (yet )?(committed|published)\b', '"not committed/published" wording'), + (r'\bTBD\b|\bTODO\b', 'TBD/TODO'), + (r'verification required before release', 'pre-release checklist'), + (r'SKILL-NAME|X\.Y\.Z|YYYY-MM-DD|<[a-z][a-z -]*>', 'template placeholder'), + (r'^\s*-\s*$', 'empty bullet'), + (r'\breleased (on )?\d{4}-\d{2}-\d{2}', 'pre-written publish date (GitHub shows published_at)'), +] +NOTES_WARN = [ + (r'\bcandidate\b', 'the word "candidate" (fine when it describes a feature, never as release status)'), + (r'\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', 'session UUID (internal ID)'), + (r'\b[RWL]-?\d{3}\b', 'register/tracker row ID (internal ID)'), +] +# Repository copy -> skill path, for --drift-against. +DRIFT_PAIRS = [ + ('packaging/check_versions.py', 'scripts/check_versions.py'), + ('packaging/verify_release.py', 'scripts/verify_release.py'), + ('packaging/check_skill_duplicates.py', 'scripts/check_skill_duplicates.py'), + ('packaging/build_packages.py', 'templates/build_packages.py'), + ('.github/workflows/release.yml', 'templates/release.yml'), + ('.github/workflows/ci.yml', 'templates/ci.yml'), + ('.gitattributes', 'templates/gitattributes'), +] def safe_stdout(): @@ -67,10 +99,96 @@ def check_notes(repo, version, results): rel = 'packaging/RELEASE_NOTES_v%s.md' % version if (repo / rel).is_file(): results.append(('PASS', 'release notes present: %s' % rel)) + lint_notes(repo / rel, rel, results) else: results.append(('FAIL', 'release notes missing: %s (the workflow will refuse to publish)' % rel)) +def lint_notes(path, rel, results): + """The notes file becomes the public release body; it must read as released text.""" + text = path.read_text(encoding='utf-8', errors='replace') + # HTML comments are template guidance, not public text; blank them but keep line numbers. + text = re.sub(r'', lambda m: '\n' * m.group(0).count('\n'), text, flags=re.S) + lines = text.splitlines() + bad = 0 + for i, line in enumerate(lines, 1): + for pat, label in NOTES_FORBIDDEN: + if re.search(pat, line, re.I if label != 'TBD/TODO' else 0): + results.append(('FAIL', 'notes %s:%d %s: %s' % (rel, i, label, line.strip()[:100]))) + bad += 1 + for pat, label in NOTES_WARN: + if re.search(pat, line): + results.append(('WARN', 'notes %s:%d %s; keep it in the PR body: %s' % (rel, i, label, line.strip()[:100]))) + if not bad: + results.append(('PASS', 'notes body lint: no candidate wording, placeholders or pre-written dates')) + + +def check_records(repo, patterns, results): + """Record files must be UTF-8 without BOM, no NUL bytes, LF line endings.""" + seen = set() + for pat in patterns: + for p in sorted(repo.glob(pat)): + if not p.is_file() or p in seen: + continue + seen.add(p) + rel = p.relative_to(repo).as_posix() + data = p.read_bytes() + problems = [] + if data.startswith(b'\xef\xbb\xbf'): + problems.append('UTF-8 BOM') + if data[:2] in (b'\xff\xfe', b'\xfe\xff') or b'\x00' in data: + problems.append('%d NUL byte(s) (UTF-16 write?)' % data.count(b'\x00')) + if b'\r\n' in data: + problems.append('CRLF line endings') + try: + data.decode('utf-8') + except UnicodeDecodeError as e: + problems.append('not UTF-8 (byte %d)' % e.start) + if p.name == 'HOST_INSTALL_LOG.md': + text = data.decode('utf-8', errors='replace') + heads = [l.strip() for l in text.splitlines() if re.match(r'^\|\s*Date\b', l)] + if not heads: + problems.append('no table header row') + elif len(heads) != len(set(heads)): + # A new column set may start a new appended table; the same header twice means a rewrite. + problems.append('repeated identical table header row') + if problems: + results.append(('FAIL', 'record %s: %s' % (rel, '; '.join(problems)))) + else: + results.append(('PASS', 'record %s: UTF-8, no BOM, no NUL, LF' % rel)) + + +def check_drift(repo, skill_dir, results): + import hashlib + skill = Path(skill_dir) + if not (skill / 'SKILL.md').is_file(): + results.append(('FAIL', 'drift: no SKILL.md in %s' % skill)) + return + stale = 0 + for repo_rel, skill_rel in DRIFT_PAIRS: + a, b = repo / repo_rel, skill / skill_rel + if not a.is_file(): + continue # repository does not carry this copy + if not b.is_file(): + results.append(('WARN', 'drift: skill has no %s to compare with %s' % (skill_rel, repo_rel))) + continue + same = hashlib.sha256(a.read_bytes()).digest() == hashlib.sha256(b.read_bytes()).digest() + if not same: + stale += 1 + results.append(('PASS' if same else 'FAIL', 'drift: %s %s skill %s' % (repo_rel, '==' if same else 'differs from', skill_rel))) + log, tmpl = repo / 'HOST_INSTALL_LOG.md', skill / 'templates' / 'HOST_INSTALL_LOG.md' + if log.is_file() and tmpl.is_file(): + def header(p): + heads = [l.strip() for l in p.read_text(encoding='utf-8', errors='replace').splitlines() + if re.match(r'^\|\s*Date\b', l)] + return heads[-1] if heads else None # the table new rows are appended to + same = header(log) == header(tmpl) + if not same: + stale += 1 + results.append(('PASS' if same else 'FAIL', 'drift: HOST_INSTALL_LOG.md current (last) table header %s template' % ('matches' if same else 'differs from'))) + results.append(('INFO', 'drift: %d stale cop%s' % (stale, 'y' if stale == 1 else 'ies'))) + + def is_history(rel, patterns): return any(fnmatch.fnmatch(rel, pat) for pat in patterns) @@ -95,7 +213,7 @@ def _scan_previous(base, label, pat, history, hits): def check_previous(repo, cfg, previous, results, extra_roots=()): - pat = re.compile(r'(? 1 else None + + + +def workflow_scripts(text): + """Read literal run blocks supported by these templates, without parsing YAML.""" + scripts, active, indent = [], None, 0 + for line in text.splitlines(): + if active is not None: + if not line.strip() or len(line) - len(line.lstrip()) > indent: + active.append(line.strip()) + continue + scripts.append('\n'.join(active)) + active = None + match = re.match(r'^(\s*)(?:-\s*)?run:\s*(.*)$', line) + if match: + value = match.group(2).strip() + if value in ('|', '|-', '|+'): + active, indent = [], len(match.group(1)) + elif value.startswith('>'): + raise ValueError('folded run blocks are outside the supported workflow lint syntax') + else: + scripts.append(value) + if active is not None: + scripts.append('\n'.join(active)) + # Shell-only fixtures are supported for callers checking an individual script. + return scripts or [text] + + +def shell_words(script): + """shlex preserves quoted #; shell comments start only at a word boundary.""" + script = re.sub(r'\\\r?\n', ' ', script) + words = [] + for line in script.splitlines(): + # shlex's comment character applies inside unquoted words too, unlike Bash. + # Disable it and strip boundary comments with quote/escape awareness first. + quote, escaped, end = None, False, len(line) + for i, char in enumerate(line): + if escaped: + escaped = False + continue + if char == '\\' and quote != "'": + escaped = True + continue + if quote: + if char == quote: + quote = None + elif char in "\"'": + quote = char + elif char == '#' and (i == 0 or line[i - 1].isspace() or line[i - 1] in ';|&('): + end = i + break + lexer = shlex.shlex(line[:end], posix=True, punctuation_chars=';&|()') + lexer.whitespace_split = True + lexer.commenters = '' + words.extend(list(lexer) + [';']) + return words + def check_workflow(path, results): p = Path(path) @@ -119,22 +299,42 @@ def check_workflow(path, results): results.append(('FAIL', 'workflow not found: %s' % path)) return text = p.read_text(encoding='utf-8', errors='replace') - lines = [line.split('#', 1)[0] for line in text.splitlines()] - code = '\n'.join(lines) - # Join shell continuations so a multi-line `gh release create` is judged as one command. - joined = re.sub(r'\\\s*\n', ' ', code) - creates = [l for l in joined.splitlines() if 'gh release create' in l] - drafts_ok = bool(creates) and all(re.search(r'--draft(?![=\w-])', l) for l in creates) - no_undraft = not re.search(r'--draft[= ]false', code) + try: + words = [word for script in workflow_scripts(text) for word in shell_words(script)] + except ValueError as e: + results.append(('FAIL', 'workflow unsupported shell/run syntax: %s' % e)) + return + creates, commands, api = [], [], False + for i, word in enumerate(words): + if word != 'gh' or i + 1 >= len(words): + continue + if words[i + 1] == 'api': + api = True + if words[i + 1] != 'release' or i + 2 >= len(words): + continue + command = words[i + 2] + commands.append(command) + args = [] + for token in words[i + 3:]: + if token in (';', '&&', '||', '|', ')'): + break + args.append(token) + if command == 'create': + creates.append(args) + drafts_ok = bool(creates) and all(any(flag in args for flag in ('--draft', '--draft=true', '-d')) for args in creates) + no_undraft = not any(word in ('--draft=false', '-d=false') or (word in ('--draft', '-d') and words[i + 1:i + 2] == ['false']) + for i, word in enumerate(words)) + supported = not api and all(cmd in ('create', 'view', 'list') for cmd in commands) rules = [ (drafts_ok, 'every `gh release create` uses --draft (%d found)' % len(creates)), (no_undraft, 'never publishes from the workflow (no --draft=false)'), - ('--clobber' not in code, 'never replaces existing assets (no --clobber)'), - ('--generate-notes' not in code, 'uses the reviewed notes file (no --generate-notes)'), - ('release view' in code, 'checks for an existing release (refusal itself not proven by lint; read the step)'), + (not any(w == '--clobber' or w.startswith('--clobber=') for w in words) and supported, 'never replaces existing assets (no --clobber; only create/view/list, no gh api)'), + (not any(w == '--generate-notes' or w.startswith('--generate-notes=') for w in words), 'uses the reviewed notes file (no --generate-notes)'), + ('view' in commands, 'checks for an existing release (refusal itself not proven by lint; read the step)'), ] for ok, label in rules: results.append(('PASS' if ok else 'FAIL', 'workflow ' + label)) + results.append(('INFO', 'workflow lint checks literal gh commands in supported run syntax; it is not a sandbox or proof against dynamic shell code')) def main(argv=None): @@ -143,10 +343,15 @@ def main(argv=None): ap.add_argument('--repo', default='.') ap.add_argument('--version', dest='new_version') ap.add_argument('--previous') + ap.add_argument('--previous-from-changelog', action='store_true', + help='infer the preceding release from CHANGELOG; enabled in CI/release templates') ap.add_argument('--extra-root', action='append', default=[], help='also scan this directory (e.g. a wiki clone) for previous-version mentions; repeatable') ap.add_argument('--config') ap.add_argument('--workflow') + ap.add_argument('--drift-against', metavar='SKILL_DIR', + help='compare repository copies of helpers, workflows and the install-log header with this skill folder (gate G14)') + ap.add_argument('--no-records', action='store_true', help='skip the record-file encoding lint') ap.add_argument('-V', '--tool-version', action='store_true', help='print helper version') if argv is None: argv = sys.argv[1:] @@ -166,12 +371,25 @@ def main(argv=None): repo = Path(args.repo).resolve() cfg = load_config(repo, args.config) results = [] + if args.previous_from_changelog: + try: + inferred = previous_from_changelog(repo, args.new_version) + if args.previous and args.previous != inferred: + raise ValueError('--previous disagrees with CHANGELOG') + args.previous = inferred + results.append(('INFO', 'previous version from CHANGELOG: %s' % (inferred or 'first release'))) + except (OSError, UnicodeError, ValueError) as e: + results.append(('FAIL', 'previous-version inference: %s' % e)) if not cfg['files']: results.append(('FAIL', 'no touch points configured (packaging/version_files.json)')) check_touch_points(repo, cfg, args.new_version, results) check_notes(repo, args.new_version, results) if args.previous: check_previous(repo, cfg, args.previous, results, args.extra_root) + if not args.no_records: + check_records(repo, cfg.get('records', DEFAULT_RECORDS), results) + if args.drift_against: + check_drift(repo, args.drift_against, results) if args.workflow: check_workflow(repo / args.workflow if not Path(args.workflow).is_absolute() else args.workflow, results) failed = sum(1 for status, _ in results if status == 'FAIL') diff --git a/packaging/packages.json b/packaging/packages.json index 51475b8..dac65bd 100644 --- a/packaging/packages.json +++ b/packaging/packages.json @@ -1,6 +1,6 @@ { "skill": "github-release", - "smoke_version_scripts": ["check_versions.py", "verify_release.py"], + "smoke_version_scripts": ["check_versions.py", "verify_release.py", "check_skill_duplicates.py"], "packages": { "UNIVERSAL-skill": {"layout": "skill", "add": {"LICENSE.txt": "LICENSE", "INSTALL.md": "packaging/INSTALL-universal.md"}}, "claude-code-plugin": {"layout": "plugin", "metadata": ".claude-plugin", "add": {"LICENSE": "LICENSE", "README.md": "README.md", "CHANGELOG.md": "CHANGELOG.md", "INSTALL.md": "packaging/INSTALL-claude-code.md"}}, diff --git a/packaging/verify_release.py b/packaging/verify_release.py index 4a4845b..d58d647 100644 --- a/packaging/verify_release.py +++ b/packaging/verify_release.py @@ -8,6 +8,8 @@ Usage: python verify_release.py --assets DIR [--version X.Y.Z] [--release-json FILE] [--candidate DIR] + [--notes packaging/RELEASE_NOTES_vX.Y.Z.md] + [--expected-author github-actions[bot]] Exit 0 when every requested check passed, 1 when any failed, 2 on usage errors. """ @@ -16,11 +18,15 @@ import io import json from pathlib import Path +import re import sys import zipfile -__version__ = '0.2.1' +__version__ = '0.3.0' SUMS = 'SHA256SUMS.txt' +WORKFLOW_AUTHOR = 'github-actions[bot]' +LOCAL_EVIDENCE = 'BUILD_EVIDENCE.json' +KNOWN_ISSUES = re.compile(r'^#{2,3} Known issues \(added \d{4}-\d{2}-\d{2}', re.I) def safe_stdout(): @@ -38,11 +44,20 @@ def sha256(path): def read_sums(path): rows = {} - for line in path.read_text(encoding='utf-8-sig').splitlines(): + for number, line in enumerate(path.read_text(encoding='utf-8-sig').splitlines(), 1): if not line.strip(): continue - digest, name = line.split(None, 1) - rows[name.lstrip('*').strip()] = digest.lower() + match = re.fullmatch(r'([0-9a-fA-F]{64})\s+\*?([^\s/\\:]+)', line.strip()) + if not match: + raise ValueError('malformed checksum line %d' % number) + digest, name = match.groups() + if name in ('.', '..', SUMS, LOCAL_EVIDENCE) or name in rows: + raise ValueError('duplicate or reserved checksum filename: %s' % name) + rows[name] = digest.lower() + if not rows: + raise ValueError('empty checksum manifest') + if not any(name.endswith('.zip') for name in rows): + raise ValueError('checksum manifest contains no release archives') return rows @@ -51,7 +66,15 @@ def check_sums(assets, out): if not sums_path.is_file(): out.append(('FAIL', 'no %s in %s' % (SUMS, assets))) return {} - listed = read_sums(sums_path) + try: + listed = read_sums(sums_path) + except (OSError, UnicodeError, ValueError) as e: + out.append(('FAIL', '%s: %s' % (SUMS, e))) + return {} + links = [p.name for p in assets.iterdir() if p.is_symlink()] + if sums_path.is_symlink() or links: + out.append(('FAIL', 'linked assets are not allowed: %s' % links)) + return {} present = {p.name: p for p in assets.iterdir() if p.is_file() and p.name != SUMS} actual = {} for name, want in sorted(listed.items()): @@ -63,11 +86,28 @@ def check_sums(assets, out): out.append(('PASS' if got == want else 'FAIL', '%s sha256 %s %s' % (name, got[:12], 'matches SHA256SUMS' if got == want else '!= ' + want[:12]))) for name in sorted(set(present) - set(listed)): - if name.endswith('.zip'): - out.append(('FAIL', 'archive not listed in SHA256SUMS: %s' % name)) + if name != LOCAL_EVIDENCE: + out.append(('FAIL', 'file not listed in SHA256SUMS: %s' % name)) + evidence = assets / LOCAL_EVIDENCE + if evidence.exists(): + check_evidence(evidence, listed, out) return actual +def check_evidence(path, listed, out): + """Local evidence is not published; if supplied it must describe these ZIPs.""" + try: + evidence = json.loads(path.read_text(encoding='utf-8')) + packages = evidence['packages'] + expected = {n: d for n, d in listed.items() if n.endswith('.zip')} + got = {n: item['sha256'] for n, item in packages.items()} + if got != expected: + raise ValueError('package set or hashes differ from SHA256SUMS') + out.append(('PASS', '%s agrees with checksum manifest' % path.name)) + except (OSError, UnicodeError, ValueError, KeyError, TypeError, AttributeError) as e: + out.append(('FAIL', '%s: invalid build evidence: %s' % (path.name, e))) + + def check_zips(assets, actual, version, out): for name in sorted(actual): if not name.endswith('.zip'): @@ -84,12 +124,75 @@ def check_zips(assets, actual, version, out): out.append(('FAIL', '%s is not a valid zip: %s' % (name, e))) -def check_release_json(path, assets, version, out, expect_published=False, expect_immutable=False): - data = json.loads(Path(path).read_text(encoding='utf-8-sig')) +def load_release(path, out): + try: + data = json.loads(Path(path).read_text(encoding='utf-8-sig')) + except (OSError, UnicodeError, ValueError) as e: + out.append(('FAIL', 'invalid release JSON: %s' % e)) + return None if not isinstance(data, dict): out.append(('FAIL', 'release JSON must be ONE release object, not a %s; select it with ' "--jq '.[] | select(.tag_name==\"vX.Y.Z\")'" % type(data).__name__)) + return None + entries = data.get('assets') + if not isinstance(data.get('author') or {}, dict) or not isinstance(data.get('name') or '', str): + out.append(('FAIL', 'invalid release author/title metadata')) + return None + if not isinstance(entries, list) or not entries: + out.append(('FAIL', 'release JSON has no asset list')) + return None + names = set() + for entry in entries: + name = entry.get('name') if isinstance(entry, dict) else None + if (not isinstance(name, str) or not re.fullmatch(r'[^\s/\\:]+', name) + or name in ('.', '..') or name in names + or not isinstance(entry.get('digest') or '', str)): + out.append(('FAIL', 'invalid or duplicate release asset metadata')) + return None + names.add(name) + return data + + +def count_downloads(assets, data, out): + """Report first, so an incomplete download is visible before any per-file FAIL.""" + listed = [a.get('name') for a in data.get('assets', [])] + got = sum(1 for n in listed if (assets / n).is_file()) + out.append(('INFO' if got == len(listed) else 'FAIL', + 'downloaded %d of %d listed assets' % (got, len(listed)))) + + +def check_identity(data, version, expected_author, out): + author = (data.get('author') or {}).get('login') + title = data.get('name') + out.append(('INFO', 'release author=%s title=%r' % (author, title))) + if author != expected_author: + out.append(('FAIL', 'release created by %s, not %s: hand-created releases are not allowed ' + '(publish.md Never)' % (author, expected_author))) + if version and not (title or '').endswith(' v' + version): + out.append(('FAIL', 'release title %r does not end with " v%s" (workflow title is " v")' % (title, version))) + + +def _norm(text): + return '\n'.join(l.rstrip() for l in text.replace('\r\n', '\n').strip().split('\n')) + + +def check_body(data, notes_path, out): + body = _norm(data.get('body') or '') + notes = _norm(Path(notes_path).read_text(encoding='utf-8-sig')) + if body == notes: + out.append(('PASS', 'release body == %s' % Path(notes_path).name)) return + if body.startswith(notes): + extra = body[len(notes):].lstrip('\n') + if KNOWN_ISSUES.match(extra): + out.append(('PASS', 'release body == notes file + appended dated Known issues section (publish.md section 5)')) + return + out.append(('FAIL', 'release body differs from %s (only an appended "## Known issues (added YYYY-MM-DD)" ' + 'section is allowed after publish)' % Path(notes_path).name)) + + +def check_release_json(data, assets, version, out, expect_published=False, expect_immutable=False, + allow_missing_digest=False): tag = data.get('tag_name') if version and tag != 'v' + version: out.append(('FAIL', 'release tag %r is not v%s' % (tag, version))) @@ -100,7 +203,7 @@ def check_release_json(path, assets, version, out, expect_published=False, expec if expect_immutable and data.get('immutable') is not True: out.append(('FAIL', 'release is not immutable')) gh = {a['name']: a for a in data.get('assets', [])} - local = {p.name for p in assets.iterdir() if p.is_file()} + local = {p.name for p in assets.iterdir() if p.is_file() and p.name != LOCAL_EVIDENCE} for name in sorted(set(gh) - local): out.append(('FAIL', 'on GitHub but not downloaded: %s' % name)) for name in sorted(set(gh) & local): @@ -109,6 +212,9 @@ def check_release_json(path, assets, version, out, expect_published=False, expec if a.get('state') not in (None, 'uploaded'): out.append(('FAIL', '%s state is %s' % (name, a.get('state')))) if not d.startswith('sha256:'): + if not allow_missing_digest: + out.append(('FAIL', '%s: GitHub returned no sha256 digest' % name)) + continue out.append(('WARN', '%s: GitHub returned no sha256 digest; size-only check' % name)) ok = a.get('size') == (assets / name).stat().st_size out.append(('PASS' if ok else 'FAIL', '%s size %s' % (name, a.get('size')))) @@ -122,6 +228,20 @@ def check_release_json(path, assets, version, out, expect_published=False, expec def check_candidate(candidate, assets, actual, out): cand = Path(candidate) + if not cand.is_dir(): + out.append(('FAIL', 'candidate folder not found: %s' % cand)) + return + candidate_out = [] + check_sums(cand, candidate_out) + if not (cand / LOCAL_EVIDENCE).is_file(): + candidate_out.append(('FAIL', 'missing candidate %s' % LOCAL_EVIDENCE)) + out.extend((status, 'candidate: ' + msg) for status, msg in candidate_out) + try: + released = read_sums(assets / SUMS) + expected = read_sums(cand / SUMS) + out.append(('PASS' if released == expected else 'FAIL', 'candidate checksum manifest matches release')) + except (OSError, UnicodeError, ValueError) as e: + out.append(('FAIL', 'candidate checksum comparison: %s' % e)) names = sorted(n for n in actual if n.endswith('.zip')) cand_zips = {p.name for p in cand.glob('*.zip')} for name in names: @@ -141,8 +261,13 @@ def main(argv=None): ap.add_argument('--version', dest='release_version') ap.add_argument('--release-json') ap.add_argument('--candidate') + ap.add_argument('--allow-missing-github-digest', action='store_true', + help='explicitly accept size-only GitHub checks; local checksums still required') ap.add_argument('--expect-published', action='store_true', help='FAIL if the release is a draft') ap.add_argument('--expect-immutable', action='store_true', help='FAIL if the release is not immutable') + ap.add_argument('--notes', help='release notes file the body must equal (needs --release-json)') + ap.add_argument('--expected-author', default=WORKFLOW_AUTHOR, + help='login that must have created the release (default %s)' % WORKFLOW_AUTHOR) if argv is None: argv = sys.argv[1:] if argv == ['--version']: @@ -157,13 +282,29 @@ def main(argv=None): print('assets folder not found: %s' % assets) return 2 out = [] + data = None + if args.release_json: + data = load_release(args.release_json, out) + if data is not None: + count_downloads(assets, data, out) + if args.notes and not args.release_json: + print('--notes needs --release-json') + return 2 + if (args.expect_published or args.expect_immutable or args.allow_missing_github_digest) and not args.release_json: + print('release state/digest options need --release-json') + return 2 actual = check_sums(assets, out) check_zips(assets, actual, args.release_version, out) ran = ['checksums', 'zip integrity'] - if args.release_json: - check_release_json(args.release_json, assets, args.release_version, out, - args.expect_published, args.expect_immutable) + if data is not None: + check_release_json(data, assets, args.release_version, out, + args.expect_published, args.expect_immutable, args.allow_missing_github_digest) + check_identity(data, args.release_version, args.expected_author, out) ran.append('GitHub metadata') + ran.append('author/title') + if args.notes: + check_body(data, args.notes, out) + ran.append('notes body') if args.candidate: check_candidate(args.candidate, assets, actual, out) ran.append('local candidate') diff --git a/packaging/version_files.json b/packaging/version_files.json index b510988..96a07bd 100644 --- a/packaging/version_files.json +++ b/packaging/version_files.json @@ -16,6 +16,10 @@ "path": "skills/github-release/scripts/verify_release.py", "pattern": "__version__ = '{v}'" }, + { + "path": "skills/github-release/scripts/check_skill_duplicates.py", + "pattern": "__version__ = '{v}'" + }, { "path": "packaging/check_versions.py", "pattern": "__version__ = '{v}'" diff --git a/skills/github-release/SKILL.md b/skills/github-release/SKILL.md index f868dbf..65f174d 100644 --- a/skills/github-release/SKILL.md +++ b/skills/github-release/SKILL.md @@ -3,7 +3,7 @@ name: github-release description: Create GitHub repositories for Agent Skills and release them right the first time - version agreement, a pre-tag gate, deterministic host-specific packages (Claude app, Claude Code, Codex/ChatGPT, Microsoft Copilot, Grok, Gemini Apps, Opal, Antigravity, local models), draft-first publishing, verification of published assets against the local build, and a dated per-host install log. Use whenever an agent prepares, tags, publishes, re-packages or verifies a skill release, sets up a skill repository or release workflow, decides which ZIP a host needs, or records that a skill was uploaded or installed somewhere. license: MIT metadata: - version: "0.2.1" + version: "0.3.0" repository: https://github.com/JesseRaber/github-release --- @@ -11,7 +11,7 @@ metadata: A release is right the first time when the bytes you tested are the bytes people download, every host gets a package it accepts, and the record says which host has which version and how anyone knows. -Report as: **Loaded GitHub Release v0.2.1 (SKILL.md at )**. State the mode in the first line. The path lets install checks detect shadow copies and stale channels. +Report as: **Loaded GitHub Release v0.3.0 (SKILL.md at )**. State the mode in the first line. The path lets install checks detect shadow copies and stale channels. ## Choose the mode @@ -31,10 +31,12 @@ For "which ZIP do I give host X?" read [host-matrix.md](references/host-matrix.m 1. **Separate approvals.** Creating a repository, pushing, merging, tagging, creating or publishing a release, changing repository settings and installing into a host are each separate owner approvals. A request to review, build or prepare authorizes none of them. Ask immediately before the action, showing exactly what will happen. 2. **A pushed tag starts a release build.** Gate first, tag second. Never tag a commit whose local candidate build and gate have not passed. 3. **Draft first.** The workflow creates a draft. Verify the draft's downloaded assets against the local candidate, then the owner publishes. A failed draft is deleted and rebuilt; nothing public has to change. -4. **Never replace published bytes.** No `--clobber`, no re-upload, no force-moved tag after publication. A defect in a published release ships as a new patch version. Prefer GitHub immutable releases. +4. **Never replace published bytes.** No `--clobber`, no re-upload, no force-moved tag after publication, no hand-created release. A defect in a published release ships as a new patch version; the published body only gains an appended, dated "Known issues" section. Prefer GitHub immutable releases; every publish approval states whether they are ON or OFF. 5. **Cite the tag, not `main`.** Evidence about a release comes from the tagged commit and the published assets. 6. **Never disguise code to pass a host scan.** Omit the file from that host's package and document the omission. 7. **Upload acceptance is not behavior.** Record what was observed: accepted, loaded, smoke-tested. Each row needs a date, version, asset hash and evidence type. +8. **The owner authors the release; the tag sits on a reviewed merge.** Commits in a release clone use the owner's identity (AI only as a `Co-Authored-By` trailer). Tag only the merge commit of the merged release PR. Notes read as released text: no candidate wording, placeholders, pre-written dates or internal IDs. +9. **Backups never shadow installs.** Never keep a backup inside a folder a host scans for skills; put it beside that folder, or rename its `SKILL.md` to `SKILL.backup-not-loaded.md`. After every install run `scripts/check_skill_duplicates.py` over all of the host's skill folders: exactly one `SKILL.md` may carry the skill's name, at the new version — more than one is a failed install. Tell the owner to restart the host. Matching bytes and `--version` prove the files, not the load: log `files verified; host load not checked` until a check inside the host confirms it (verify-and-record.md). ## Keep evidence honest @@ -45,4 +47,4 @@ For "which ZIP do I give host X?" read [host-matrix.md](references/host-matrix.m ## Helpers -`scripts/check_versions.py` (version agreement, stale versions, workflow safety lint) and `scripts/verify_release.py` (published assets vs checksums, GitHub digests, release state and the local candidate) are read-only, standard-library Python 3.8+, and need no network. Both accept `--version`. Skill repositories keep copies in `packaging/` (see repo-setup.md). Templates for the builder, workflow and records are in `templates/`; some host packages omit them (see host-matrix.md). +`scripts/check_versions.py` (version agreement, stale versions, workflow safety lint), `scripts/verify_release.py` (published assets vs checksums, GitHub digests, release state and the local candidate) and `scripts/check_skill_duplicates.py` (duplicate skill names under host skill folders, after an install) are read-only, standard-library Python 3.8+, and need no network. All accept `--version`. Skill repositories keep copies in `packaging/` (see repo-setup.md). Templates for the builder, workflow and records are in `templates/`; some host packages omit them (see host-matrix.md). diff --git a/skills/github-release/references/host-matrix.md b/skills/github-release/references/host-matrix.md index 5e9de1d..83685b3 100644 --- a/skills/github-release/references/host-matrix.md +++ b/skills/github-release/references/host-matrix.md @@ -14,7 +14,7 @@ Reference: `multi-agent-folder-cleanup` v1.6.1. On 2026-10-08 the author rebuilt | `-claude-code-plugin` | Claude Code `/plugin install` only | `.claude-plugin/*.json` + `skills//…` + README, CHANGELOG, LICENSE, INSTALL | — | repo; the Claude app uploader rejects plugin files (repo) | 2026-10-06 | | `-codex-chatgpt-plugin` | Codex / ChatGPT plugin | `.codex-plugin/*.json` + `skills//…` + README, CHANGELOG, LICENSE, INSTALL | — | repo; OpenAI validators pinned in CI | 2026-10-06 | | `-microsoft-copilot-agent-only` | Microsoft Copilot agent upload; **Grok** | universal minus PowerShell | `*.ps1` | Copilot: repo (validator rejects `.ps1`). Grok: observed 2026-10-05 in an owner upload test (Universal rejected for `.ps1`, this ZIP accepted) — documented as | 2026-10-08 | -| `-gemini-apps-only` | Gemini Apps upload | skill minus listed omissions | `*.ps1`, `*.yaml`, `*.yml` (precaution, untested), `agents/`, nested `references/*/`, scripts the security scan rejects (folder-cleanup: `audit_folder.py`, credential-hint read guards) | observed: 1.4.1 rejection of `audit_folder.py` (repo CHANGELOG); 1.6.1 package **accepted**, owner-reported 2026-10-08 — it contains a `ctypes`/kernel32 script, so ctypes alone is not rejected (if that was the published asset) | 2026-10-08 | +| `-gemini-apps-only` | **Gemini Spark** skill upload (regular Gemini chat rejects any skill with scripts — see host notes) | skill minus listed omissions | `*.ps1`, `*.yaml`, `*.yml` (precaution, untested), `agents/`, nested `references/*/`, scripts the security scan rejects (folder-cleanup: `audit_folder.py`, credential-hint read guards) | observed: 1.4.1 rejection of `audit_folder.py` (repo CHANGELOG); 1.6.1 package **accepted**, owner-reported 2026-10-08 — it contains a `ctypes`/kernel32 script, so ctypes alone is not rejected (if that was the published asset) | 2026-10-08 | | `-opal-only` | Opal import | `SKILL.md` with only `name` + `description` frontmatter; top-level `references/*.md`; LF endings; **no folder entries** | everything else | observed: confirmed by import (repo CHANGELOG 1.5.0); per-file size rejection above ~48–79 KB observed 2026-10-03, approximate | 2026-10-06 | | `-project-rules-optional` | add-on, any host | only if the skill ships optional add-on material | — | repo | 2026-10-06 | @@ -25,9 +25,27 @@ A host can have several install channels that drift to different versions indepe | Host | Channels | |---|---| | Claude | app skill upload (Settings → Capabilities → Skills); Claude Code plugin (`/plugin install` or marketplace) | -| Codex / ChatGPT | standalone skills dir (`~/.codex/skills/`); Personal Plugin (manual "Upload new version" in the ChatGPT UI — no API); desktop marketplace (cache `~/.codex/cache/remote_plugin_catalog/*.json` is stale evidence) | -| Antigravity | `~/.gemini/config/skills/` from the repository | -| Gemini Apps, Copilot, Grok, Opal | single upload channel each (UI) | +| Codex / ChatGPT | standalone skills dir (`~/.codex/skills/`; **every subfolder is scanned** for `SKILL.md` — backups go in `~/.codex/skill-backups/`, observed 2026-10-09); Personal Plugin (manual "Upload new version" in the ChatGPT UI — no API); desktop marketplace (cache `~/.codex/cache/remote_plugin_catalog/*.json` is stale evidence) | +| Antigravity | `~/.gemini/config/skills/` from the repository; backups in `~/.gemini/config/skills_backups/` beside it (not scanned; observed unaffected 2026-10-09) | +| Claude app | skill upload in Settings; skills synced to the account — a Claude cloud session can read the synced copy directly, which is the in-host load evidence for T2 | +| ChatGPT web | remote skill store; the loaded path shows as `skill://…/remote-skills//SKILL.md` (observed 2026-10-08) | +| Grok | server-side skills, loaded from `/root/.grok/server-skills//` (observed 2026-10-08) | +| Microsoft Copilot | agent upload; loaded from an `appCatalog/…` path (observed 2026-10-08) | +| Gemini | **two surfaces**: regular Gemini chat (rejects skills containing scripts) and Gemini Spark (loads them) — observed 2026-10-08 | +| Opal | single import channel (UI) | + +Expected load paths are what T2 of `templates/HOST_INSTALL_CHECK_PROMPT.md` compares against; a different path is a shadow copy or a stale channel until shown otherwise. + +### Backups and scanned folders (all hosts) + +Treat every folder a host loads skills from as scanned **recursively** unless the host's documentation says otherwise with a date. A copy of a skill anywhere inside it — backup, staging, extracted ZIP — can load as a second skill with the same name and win (observed 2026-10-09: Codex loaded `.codex/skills/_backups/multi-agent-folder-cleanup.pre-v1.7.1-` (1.7.0) instead of the new 1.7.1 install; "reveal in folder" pointed at the backup; the same pattern was seen on other skills from earlier installs that day). Rules: backups live beside the skills folder; if that is impossible, rename the backup's `SKILL.md` to `SKILL.backup-not-loaded.md`; after every install run `scripts/check_skill_duplicates.py` over all of the host's skill folders (verify-and-record.md step 5); restart the host before the in-host load check. + +| Host | Scanned skill folder(s) | Backup location | +|---|---|---| +| Codex standalone | `~/.codex/skills/` (all subfolders) | `~/.codex/skill-backups/` | +| Antigravity | `~/.gemini/config/skills/` | `~/.gemini/config/skills_backups/` | +| Claude Code | `~/.claude/skills/`, project `.claude/skills/`, plugin marketplace source dirs and plugin cache | a sibling folder outside each (e.g. `~/.claude/skill-backups/`); inferred, not host-tested | +| Others with a skills folder | the folder the host documents | a sibling outside it | ## Host notes @@ -36,6 +54,7 @@ A host can have several install channels that drift to different versions indepe - **Codex / ChatGPT**: plugin via Plugin Creator; standalone skill via the Universal ZIP. - **Microsoft Copilot**: two distinct surfaces — **Agent Builder** (Configure → Skills, takes the agent ZIP) and **chat attachment** (observed 2026-10-07 rejecting the ZIP but accepting Markdown files attached directly). The documented Agent Builder layout is root-level `SKILL.md`, while this package nests it under `/`; whether Agent Builder requires the root layout is an **open question** (pending test, ai-prompting W-015) — do not change the package until that test runs. The sandbox has no direct network; packaged scripts must not need it (repo). - **Grok**: sandbox is POSIX; OneDrive/SharePoint hydration cannot be checked there (repo). +- **Gemini chat vs Gemini Spark** (observed 2026-10-08, folder-cleanup v1.6.3 install round): regular Gemini chat refused a skill that contains any script file; Gemini Spark loaded the same package. github-release's Gemini package carries three `.py` helpers, so it targets **Spark only**; for Gemini chat, no package is offered — use another host, and never strip or rename scripts to get past the check. A scripts-free chat variant is not built. - **Gemini Apps**: the skill uploader rejects a folder containing a file without an extension ("The skill folder contains a file with an unsupported file type", observed 2026-10-08 on `templates/gitattributes`); the same package without that file was accepted, including `.py`, `.json`, `.md` and `.txt`. Whether packaged `.py` scripts can run there is untested. Never rename or obfuscate code to pass the scan; omit and document. Add-on files must keep an extension (`LICENSE.txt`, not `LICENSE`). - **Opal**: references must be flat for the skill to work there; keep essential instructions in `SKILL.md` and top-level `references/`. - **Antigravity**: installs from the GitHub repository (repo-documented; untested here). diff --git a/skills/github-release/references/packaging.md b/skills/github-release/references/packaging.md index 1ae8ee2..7a68bec 100644 --- a/skills/github-release/references/packaging.md +++ b/skills/github-release/references/packaging.md @@ -2,14 +2,14 @@ ## The builder -Copy `templates/build_packages.py` to `packaging/build_packages.py` and `templates/packages.json` to `packaging/packages.json` in the skill repository. The builder: +Copy `templates/build_packages.py` to `packaging/build_packages.py` and `templates/packages.json` to `packaging/packages.json` in the skill repository. The builder has its own version lifecycle (`build_packages --version`), independent of the skill and version-coupled helpers. Do not force the builder version to equal the skill version. The builder: - reads the skill from `skills//`, version from `SKILL.md` `metadata.version`, and refuses if plugin manifests disagree; - builds each archive in `packages.json` with a fixed timestamp, sorted members and fixed permissions, so the same source gives the same bytes; - re-reads every archive, byte-compares every member, asserts no omitted file slipped in, compiles every packaged Python script and runs `--version` on the scripts listed in `smoke_version_scripts`; - writes `SHA256SUMS.txt` and `BUILD_EVIDENCE.json`; never tags, uploads or installs. -Fidelity check (2026-10-08, by the author, repeatable): with a `packages.json` describing multi-agent-folder-cleanup's seven packages (six like `templates/packages.json` plus a `subtree` add-on), this builder reproduced all seven published v1.6.1 ZIPs byte-for-byte against GitHub's asset digests. +Historical fidelity check (2026-10-08, by the author, with the then-current builder; newer ZIP file-type metadata changes archive bytes): with a `packages.json` describing multi-agent-folder-cleanup's seven packages (six like `templates/packages.json` plus a `subtree` add-on), this builder reproduced all seven published v1.6.1 ZIPs byte-for-byte against GitHub's asset digests. Independent of `packages.json`, the builder also refuses `.ps1` in Copilot packages, `.ps1`/`.yml`/`.yaml` and files without an extension (e.g. `gitattributes`, `LICENSE`) in Gemini packages and non-Markdown in Opal packages, and fails if a script named in `smoke_version_scripts` is never packaged. @@ -38,6 +38,15 @@ Independent of `packages.json`, the builder also refuses `.ps1` in Copilot packa Start from the six packages in `templates/packages.json` and remove hosts the skill does not target. Every omission needs a reason in host-matrix.md. +## Input and validation boundaries + +- Every repository source and metadata read refuses linked ancestors, Windows reparse points and paths outside the repository. Archive targets refuse absolute paths, drive prefixes, backslashes and traversal segments. +- ZIP regular files carry POSIX type and permission mode `0100644`. Helpers run through Python; this does not promise executable script permissions. +- Exclusions and host invariants treat suffixes case-insensitively. Dotfiles without a suffix count as extensionless; host acceptance still requires a real upload check. +- Frontmatter delimiters occupy their own lines. The standard-library builder requires an unquoted single-line name and `metadata.version` as a quoted semantic version. Opal name and description must occupy single lines; all multiline forms are refused rather than truncated. Keep only its supported name/description keys. +- All packaged Python files, including templates, compile. Identical bytes are compiled once per invocation; version smoke checks run only for configured helpers and reuse identical helper bytes. +- Distributable helper/template copies stay standalone. Keep repository copies byte-identical to them and run the drift check; do not introduce imports that require an installed skill. + ## Rules - One builder for local candidates and the release workflow; never hand-zip a release asset. diff --git a/skills/github-release/references/publish.md b/skills/github-release/references/publish.md index 32dce6e..b4e77e7 100644 --- a/skills/github-release/references/publish.md +++ b/skills/github-release/references/publish.md @@ -14,27 +14,43 @@ Tag: v (annotated, on the commit above) Gate: PASS () Candidate: SHA256SUMS Workflow: builds packages, creates DRAFT release with assets; nothing public yet +Immutable releases: ON | OFF (repository setting, read ) Not included: publishing the draft, repository settings, host installs ``` -An approval covers only what the block lists. +An approval covers only what the block lists. When immutable releases are **OFF**, ask the owner once, before the first publish of the repository (or of this session), whether to turn the setting on (owner's browser, about a minute) — then record the answer in the approval block. Do not ask again in the same release. Observed: immutable releases stayed off for three skill-run releases because nothing forced the decision. ## 2. Where release actions run -Observed 2026-10-08 (multi-agent-folder-cleanup v1.6.2 pilot): a cloud agent session could not create tags (git push dropped by the proxy; REST tag create 403), could not publish a draft (403 "editing releases not permitted"), and could not change repository settings. Expect the same split and say so before starting: +Cloud agent sessions are refused these steps by two different sources: the network proxy or GitHub API (`remote end hung up`, HTTP 403 "editing releases not permitted"), and the session's own **safety classifier** (denials such as "merge without review" or "create public surface"). Both were observed repeatedly (2026-10-08 pilot, 2026-10-09 v1.7.0 and v1.7.1). Treat the refusals below as **expected: do not attempt them from a cloud session**; plan them as owner steps from the start: | Action | Cloud agent session | Owner's machine | |---|---|---| -| Build candidate, gate, open/merge PRs, read releases | usually works | works | -| Push the tag | may be refused (observed) | signed-in `git` in a fresh temp clone, `core.autocrlf=false` | -| Publish the draft (`gh release edit --draft=false`) | may be refused (observed) | signed-in `gh` | -| Repository settings (branch protection, immutable releases) | refused (observed) | owner's browser or `gh`; may require the owner's **passkey ("sudo mode")** — the agent must stop and ask the owner to complete it; never ask for the credential | +| Build candidate, gate, push the release branch, open PRs, read releases | usually works | works | +| Merge the release PR | expected: refused (safety classifier, observed 2026-10-09) | GitHub web UI or `gh pr merge --squash` | +| Push the tag | expected: refused (proxy, and classifier; observed 3 times) | signed-in `git` in a fresh temp clone, `core.autocrlf=false` | +| Publish the draft (`gh release edit --draft=false`) | expected: refused (API 403, observed twice) | signed-in `gh` | +| Delete a remote branch | expected: refused (observed 2026-10-09) | `git push origin --delete ` | +| Repository settings (branch protection, immutable releases) | expected: refused | owner's browser or `gh`; may require the owner's **passkey ("sudo mode")** — the agent must stop and ask the owner to complete it; never ask for the credential | -When a step is refused from the cloud, do not retry through another route; hand the owner the exact command and wait. The refusal changes where the step runs, never the approval for it. +Do not retry a refused step through another route; the refusal changes where the step runs, never the approval for it. Give the owner **one combined command block** per hand-off so there is one round trip, not one per step: -Prefer `git push` from a real clone. If a connector/API push is the only route, it can **alter file bytes** (observed 2026-10-06: a connector decoded `\u` escapes in a pushed Python file into literal characters; caught only by hash comparison). After any non-git push, before opening the PR, compare every pushed blob with the local file: `gh api repos///contents/?ref= --jq .sha` must equal `git hash-object ` locally. +```text +OWNER COMMANDS — v (each line is covered by the approval named on it) +# 1. merge (approval: merge PR #) +gh pr merge --repo / --squash +# 2. tag the merge commit (approval: tag) — fresh temp clone, outside synced folders +git clone https://github.com// %TEMP%\-tag && cd %TEMP%\-tag +git config user.name "" && git config user.email "" +git tag -a v -m " v" && git push origin v +# later, after the draft verify — 3. publish (approval: publish) 4. delete branch (approval: branch delete) +gh release edit v --repo / --draft=false +git push origin --delete # tip recorded for recovery +``` + +Prefer `git push` from a real clone. If a connector/API push or a GitHub **web-UI upload** ("Add files via upload") is the only route, it can **alter file bytes** (observed 2026-10-06: a connector decoded `\u` escapes in a pushed Python file into literal characters; caught only by hash comparison). After any non-git push or web upload, before opening the PR, compare every pushed blob with the local file: `gh api repos///contents/?ref= --jq .sha` must equal `git hash-object ` locally. -- Typical flow: feature PR (CI green) → merge → release PR with version bump (CI green) → merge → annotated tag on the merge commit. +- Typical flow: feature PR (CI green) → merge → release PR with version bump **and final notes wording** (CI green) → merge → annotated tag on **the PR merge commit** (gate G1c). Any change after the merge — even a one-word notes fix — is a new PR; never commit straight to `main` and tag that. ```bash git tag -a v1.2.0 -m " v1.2.0" @@ -44,22 +60,29 @@ git push origin v1.2.0 ## 3. Draft created → verify 1. Wait for the workflow. Confirm the release is a **draft** (`gh release view v1.2.0 --json isDraft`). -2. Download every asset: `gh release download v1.2.0 --dir ../draft-1.2.0` (drafts need an authenticated `gh`). `gh release download` uses GraphQL, which some proxies block (observed); fall back to REST per asset: +2. Download every asset: `gh release download v1.2.0 --dir ../draft-1.2.0` (drafts need an authenticated `gh`). `gh release download` uses GraphQL, which some proxies block (observed); fall back to REST, iterating the **release JSON asset list** (not a hand-written list) and asserting the count: ```bash - gh api repos///releases --jq '.[] | select(.tag_name=="v1.2.0") | .assets[] | [.id, .name] | @tsv' - gh api -H "Accept: application/octet-stream" repos///releases/assets/ > ../draft-1.2.0/ + gh api repos///releases --jq '.[] | select(.tag_name=="v1.2.0") | .assets[] | [.id, .name] | @tsv' > ../draft-1.2.0.assets.tsv + while IFS=$'\t' read -r id name; do + gh api -H "Accept: application/octet-stream" repos///releases/assets/$id > "../draft-1.2.0/$name" + done < ../draft-1.2.0.assets.tsv + test "$(ls ../draft-1.2.0 | wc -l)" -eq "$(wc -l < ../draft-1.2.0.assets.tsv)" && echo "all assets downloaded" ``` + + `verify_release.py` prints `downloaded N of M listed assets` first; N < M is a download problem, not a release defect (observed false FAIL when a loop skipped `SHA256SUMS.txt`). 3. Save the draft's metadata. The `releases/tags/` endpoint returns published releases only, so select the draft from the list: `gh api repos///releases --jq '.[] | select(.tag_name=="v1.2.0")' > ../draft-1.2.0.json` 4. Run: ```bash python packaging/verify_release.py --assets ../draft-1.2.0 --release-json ../draft-1.2.0.json \ - --candidate ../candidate-1.2.0 --version 1.2.0 + --candidate ../candidate-1.2.0 --version 1.2.0 --notes packaging/RELEASE_NOTES_v1.2.0.md ``` -5. Read the notes on the draft page; they must match `packaging/RELEASE_NOTES_v1.2.0.md`. + It also checks that the release was created by `github-actions[bot]` with the title ` v`, and that the body equals the notes file. + +5. Read the notes on the draft page as a reader would. If anything fails: delete the draft (approval), fix, rebuild. If the tag must move, delete it before anything is published, record why, and push again (approval). Never move a tag after publication. @@ -77,13 +100,16 @@ With immutable releases enabled in repository settings, assets and the tag are l ## 5. After publishing -- Download the published assets again into the project's local release folder and run `verify_release.py --expect-published --expect-immutable` against the published metadata (`gh api repos///releases/tags/v1.2.0`). +- Download the published assets again into the project's local release folder and run `verify_release.py --expect-published --notes packaging/RELEASE_NOTES_v1.2.0.md` against the published metadata (`gh api repos///releases/tags/v1.2.0`), adding `--expect-immutable` when the approval block said ON. The record says which. +- **Release body after publishing.** Assets and tag are locked; the body is not. The only allowed edit is to **append** a dated section `## Known issues (added YYYY-MM-DD)` that names the defect and the fixing version; never rewrite the original text. Log the edit in the release record. `verify_release.py --notes` accepts exactly that shape and FAILs any other difference. +- Remove the project's "Release in progress" marker (gate G0) after publishing — or after abandoning the release, saying why. - Published releases do not update any host. Each host install is a separate action and a separate install-log row ([verify-and-record.md](verify-and-record.md)). - Delete the merged release branch (approval), recording its tip SHA for recovery; list any other remote branches whose tips are ancestors of `main` for a separate owner decision. Repositories with "automatically delete head branches" enabled (repo-setup default) skip the first step. - Update the project's records: tracker, quick context, index. ## Never +- Creating a release by hand (`gh release create` outside the workflow, or the web UI's "Draft a new release"). If the workflow failed, fix it and re-run it on the same tag before anything is published. Observed: 4 of 12 releases of one repository were hand-created, one with a broken title; `verify_release.py` now FAILs a release whose author is not the workflow. - Renaming a branch that has an open PR — GitHub closes the PR and it cannot be reopened (observed 2026-10-08). A renumbered candidate gets a new branch and a replacement PR; close the old PR with a comment linking forward. `--force-with-lease` only on your own un-reviewed branch, never on `main`. - `gh release upload --clobber` or deleting and re-uploading a published asset. - `--generate-notes` as a substitute for the reviewed notes file. diff --git a/skills/github-release/references/release-gate.md b/skills/github-release/references/release-gate.md index 1322068..c2aadc5 100644 --- a/skills/github-release/references/release-gate.md +++ b/skills/github-release/references/release-gate.md @@ -22,12 +22,47 @@ Typical touch points: - `packaging/RELEASE_NOTES_vX.Y.Z.md` (must exist; the workflow refuses to publish without it) - `CHANGELOG.md` entry +### Which number to bump + +Decide by rule, not by asking each time. Name the level and the item that triggered it in the gate report. + +| Level | When | +|---|---| +| **patch** (1.2.0 → 1.2.1) | text and documentation fixes; bug fixes that leave helper output and package layout unchanged | +| **minor** (1.2.0 → 1.3.0) | a new helper flag or helper; new output lines or columns; changed output meaning; a new gate check or lint that can fail a run that passed before; a package added or removed; a new host | +| **major** (1.x → 2.0.0) | a mode or protocol change, or a helper interface that breaks existing callers | + +The highest level among the release's changes wins. Owner override is allowed and recorded. + +`--previous-from-changelog` is enabled in the CI/release templates: it uses the preceding unique version heading and refuses a changelog whose first heading is not the requested version. An explicit `--previous` must agree when both are used. Configure legitimate historical references in the existing history list. + `--previous` lists every remaining mention of the old version outside history files (CHANGELOG, older release notes). Each one is either a stale reference to fix or history to leave; decide per line. (Observed failure: a Copilot install guide still said the previous version after a release.) ## 2. Build the local candidate Build from a **fresh clone of the canonical repository, outside any synced folder** (`%USERPROFILE%\dev\` or a temp dir, never inside OneDrive/SharePoint), cloned with `core.autocrlf=false`. Observed failures from clones inside synced project folders: `.git/index.lock` conflicts, read-only dirs that cannot be removed, stale copies that no longer match the remote (2026-10-06, folder-cleanup and cabinet-load-optimizer records). A project folder's copy of the repo is documentation, not a build source. +In every fresh clone, before the first commit, set the owner's identity so release commits are attributed to the owner, not to an AI: + +```bash +git config user.name "" +git config user.email "" +``` + +AI attribution goes only in a commit trailer (`Co-Authored-By: ...`), never in the author field. Observed: release commits authored "Claude " showed no owner link on GitHub. + +**Never hold a candidate only in a sandbox.** Before building, either push the candidate branch or save `git bundle create /.bundle main..` (full history with `--all` when the base may move). Observed: a worktree was lost before it was pushed and its notes had to be rebuilt from a draft. + +**Synced clones are inspect-only.** When you must read a clone inside OneDrive/SharePoint or through a device bridge, use `git --no-optional-locks -c core.fsmonitor=false status` so the read leaves no `.git/index.lock` (observed: a read-only `git status` through a bridged OneDrive path left a lock the mount could not delete). After every write through a bridge or connector, re-hash the written file at its destination; a bridged write was observed to land a stale copy first. + +**Run tests on their own line and record the exit code** before any push or build: + +```bash +python -m unittest discover -s tests -v; echo "tests exit=$?" +``` + +Never chain tests and the next step with `;` (observed: a branch was pushed with a failing test because the chained command did not stop). Exit 0 = pass; non-zero = fail, stop. A runner that reports **blocked** (for example exit 2 because PowerShell 7 is not on this agent's PATH) is neither pass nor fail: write `not-run (blocked: )` and cite the CI job that ran it (template ci.yml runs Windows) as the evidence. + ```bash python packaging/build_packages.py ../candidate-1.2.0 --status "local candidate, not published" ``` @@ -42,13 +77,15 @@ Run in order. Stop at the first fail. | # | Check | How | |---|---|---| -| G0 | Release lane is free: no other open release PR; no `v-*` branch by another writer; the expected `main` SHA (from the handoff prompt, when one exists) still equals the remote | `gh pr list --state open`; `git ls-remote --heads origin`; `git ls-remote origin main`. One release in flight per repo — observed collision 2026-10-08: two agents built "v1.6.2" from different bases; the loser's PR had to be renumbered | -| G1 | Working tree clean; candidate built from the exact commit to be tagged, in a fresh clone outside synced folders (section 2) | `git status --porcelain` empty; record `git rev-parse HEAD` | +| G0 | Release lane is free: no other open release PR; no `v-*` branch by another writer; the expected `main` SHA (from the handoff prompt, when one exists) still equals the remote. **When the project's rules define a "Release in progress" marker** (for example a line in quick context): read it and stop if another session holds it; write yours (`Release in progress: v, session , started