Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
2 changes: 1 addition & 1 deletion .github/workflows/template-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@v7

- name: Set up Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: "22"

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/template-release-draft.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:
} >> "${GITHUB_OUTPUT}"

- name: Set up Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: "22"

Expand Down
11 changes: 10 additions & 1 deletion .template/bootstrap/release-packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Strict exclusions protect privacy, reduce context noise, and prevent bootstrap i
- Keep version metadata synchronized.
- Refuse ambiguous releases instead of producing misleading artifacts.
- Prefer clean draft releases over mutating published releases.
- Produce byte-identical assets from the same candidate tree on every supported operating system.

## Version Source

Expand All @@ -39,6 +40,8 @@ Every release should produce three ZIP files and one checksum manifest:

The workflow should attest each ZIP after packaging and before upload.

The packager must normalize known text files to LF, use the repository's dependency-free stored ZIP writer, and sort every archive entry. Run the asset verifier after packaging to prove checksums, CRC values, archive inventory, staged byte identity, forbidden paths, local material, and credential patterns.

## Canonical Source

Package from exactly one `skills/<name>/SKILL.md` tree whose directory and frontmatter names agree. GitHub CLI source installation reads this tagged tree directly, while release and plugin consumers receive copies of the same runtime content.
Expand Down Expand Up @@ -74,12 +77,18 @@ Rationale: Manifests are the package identity seen by host systems. Stale manife

## Workflow Rules

The draft release workflow should package from the tagged commit, generate checksums, attest every ZIP, and refuse to mutate a published release when notes or changelog entries are missing.
Run `npm run release:preflight -- vX.Y.Z` once on the final uncommitted release tree. The preflight validates synchronized versions and release documents, proves that the tag and release identity are unused, runs source validation, builds twice, verifies both builds, and refuses changes to the candidate tree.

The draft release workflow should package from the tagged commit, generate checksums, verify and attest every ZIP, install the exact unpublished tag, and refuse to mutate a published release or overwrite different draft title and notes.

Run `gh skill publish --dry-run` in a clean checkout before packaging creates `dist/`. Do not use `gh skill publish --tag` because it bypasses the generated package, checksum, attestation, curated note, draft, and review sequence.

After publication, a separate release event workflow should install the versionless public release into an ephemeral profile and verify source metadata, file inventory, and content without executing the installed skill.

The post-publication workflow should also install the previous published release, compare the previous and current runtime tree identities, apply a contained `gh skill update` only when runtime content changed, and verify the resulting installation.

Never move, reuse, or delete a final-form release tag. If an unpublished candidate must be retired, require explicit authorization, create and push an annotated `abandoned/vX.Y.Z` marker at the same commit, verify both immutable tags, and only then delete the matching unpublished draft. The release-state guard must permanently reject a marked version.

Rationale: A tag is a promise that the repository state, docs, package manifests, and release notes describe the same artifact.

## Bootstrap Location
Expand Down
2 changes: 1 addition & 1 deletion .template/generated/.github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
uses: actions/checkout@v7

- name: Set up Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: "22"

Expand Down
123 changes: 115 additions & 8 deletions .template/generated/.github/workflows/gh-skill-install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ on:
permissions:
contents: read

concurrency:
group: release-state-${{ github.event.release.tag_name }}
cancel-in-progress: false

jobs:
verify-install:
name: Verify public gh skill install
Expand All @@ -17,10 +21,10 @@ jobs:
- name: Check out released source
uses: actions/checkout@v7
with:
ref: \${{ github.event.release.tag_name }}
ref: ${{ github.event.release.tag_name }}

- name: Set up Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: "22"

Expand All @@ -33,18 +37,121 @@ jobs:
run: |
set -euo pipefail
skill_name="$(node scripts/verify-gh-skill-install.mjs --print-skill-name)"
echo "name=\${skill_name}" >> "\${GITHUB_OUTPUT}"
echo "name=${skill_name}" >> "${GITHUB_OUTPUT}"

- name: Install the latest published release
shell: bash
env:
GH_TOKEN: \${{ github.token }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh skill install "\${GITHUB_REPOSITORY}" "skills/\${{ steps.skill.outputs.name }}" --dir "\${RUNNER_TEMP}/gh-skill-install"
gh skill install "${GITHUB_REPOSITORY}" "skills/${{ steps.skill.outputs.name }}" --dir "${RUNNER_TEMP}/gh-skill-install"

- name: Verify installed source and metadata
env:
INSTALL_ROOT: \${{ runner.temp }}/gh-skill-install
RELEASE_TAG: \${{ github.event.release.tag_name }}
run: npm run verify:gh-skill -- --install-root "\${INSTALL_ROOT}" --repository "\${GITHUB_REPOSITORY}" --tag "\${RELEASE_TAG}"
INSTALL_ROOT: ${{ runner.temp }}/gh-skill-install
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: npm run verify:gh-skill -- --install-root "${INSTALL_ROOT}" --repository "${GITHUB_REPOSITORY}" --tag "${RELEASE_TAG}"

verify-update:
name: Verify update from previous release
runs-on: ubuntu-latest

steps:
- name: Check out released source
uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}

- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: "22"

- name: Resolve previous published release
id: previous
shell: bash
env:
CURRENT_TAG: ${{ github.event.release.tag_name }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
releases="$(gh release list --repo "${GITHUB_REPOSITORY}" --limit 100 --json tagName,isDraft,isPrerelease)"
previous="$(jq -r --arg current "${CURRENT_TAG}" '[.[] | select(.isDraft == false and .isPrerelease == false and .tagName != $current)][0].tagName // ""' <<<"${releases}")"
echo "tag=${previous}" >> "${GITHUB_OUTPUT}"

- name: Check out previous released source
if: steps.previous.outputs.tag != ''
uses: actions/checkout@v7
with:
ref: ${{ steps.previous.outputs.tag }}
path: tmp/previous-source

- name: Resolve canonical skill
if: steps.previous.outputs.tag != ''
id: skill
shell: bash
run: |
set -euo pipefail
skill_name="$(node scripts/verify-gh-skill-install.mjs --print-skill-name)"
echo "name=${skill_name}" >> "${GITHUB_OUTPUT}"

- name: Compare released runtime
if: steps.previous.outputs.tag != ''
id: runtime
shell: bash
env:
PREVIOUS_SOURCE: ${{ github.workspace }}/tmp/previous-source
SKILL_NAME: ${{ steps.skill.outputs.name }}
run: |
set -euo pipefail
current_tree="$(git rev-parse "HEAD:skills/${SKILL_NAME}")"
previous_tree="$(git -C "${PREVIOUS_SOURCE}" rev-parse "HEAD:skills/${SKILL_NAME}")"
if [[ "${current_tree}" == "${previous_tree}" ]]; then
echo "changed=false" >> "${GITHUB_OUTPUT}"
else
echo "changed=true" >> "${GITHUB_OUTPUT}"
fi

- name: Install the previous published release
if: steps.previous.outputs.tag != ''
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
SKILL_NAME: ${{ steps.skill.outputs.name }}
run: |
set -euo pipefail
gh skill install "${GITHUB_REPOSITORY}" "skills/${SKILL_NAME}@${PREVIOUS_TAG}" --dir "${RUNNER_TEMP}/gh-skill-update"

- name: Verify the previous installation
if: steps.previous.outputs.tag != ''
env:
INSTALL_ROOT: ${{ runner.temp }}/gh-skill-update
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
run: npm run verify:gh-skill -- --source-root "${GITHUB_WORKSPACE}/tmp/previous-source" --install-root "${INSTALL_ROOT}" --repository "${GITHUB_REPOSITORY}" --tag "${PREVIOUS_TAG}"

- name: Preview and apply the contained update
if: steps.previous.outputs.tag != '' && steps.runtime.outputs.changed == 'true'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
SKILL_NAME: ${{ steps.skill.outputs.name }}
run: |
set -euo pipefail
gh skill update "${SKILL_NAME}" --dir "${RUNNER_TEMP}/gh-skill-update" --dry-run
gh skill update "${SKILL_NAME}" --dir "${RUNNER_TEMP}/gh-skill-update" --all

- name: Verify the updated installation
if: steps.previous.outputs.tag != '' && steps.runtime.outputs.changed == 'true'
env:
INSTALL_ROOT: ${{ runner.temp }}/gh-skill-update
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: npm run verify:gh-skill -- --install-root "${INSTALL_ROOT}" --repository "${GITHUB_REPOSITORY}" --tag "${RELEASE_TAG}"

- name: Verify the unchanged runtime
if: steps.previous.outputs.tag != '' && steps.runtime.outputs.changed == 'false'
env:
INSTALL_ROOT: ${{ runner.temp }}/gh-skill-update
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
run: npm run verify:gh-skill -- --install-root "${INSTALL_ROOT}" --repository "${GITHUB_REPOSITORY}" --tag "${PREVIOUS_TAG}"
64 changes: 47 additions & 17 deletions .template/generated/.github/workflows/release-draft.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ permissions:
id-token: write
attestations: write

concurrency:
group: release-state-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false

jobs:
draft-release:
name: Build release assets and draft release
Expand All @@ -26,6 +30,7 @@ jobs:
uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.tag || github.ref }}
fetch-depth: 0

- name: Resolve release metadata
id: meta
Expand All @@ -38,7 +43,7 @@ jobs:
tag="${INPUT_TAG:-${GITHUB_REF_NAME}}"
notes_file="docs/releases/${tag}.md"

if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$ ]]; then
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag '${tag}' does not match expected vX.Y.Z format." >&2
exit 1
fi
Expand All @@ -48,11 +53,6 @@ jobs:
exit 1
fi

if [[ ! -f CHANGELOG.md ]]; then
echo "Missing CHANGELOG.md." >&2
exit 1
fi

if ! grep -Fq "## [${tag}]" CHANGELOG.md; then
echo "CHANGELOG.md is missing required section: ## [${tag}]" >&2
exit 1
Expand All @@ -65,10 +65,15 @@ jobs:
} >> "${GITHUB_OUTPUT}"

- name: Set up Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: "22"

- name: Guard exact immutable tag
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: npm run release:state -- guard --tag "${{ steps.meta.outputs.tag }}" --github-output "${GITHUB_OUTPUT}"

- name: Validate skill
run: npm run validate

Expand All @@ -80,12 +85,39 @@ jobs:
- name: Package release assets
run: npm run package -- ${{ steps.meta.outputs.tag }}

- name: Verify packaged assets
run: npm run release:verify-assets -- ${{ steps.meta.outputs.tag }}

- name: Resolve canonical skill
id: skill
shell: bash
run: |
set -euo pipefail
skill_name="$(node scripts/verify-gh-skill-install.mjs --print-skill-name)"
echo "name=${skill_name}" >> "${GITHUB_OUTPUT}"

- name: Install the exact unpublished tag
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.meta.outputs.tag }}
SKILL_NAME: ${{ steps.skill.outputs.name }}
run: |
set -euo pipefail
gh skill install "${GITHUB_REPOSITORY}" "skills/${SKILL_NAME}@${RELEASE_TAG}" --dir "${RUNNER_TEMP}/gh-skill-tag-install"

- name: Verify the exact tag installation
env:
INSTALL_ROOT: ${{ runner.temp }}/gh-skill-tag-install
RELEASE_TAG: ${{ steps.meta.outputs.tag }}
run: npm run verify:gh-skill -- --install-root "${INSTALL_ROOT}" --repository "${GITHUB_REPOSITORY}" --tag "${RELEASE_TAG}"

- name: Attest release ZIP provenance
uses: actions/attest@v4
with:
subject-path: dist/assets/*.zip

- name: Create or update draft release
- name: Create immutable draft release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
Expand All @@ -95,16 +127,15 @@ jobs:
run: |
set -euo pipefail

if gh release view "${TAG_NAME}" >/dev/null 2>&1; then
is_draft="$(gh release view "${TAG_NAME}" --json isDraft --jq '.isDraft')"
if [[ "${is_draft}" != "true" ]]; then
echo "Release ${TAG_NAME} already exists and is not a draft. Refusing to mutate a published release." >&2
if release_json="$(gh release view "${TAG_NAME}" --json body,isDraft,isPrerelease,name 2>/dev/null)"; then
if [[ "$(jq -r '.isDraft' <<<"${release_json}")" != "true" || "$(jq -r '.isPrerelease' <<<"${release_json}")" != "false" ]]; then
echo "Release ${TAG_NAME} is already published." >&2
exit 1
fi
if [[ "$(jq -r '.name' <<<"${release_json}")" != "${RELEASE_TITLE}" || "$(jq -r '.body' <<<"${release_json}")" != "$(cat "${NOTES_FILE}")" ]]; then
echo "Draft ${TAG_NAME} differs from the immutable tagged title or notes; refusing to overwrite it." >&2
exit 1
fi

gh release edit "${TAG_NAME}" \
--title "${RELEASE_TITLE}" \
--notes-file "${NOTES_FILE}"
else
gh release create "${TAG_NAME}" \
--draft \
Expand All @@ -119,5 +150,4 @@ jobs:
TAG_NAME: ${{ steps.meta.outputs.tag }}
run: |
set -euo pipefail

gh release upload "${TAG_NAME}" dist/assets/*.zip dist/assets/SHA256SUMS --clobber
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

- Make generated releases deterministic across operating systems, verify staged and archived bytes, guard immutable tags and drafts, and add a final-tree preflight.
- Make GitHub CLI delivery a release gate with exact-tag installation, published installation, previous-release update testing, and installed-tree equality checks.
- Normalize repository text to LF, reject escaped generated workflow expressions, and keep active and generated workflows on the same Node setup action.
- Replace the incompatible `src/SKILL.md` source with one standard `skills/<name>/SKILL.md` tree and keep maintenance fixtures outside the installed runtime.
- Add GitHub CLI publisher validation, public install verification, checksums, attestations, root installation guidance, beginner orientation, and a focused runtime update scaffold.
- Make validation, packaging, archive fallback, and installed tree verification discover the generated skill dynamically and remain portable across operating systems.
Expand Down
6 changes: 4 additions & 2 deletions docs/GITHUB-CLI-DELIVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ The generated tag workflow remains authoritative for validation, three ZIP packa

Do not use `gh skill publish --tag` in the generated workflow. It can push the branch and create an immediately published release without the generated repository's package, checksum, attestation, draft, or curated note sequence.

After the draft is published, the release event workflow performs a public versionless install in an ephemeral runner and verifies repository, tag, source path, tree metadata, runtime inventory, and content without executing the installed skill.
Before the draft is created, the tag workflow installs the exact unpublished tag and verifies repository, tag, source path, tree metadata, runtime inventory, and content without executing the installed skill.

After the draft is published, the release event workflow performs the same checks for a public versionless install. A second job installs the previous published release, compares runtime tree identity, applies a contained `gh skill update` only when runtime content changed, and verifies either the updated installation or the unchanged equivalent runtime.

## Containment

Expand All @@ -45,7 +47,7 @@ npm run validate
npm run package -- vX.Y.Z
```

Inspect the three ZIPs and `SHA256SUMS`, confirm excluded bootstrap and private material is absent, run the publisher dry run in a clean checkout, and verify the public versionless install after release publication.
Run the final-tree preflight, inspect the three ZIPs and `SHA256SUMS`, confirm excluded bootstrap and private material is absent, and verify the exact-tag install before publication plus the versionless install and previous-release update after publication.

## Rollback

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"scripts": {
"validate": "node scripts/validate-skill.mjs",
"package": "node scripts/package-release.mjs",
"verify:gh-skill": "node scripts/verify-gh-skill-install.mjs"
"verify:gh-skill": "node scripts/verify-gh-skill-install.mjs",
"release:preflight": "node scripts/release-preflight.mjs",
"release:state": "node scripts/release-state.mjs",
"release:verify-assets": "node scripts/verify-release-assets.mjs"
}
}
Loading