From d708ad4e72e6141812b812d337296296e1ca333d Mon Sep 17 00:00:00 2001 From: sergeliatko Date: Mon, 27 Jul 2026 14:48:11 +0200 Subject: [PATCH] harden generated release delivery --- .gitattributes | 1 + .github/workflows/template-ci.yml | 2 +- .github/workflows/template-release-draft.yml | 2 +- .template/bootstrap/release-packaging.md | 11 +- .template/generated/.github/workflows/ci.yml | 2 +- .../.github/workflows/gh-skill-install.yml | 123 ++++++++- .../.github/workflows/release-draft.yml | 64 +++-- CHANGELOG.md | 3 + docs/GITHUB-CLI-DELIVERY.md | 6 +- package.json | 5 +- scripts/lib/stored-zip.mjs | 243 ++++++++++++++++++ scripts/package-release.mjs | 82 ++++-- scripts/release-preflight.mjs | 173 +++++++++++++ scripts/release-state.mjs | 114 ++++++++ scripts/validate-skill.mjs | 35 ++- scripts/verify-release-assets.mjs | 121 +++++++++ 16 files changed, 924 insertions(+), 63 deletions(-) create mode 100644 .gitattributes create mode 100644 scripts/lib/stored-zip.mjs create mode 100644 scripts/release-preflight.mjs create mode 100644 scripts/release-state.mjs create mode 100644 scripts/verify-release-assets.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index 18effd1..f0d2bed 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -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" diff --git a/.github/workflows/template-release-draft.yml b/.github/workflows/template-release-draft.yml index 2ef3f47..2a8707d 100644 --- a/.github/workflows/template-release-draft.yml +++ b/.github/workflows/template-release-draft.yml @@ -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" diff --git a/.template/bootstrap/release-packaging.md b/.template/bootstrap/release-packaging.md index fb17e5a..fdcd441 100644 --- a/.template/bootstrap/release-packaging.md +++ b/.template/bootstrap/release-packaging.md @@ -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 @@ -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//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. @@ -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 diff --git a/.template/generated/.github/workflows/ci.yml b/.template/generated/.github/workflows/ci.yml index ae415cc..48b44db 100644 --- a/.template/generated/.github/workflows/ci.yml +++ b/.template/generated/.github/workflows/ci.yml @@ -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" diff --git a/.template/generated/.github/workflows/gh-skill-install.yml b/.template/generated/.github/workflows/gh-skill-install.yml index 77bb4a1..8f48339 100644 --- a/.template/generated/.github/workflows/gh-skill-install.yml +++ b/.template/generated/.github/workflows/gh-skill-install.yml @@ -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 @@ -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" @@ -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}" diff --git a/.template/generated/.github/workflows/release-draft.yml b/.template/generated/.github/workflows/release-draft.yml index abd64be..26c758c 100644 --- a/.template/generated/.github/workflows/release-draft.yml +++ b/.template/generated/.github/workflows/release-draft.yml @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 }} @@ -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 \ @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b637d..f5ca4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//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. diff --git a/docs/GITHUB-CLI-DELIVERY.md b/docs/GITHUB-CLI-DELIVERY.md index 212aca9..472e27d 100644 --- a/docs/GITHUB-CLI-DELIVERY.md +++ b/docs/GITHUB-CLI-DELIVERY.md @@ -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 @@ -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 diff --git a/package.json b/package.json index 9d1da58..7f1da68 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/lib/stored-zip.mjs b/scripts/lib/stored-zip.mjs new file mode 100644 index 0000000..43410a8 --- /dev/null +++ b/scripts/lib/stored-zip.mjs @@ -0,0 +1,243 @@ +/** Create and inspect deterministic stored ZIP archives. + * @since 1.4.0 + * @why #17 requires dependency-free, byte-identical release archives across operating systems. + * @constraints Supports classic non-Zip64, single-disk archives containing sorted regular files. + */ +import fs from "node:fs"; +import path from "node:path"; + +const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50; +const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; +const LOCAL_FILE_SIGNATURE = 0x04034b50; +const UTF8_FLAG = 0x0800; +const STORED_METHOD = 0; +const FIXED_DOS_DATE = 33; +const FIXED_DOS_TIME = 0; +const MAX_UINT16 = 0xffff; +const MAX_UINT32 = 0xffffffff; +const CRC32_TABLE = createCrc32Table(); + +/** Create a deterministic ZIP that stores sorted regular files without compression. + * @param {Array<{name: string, data: Buffer}>} entries Slash-separated archive names and bytes. + * @param {string} destination Absolute or relative ZIP destination. + * @returns {void} + * @throws {Error} When an entry is unsafe, duplicated, or too large for the non-Zip64 format. + * @sideEffects Creates the destination parent directory and replaces the destination file. + * @constraints Fixed metadata and stored entries make output independent of the host archiver, filesystem timestamps, and compression library. + */ +export function createStoredZip(entries, destination) { + const normalized = normalizeEntries(entries); + const localParts = []; + const centralParts = []; + let localOffset = 0; + + for (const entry of normalized) { + const name = Buffer.from(entry.name, "utf8"); + const size = entry.data.length; + const checksum = crc32(entry.data); + requireClassicZipLimit(name.length, "entry name"); + requireClassicZipLimit(size, "entry size", MAX_UINT32); + requireClassicZipLimit(localOffset, "archive offset", MAX_UINT32); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(LOCAL_FILE_SIGNATURE, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(UTF8_FLAG, 6); + localHeader.writeUInt16LE(STORED_METHOD, 8); + localHeader.writeUInt16LE(FIXED_DOS_TIME, 10); + localHeader.writeUInt16LE(FIXED_DOS_DATE, 12); + localHeader.writeUInt32LE(checksum, 14); + localHeader.writeUInt32LE(size, 18); + localHeader.writeUInt32LE(size, 22); + localHeader.writeUInt16LE(name.length, 26); + localHeader.writeUInt16LE(0, 28); + localParts.push(localHeader, name, entry.data); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(CENTRAL_DIRECTORY_SIGNATURE, 0); + centralHeader.writeUInt16LE(0x0314, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt16LE(UTF8_FLAG, 8); + centralHeader.writeUInt16LE(STORED_METHOD, 10); + centralHeader.writeUInt16LE(FIXED_DOS_TIME, 12); + centralHeader.writeUInt16LE(FIXED_DOS_DATE, 14); + centralHeader.writeUInt32LE(checksum, 16); + centralHeader.writeUInt32LE(size, 20); + centralHeader.writeUInt32LE(size, 24); + centralHeader.writeUInt16LE(name.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt16LE(0, 36); + centralHeader.writeUInt32LE((0o100644 << 16) >>> 0, 38); + centralHeader.writeUInt32LE(localOffset, 42); + centralParts.push(centralHeader, name); + + localOffset += localHeader.length + name.length + size; + } + + requireClassicZipLimit(normalized.length, "entry count"); + const centralDirectory = Buffer.concat(centralParts); + requireClassicZipLimit(centralDirectory.length, "central directory size", MAX_UINT32); + + const end = Buffer.alloc(22); + end.writeUInt32LE(END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(normalized.length, 8); + end.writeUInt16LE(normalized.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(localOffset, 16); + end.writeUInt16LE(0, 20); + + fs.mkdirSync(path.dirname(path.resolve(destination)), { recursive: true }); + fs.writeFileSync(destination, Buffer.concat([...localParts, centralDirectory, end])); +} + +/** Read and validate a deterministic stored ZIP without extracting it. + * @param {string} archive ZIP file to inspect. + * @returns {Map} Sorted archive names and their verified bytes. + * @throws {Error} When the archive is malformed, compressed, duplicated, unsafe, or fails CRC validation. + * @constraints Supports the archive format emitted by createStoredZip. + */ +export function readStoredZip(archive) { + const bytes = fs.readFileSync(archive); + const endOffset = findEndRecord(bytes); + const disk = bytes.readUInt16LE(endOffset + 4); + const centralDisk = bytes.readUInt16LE(endOffset + 6); + const diskEntries = bytes.readUInt16LE(endOffset + 8); + const totalEntries = bytes.readUInt16LE(endOffset + 10); + const centralSize = bytes.readUInt32LE(endOffset + 12); + const centralOffset = bytes.readUInt32LE(endOffset + 16); + const commentLength = bytes.readUInt16LE(endOffset + 20); + + if (disk !== 0 || centralDisk !== 0 || diskEntries !== totalEntries) { + throw new Error(`${archive} is not a single-disk ZIP.`); + } + if (endOffset + 22 + commentLength !== bytes.length || centralOffset + centralSize !== endOffset) { + throw new Error(`${archive} has inconsistent central directory bounds.`); + } + + const entries = new Map(); + let offset = centralOffset; + for (let index = 0; index < totalEntries; index += 1) { + requireBounds(bytes, offset, 46, archive); + if (bytes.readUInt32LE(offset) !== CENTRAL_DIRECTORY_SIGNATURE) { + throw new Error(`${archive} has an invalid central directory entry.`); + } + const flags = bytes.readUInt16LE(offset + 8); + const method = bytes.readUInt16LE(offset + 10); + const checksum = bytes.readUInt32LE(offset + 16); + const compressedSize = bytes.readUInt32LE(offset + 20); + const size = bytes.readUInt32LE(offset + 24); + const nameLength = bytes.readUInt16LE(offset + 28); + const extraLength = bytes.readUInt16LE(offset + 30); + const entryCommentLength = bytes.readUInt16LE(offset + 32); + const localHeaderOffset = bytes.readUInt32LE(offset + 42); + requireBounds(bytes, offset + 46, nameLength + extraLength + entryCommentLength, archive); + const name = bytes.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); + assertSafeName(name); + + if ((flags & UTF8_FLAG) === 0 || method !== STORED_METHOD || compressedSize !== size) { + throw new Error(`${archive} entry ${name} is not a UTF-8 stored file.`); + } + if (entries.has(name)) { + throw new Error(`${archive} contains duplicate entry ${name}.`); + } + + requireBounds(bytes, localHeaderOffset, 30, archive); + if (bytes.readUInt32LE(localHeaderOffset) !== LOCAL_FILE_SIGNATURE) { + throw new Error(`${archive} entry ${name} has an invalid local header.`); + } + const localNameLength = bytes.readUInt16LE(localHeaderOffset + 26); + const localExtraLength = bytes.readUInt16LE(localHeaderOffset + 28); + const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength; + requireBounds(bytes, dataOffset, size, archive); + const localName = bytes.subarray(localHeaderOffset + 30, localHeaderOffset + 30 + localNameLength).toString("utf8"); + if (localName !== name) { + throw new Error(`${archive} entry ${name} disagrees with its local header.`); + } + const data = bytes.subarray(dataOffset, dataOffset + size); + if (crc32(data) !== checksum) { + throw new Error(`${archive} entry ${name} failed CRC validation.`); + } + entries.set(name, Buffer.from(data)); + offset += 46 + nameLength + extraLength + entryCommentLength; + } + + if (offset !== endOffset) { + throw new Error(`${archive} has unparsed central directory data.`); + } + return new Map([...entries.entries()].sort(([left], [right]) => compareNames(left, right))); +} + +function normalizeEntries(entries) { + const normalized = entries.map(({ name, data }) => { + assertSafeName(name); + if (!Buffer.isBuffer(data)) { + throw new Error(`ZIP entry ${name} data must be a Buffer.`); + } + return { name, data }; + }).sort((left, right) => compareNames(left.name, right.name)); + + for (let index = 1; index < normalized.length; index += 1) { + if (normalized[index - 1].name === normalized[index].name) { + throw new Error(`Duplicate ZIP entry ${normalized[index].name}.`); + } + } + return normalized; +} + +function assertSafeName(name) { + if (!name || name.includes("\\") || name.startsWith("/") || /^[A-Za-z]:/.test(name)) { + throw new Error(`Unsafe ZIP entry name ${name || ""}.`); + } + const segments = name.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) { + throw new Error(`Unsafe ZIP entry name ${name}.`); + } +} + +function findEndRecord(bytes) { + const minimum = Math.max(0, bytes.length - 22 - MAX_UINT16); + for (let offset = bytes.length - 22; offset >= minimum; offset -= 1) { + if (bytes.readUInt32LE(offset) === END_OF_CENTRAL_DIRECTORY_SIGNATURE) { + return offset; + } + } + throw new Error("ZIP end-of-central-directory record is missing."); +} + +function requireBounds(bytes, offset, length, archive) { + if (offset < 0 || length < 0 || offset + length > bytes.length) { + throw new Error(`${archive} contains an out-of-bounds ZIP record.`); + } +} + +function requireClassicZipLimit(value, label, maximum = MAX_UINT16) { + if (!Number.isSafeInteger(value) || value < 0 || value > maximum) { + throw new Error(`${label} exceeds the supported classic ZIP limit.`); + } +} + +function compareNames(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function crc32(bytes) { + let checksum = MAX_UINT32; + for (const byte of bytes) { + checksum = CRC32_TABLE[(checksum ^ byte) & 0xff] ^ (checksum >>> 8); + } + return (checksum ^ MAX_UINT32) >>> 0; +} + +function createCrc32Table() { + return Array.from({ length: 256 }, (_, value) => { + let entry = value; + for (let bit = 0; bit < 8; bit += 1) { + entry = (entry & 1) === 1 ? 0xedb88320 ^ (entry >>> 8) : entry >>> 1; + } + return entry >>> 0; + }); +} diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs index 275c7f3..21217e3 100644 --- a/scripts/package-release.mjs +++ b/scripts/package-release.mjs @@ -9,8 +9,8 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { createStoredZip } from "./lib/stored-zip.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const tag = process.argv[2]; @@ -114,7 +114,7 @@ function copyRuntimeTree(source, destination) { if (entry.isDirectory()) { copyRuntimeTree(sourcePath, destinationPath); } else if (entry.isFile()) { - fs.copyFileSync(sourcePath, destinationPath); + copyReleaseFile(sourcePath, destinationPath); } else { throw new Error(`Runtime source contains unsupported entry ${sourcePath}.`); } @@ -124,6 +124,26 @@ function copyRuntimeTree(source, destination) { } } +/** Copy one staged file while normalizing portable text bytes. + * @param {string} source Source file in the canonical skill or wrapper manifest. + * @param {string} destination Staged release destination. + * @returns {void} + * @throws {TypeError} When a known text file is not valid UTF-8. + * @sideEffects Creates or replaces the staged file. + * @constraints Known text formats use LF so existing Windows and clean Linux checkouts produce identical archives. + * @why #17 requires release identity to be independent of the checkout platform. + */ +function copyReleaseFile(source, destination) { + const extension = path.extname(source).toLowerCase(); + const bytes = fs.readFileSync(source); + if ([".json", ".md", ".txt", ".yaml", ".yml"].includes(extension)) { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/\r\n/g, "\n"); + fs.writeFileSync(destination, text, "utf8"); + return; + } + fs.writeFileSync(destination, bytes); +} + /** * Stages the portable standalone skill folder. * @param {{name: string, directory: string}} skill Canonical skill identity and source. @@ -159,39 +179,47 @@ function stagePlugin(type, skill, releaseVersion) { } /** - * Creates one ZIP with portable archivers and an optional PowerShell fallback. + * Creates one deterministic ZIP from a staged release directory. * @param {string} source Absolute directory path to archive. * @param {string} destination Absolute ZIP path to create or replace. * @returns {void} - * @throws {Error} When zip, archive-capable tar, and available PowerShell hosts all fail. - * @sideEffects Creates or replaces the destination archive and streams archiver output. + * @throws {Error} When staging contains a symbolic link or unsupported entry. + * @sideEffects Creates or replaces the destination archive. + * @constraints Fixed metadata, sorted names, and stored entries make output independent of host archivers and timestamps. + * @why #17 requires two builds of the same candidate tree to have identical checksums. */ function zipDirectory(source, destination) { - const parent = path.dirname(source); - const base = path.basename(source); - const zip = spawnSync("zip", ["-r", destination, base], { cwd: parent, stdio: "inherit" }); - if (zip.status === 0) { - return; - } - - const tar = spawnSync("tar", ["-a", "-c", "-f", destination, base], { cwd: parent, stdio: "inherit" }); - if (tar.status === 0) { - return; - } - - const escapedSource = source.replaceAll("'", "''"); - const escapedDestination = destination.replaceAll("'", "''"); - const command = `Compress-Archive -LiteralPath '${escapedSource}' -DestinationPath '${escapedDestination}' -Force`; - const fallbackHosts = process.platform === "win32" ? ["pwsh", "powershell"] : ["pwsh"]; + const archiveRoot = path.basename(source); + const entries = listFiles(source).map((relativePath) => ({ + name: `${archiveRoot}/${relativePath}`, + data: fs.readFileSync(path.join(source, relativePath)) + })); + createStoredZip(entries, destination); +} - for (const host of fallbackHosts) { - const powershell = spawnSync(host, ["-NoProfile", "-NonInteractive", "-Command", command], { stdio: "inherit" }); - if (powershell.status === 0) { - return; +/** List regular staged files without following symbolic links. + * @param {string} directory Absolute staged directory. + * @param {string} prefix Slash-separated archive prefix accumulated during recursion. + * @returns {string[]} Sorted paths relative to the staged directory. + * @throws {Error} When staging contains a symbolic link or unsupported entry. + */ +function listFiles(directory, prefix = "") { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + const absolutePath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Release staging contains unsupported symbolic link ${relativePath}.`); + } + if (entry.isDirectory()) { + files.push(...listFiles(absolutePath, relativePath)); + } else if (entry.isFile()) { + files.push(relativePath); + } else { + throw new Error(`Release staging contains unsupported entry ${relativePath}.`); } } - - throw new Error(`Unable to create ZIP ${destination}. Install zip, archive-capable tar, or PowerShell 7 and retry.`); + return files.sort(); } /** diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs new file mode 100644 index 0000000..8eb8d9f --- /dev/null +++ b/scripts/release-preflight.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** Validate the exact uncommitted candidate tree before a generated skill release. + * @since 1.4.0 + * @why #17 requires a reproducible final-tree gate before a release tag creates immutable public identity. + * @constraints Reads repository and remote release state, resets only generated dist, and never commits, tags, pushes, or publishes. + */ +/* global process */ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const tag = process.argv[2]; + +if (!tag || !/^v[0-9]+\.[0-9]+\.[0-9]+$/.test(tag)) { + throw new Error("Usage: npm run release:preflight -- vX.Y.Z"); +} + +const version = tag.slice(1); +const initialTreeDigest = snapshotCandidateTree(); + +validateReleaseIdentity(); +requireUnusedRemoteIdentity(); +runNpm(["run", "validate"]); +resetGeneratedDistribution(); +run("gh", ["skill", "publish", "--dry-run"]); +runNpm(["run", "package", "--", tag]); +runNpm(["run", "release:verify-assets", "--", tag]); +const firstChecksums = fs.readFileSync(path.join(root, "dist", "assets", "SHA256SUMS"), "utf8"); +runNpm(["run", "package", "--", tag]); +runNpm(["run", "release:verify-assets", "--", tag]); +const secondChecksums = fs.readFileSync(path.join(root, "dist", "assets", "SHA256SUMS"), "utf8"); + +if (firstChecksums !== secondChecksums) { + throw new Error("Two release builds produced different archive checksums."); +} + +const finalTreeDigest = snapshotCandidateTree(); +if (finalTreeDigest !== initialTreeDigest) { + throw new Error("Release preflight changed tracked or nonignored untracked content. Review the candidate tree and rerun the preflight."); +} + +console.log(JSON.stringify({ + tag, + version, + releaseTreeSha256: initialTreeDigest, + checksumsSha256: digest(secondChecksums), + result: "pass" +})); +console.log("Commit this exact validated tree, merge it through the protected pull request workflow, then validate and push the annotated tag."); + +function validateReleaseIdentity() { + const packageDocument = readJson("package.json"); + const codexManifest = readJson("packaging/codex-plugin/.codex-plugin/plugin.json"); + const claudeManifest = readJson("packaging/claude-plugin/.claude-plugin/plugin.json"); + for (const [label, actual] of [ + ["package.json", packageDocument.version], + ["Codex plugin manifest", codexManifest.version], + ["Claude plugin manifest", claudeManifest.version] + ]) { + if (actual !== version) { + throw new Error(`${label} version ${actual || ""} does not match ${version}.`); + } + } + + const changelog = fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8"); + const versionDocument = fs.readFileSync(path.join(root, "docs", "VERSION.md"), "utf8"); + const notes = path.join(root, "docs", "releases", `${tag}.md`); + if (!changelog.includes(`## [${tag}]`)) { + throw new Error(`CHANGELOG.md is missing ## [${tag}].`); + } + if (!versionDocument.includes(`Current version: \`${version}\`.`)) { + throw new Error(`docs/VERSION.md does not declare ${version} as current.`); + } + if (!fs.existsSync(notes)) { + throw new Error(`Missing release notes docs/releases/${tag}.md.`); + } +} + +function requireUnusedRemoteIdentity() { + const remoteTag = git(["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`]); + if (remoteTag) { + throw new Error(`Remote tag ${tag} already exists and cannot be reused.`); + } + const releases = JSON.parse(run("gh", ["release", "list", "--limit", "100", "--json", "tagName"], { echo: false })); + if (releases.some((release) => release.tagName === tag)) { + throw new Error(`GitHub Release ${tag} already exists and cannot be reused.`); + } +} + +function resetGeneratedDistribution() { + const distribution = path.resolve(root, "dist"); + if (path.relative(root, distribution) !== "dist") { + throw new Error(`Refusing to reset unsafe generated directory ${distribution}.`); + } + fs.rmSync(distribution, { recursive: true, force: true }); +} + +function snapshotCandidateTree() { + const result = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: null }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(result.stderr.toString("utf8").trim() || "git ls-files failed."); + } + + const files = result.stdout.toString("utf8").split("\0").filter(Boolean).sort(); + const snapshot = crypto.createHash("sha256"); + for (const relativePath of files) { + const absolutePath = path.resolve(root, relativePath); + const containment = path.relative(root, absolutePath); + if (!containment || path.isAbsolute(containment) || containment.startsWith(`..${path.sep}`) || containment === "..") { + throw new Error(`Candidate path escapes the repository: ${relativePath}.`); + } + snapshot.update(relativePath.replaceAll("\\", "/")); + snapshot.update("\0"); + if (!fs.existsSync(absolutePath)) { + snapshot.update("\0"); + continue; + } + const status = fs.lstatSync(absolutePath); + if (!status.isFile()) { + throw new Error(`Candidate tree contains unsupported non-file entry ${relativePath}.`); + } + const blob = run("git", ["hash-object", `--path=${relativePath}`, absolutePath], { echo: false }); + if (!/^[0-9a-f]{40}$/.test(blob)) { + throw new Error(`Git did not return a filtered blob identity for ${relativePath}.`); + } + snapshot.update(blob); + snapshot.update("\0"); + } + return snapshot.digest("hex"); +} + +function readJson(relativePath) { + return JSON.parse(fs.readFileSync(path.join(root, relativePath), "utf8")); +} + +function runNpm(args) { + const npmExecPath = process.env.npm_execpath; + if (npmExecPath) { + return run(process.execPath, [npmExecPath, ...args]); + } + return run(process.platform === "win32" ? "npm.cmd" : "npm", args); +} + +function git(args) { + return run("git", args, { echo: false }); +} + +function run(command, args, { echo = true } = {}) { + const result = spawnSync(command, args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + if (result.error) { + throw result.error; + } + if (echo && result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}.`); + } + return result.stdout.trim(); +} + +function digest(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} diff --git a/scripts/release-state.mjs b/scripts/release-state.mjs new file mode 100644 index 0000000..b36b16a --- /dev/null +++ b/scripts/release-state.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** Guard immutable release tags and their optional abandonment markers. + * @since 1.4.0 + * @why #17 requires tagged release identity, default-branch ancestry, and recovery state to remain auditable. + * @constraints Reads and fetches Git refs; it never creates, moves, or deletes tags or releases. + */ +/* global process */ +import { appendFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +const [command, ...argumentsList] = process.argv.slice(2); +const options = parseOptions(argumentsList); +const defaultBranch = process.env.DEFAULT_BRANCH || "main"; + +if (!["guard", "inspect"].includes(command) || !options.tag || !/^v[0-9]+\.[0-9]+\.[0-9]+$/.test(options.tag)) { + throw new Error("Usage: node scripts/release-state.mjs --tag vX.Y.Z [--github-output path]"); +} + +const state = inspectTag(options.tag, { requireHead: command === "guard" }); +if (command === "guard" && state.marker_state === "matching") { + throw new Error(`${state.tag} is permanently abandoned by ${state.marker}.`); +} +if (options.githubOutput) { + appendFileSync(options.githubOutput, Object.entries(state).map(([key, value]) => `${key}=${value}`).join("\n") + "\n", "utf8"); +} +console.log(JSON.stringify(state)); + +function inspectTag(tag, { requireHead }) { + git(["fetch", "--quiet", "origin", `refs/heads/${defaultBranch}:refs/remotes/origin/${defaultBranch}`, "refs/tags/*:refs/tags/*"]); + const localType = git(["cat-file", "-t", `refs/tags/${tag}`]); + if (localType !== "tag") { + throw new Error(`${tag} must be an annotated tag.`); + } + + const releaseCommit = git(["rev-parse", "--verify", `${tag}^{commit}`]); + if (requireHead) { + const headCommit = git(["rev-parse", "--verify", "HEAD^{commit}"]); + if (releaseCommit !== headCommit) { + throw new Error(`Checked out commit ${headCommit} does not match ${tag} at ${releaseCommit}.`); + } + } + + const ancestry = spawnSync("git", ["merge-base", "--is-ancestor", releaseCommit, `origin/${defaultBranch}`], { encoding: "utf8" }); + if (ancestry.status !== 0) { + throw new Error(`${tag} is not reachable from origin/${defaultBranch}.`); + } + + const remoteLines = git(["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`]).split(/\r?\n/); + const remote = new Map(remoteLines.filter(Boolean).map((line) => { + const [object, ref] = line.split(/\s+/, 2); + return [ref, object]; + })); + const remoteObject = remote.get(`refs/tags/${tag}`); + const remoteCommit = remote.get(`refs/tags/${tag}^{}`); + if (!remoteObject || !remoteCommit || remoteObject === remoteCommit || remoteCommit !== releaseCommit) { + throw new Error(`${tag} must exist remotely as one annotated tag on ${releaseCommit}.`); + } + + const version = tag.slice(1); + const packageDocument = JSON.parse(git(["show", `${releaseCommit}:package.json`])); + const codexManifest = JSON.parse(git(["show", `${releaseCommit}:packaging/codex-plugin/.codex-plugin/plugin.json`])); + const claudeManifest = JSON.parse(git(["show", `${releaseCommit}:packaging/claude-plugin/.claude-plugin/plugin.json`])); + if ([packageDocument.version, codexManifest.version, claudeManifest.version].some((candidate) => candidate !== version)) { + throw new Error(`${tag} does not match every packaged version at ${releaseCommit}.`); + } + git(["cat-file", "-e", `${releaseCommit}:docs/releases/${tag}.md`]); + const changelog = git(["show", `${releaseCommit}:CHANGELOG.md`]); + if (!changelog.includes(`## [${tag}]`)) { + throw new Error(`CHANGELOG.md at ${releaseCommit} is missing ## [${tag}].`); + } + + const marker = `abandoned/${tag}`; + const markerLines = git(["ls-remote", "--tags", "origin", `refs/tags/${marker}`, `refs/tags/${marker}^{}`]).split(/\r?\n/).filter(Boolean); + if (markerLines.length === 0) { + return { tag, version, release_commit: releaseCommit, marker, marker_commit: "", marker_state: "absent" }; + } + const remoteMarker = new Map(markerLines.map((line) => { + const [object, ref] = line.split(/\s+/, 2); + return [ref, object]; + })); + const markerObject = remoteMarker.get(`refs/tags/${marker}`); + const markerCommit = remoteMarker.get(`refs/tags/${marker}^{}`); + if (!markerObject || !markerCommit || markerObject === markerCommit) { + throw new Error(`${marker} must be an annotated tag.`); + } + if (markerCommit !== releaseCommit) { + throw new Error(`${marker} resolves to ${markerCommit}, but ${tag} resolves to ${releaseCommit}; investigate without mutation.`); + } + return { tag, version, release_commit: releaseCommit, marker, marker_commit: markerCommit, marker_state: "matching" }; +} + +function parseOptions(args) { + const parsed = { tag: "", githubOutput: "" }; + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (!value || !["--tag", "--github-output"].includes(flag)) { + throw new Error(`Invalid release-state option near ${flag || ""}.`); + } + parsed[flag === "--tag" ? "tag" : "githubOutput"] = value; + } + return parsed; +} + +function git(args) { + const result = spawnSync("git", args, { encoding: "utf8" }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `git ${args[0]} failed.`); + } + return result.stdout.trim(); +} diff --git a/scripts/validate-skill.mjs b/scripts/validate-skill.mjs index 5d01f2f..56c35a1 100644 --- a/scripts/validate-skill.mjs +++ b/scripts/validate-skill.mjs @@ -42,7 +42,7 @@ function warn(message) { } function readText(relativePath) { - return fs.readFileSync(path.join(root, relativePath), "utf8"); + return fs.readFileSync(path.join(root, relativePath), "utf8").replace(/\r\n/g, "\n"); } function exists(relativePath) { @@ -331,7 +331,7 @@ function validateWorkflowMode() { * @constraints The template validates generated workflows in bootstrap mode and installed workflows in maintenance mode without publishing a release. */ function validateDeliveryContract() { - for (const file of ["INSTALL.md", "docs/GITHUB-CLI.md", "docs/GITHUB-CLI-DELIVERY.md", "scripts/verify-gh-skill-install.mjs", "tests/fixtures/README.md"]) { + for (const file of ["INSTALL.md", "docs/GITHUB-CLI.md", "docs/GITHUB-CLI-DELIVERY.md", "scripts/verify-gh-skill-install.mjs", "scripts/release-preflight.mjs", "scripts/release-state.mjs", "scripts/verify-release-assets.mjs", "scripts/lib/stored-zip.mjs", "tests/fixtures/README.md"]) { if (!exists(file)) { fail(`Missing delivery file ${file}.`); } @@ -361,7 +361,7 @@ function validateDeliveryContract() { } const packageScript = readText("scripts/package-release.mjs"); - for (const expected of ["fileURLToPath(import.meta.url)", "Expected exactly one skills//SKILL.md source", "relative !== \"dist\"", "SHA256SUMS"]) { + for (const expected of ["fileURLToPath(import.meta.url)", "Expected exactly one skills//SKILL.md source", "relative !== \"dist\"", "SHA256SUMS", "createStoredZip", "copyReleaseFile", "replace(/\\r\\n/g, \"\\n\")"]) { if (!packageScript.includes(expected)) { fail(`scripts/package-release.mjs is missing delivery contract: ${expected}.`); } @@ -371,6 +371,15 @@ function validateDeliveryContract() { if (packageManifest.scripts?.["verify:gh-skill"] !== "node scripts/verify-gh-skill-install.mjs") { fail("package.json must expose the platform-neutral GitHub CLI install verifier."); } + for (const [name, expected] of [ + ["release:preflight", "node scripts/release-preflight.mjs"], + ["release:state", "node scripts/release-state.mjs"], + ["release:verify-assets", "node scripts/verify-release-assets.mjs"] + ]) { + if (packageManifest.scripts?.[name] !== expected) { + fail(`package.json must expose ${name} as ${expected}.`); + } + } const workflowRoot = bootstrapMode ? ".template/generated/.github/workflows" : ".github/workflows"; const ciWorkflow = readText(`${workflowRoot}/ci.yml`); @@ -392,12 +401,30 @@ function validateDeliveryContract() { } } - for (const expected of ["types:\n - published", "permissions:\n contents: read", "gh skill install", "scripts/verify-gh-skill-install.mjs", "npm run verify:gh-skill"]) { + for (const expected of ["concurrency:", "release:state -- guard", "release:verify-assets", "Install the exact unpublished tag", "refusing to overwrite it"]) { + if (!releaseWorkflow.includes(expected)) { + fail(`Generated release workflow is missing immutable release contract: ${expected}.`); + } + } + + for (const expected of ["types:\n - published", "permissions:\n contents: read", "gh skill install", "scripts/verify-gh-skill-install.mjs", "npm run verify:gh-skill", "Verify update from previous release", "gh skill update", "Compare released runtime"]) { if (!installWorkflow.includes(expected)) { fail(`Generated install workflow is missing delivery contract: ${expected}.`); } } + for (const [label, text] of [["release", releaseWorkflow], ["install", installWorkflow]]) { + if (text.includes("\\${{")) { + fail(`Generated ${label} workflow contains escaped GitHub expressions that will not execute.`); + } + } + + for (const workflow of walk(".github/workflows").concat(walk(".template/generated/.github/workflows")).filter((file) => file.endsWith(".yml"))) { + if (readText(workflow).includes("actions/setup-node@v6")) { + fail(`${workflow} must use actions/setup-node@v7.`); + } + } + const markdownInstructions = readText(".github/instructions/markdown.instructions.md"); for (const expected of ["one physical source line", "one idea", "one empty line", "Do not hard wrap prose"]) { if (!markdownInstructions.includes(expected)) { diff --git a/scripts/verify-release-assets.mjs b/scripts/verify-release-assets.mjs new file mode 100644 index 0000000..ec54d53 --- /dev/null +++ b/scripts/verify-release-assets.mjs @@ -0,0 +1,121 @@ +/** Verify deterministic release archives without extracting or executing them. + * @since 1.4.0 + * @why #17 requires archive inventory, byte identity, checksum, CRC, newline, and disclosure checks before publication. + * @constraints Reads only generated dist assets and their staged source trees. + */ +/* global process */ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readStoredZip } from "./lib/stored-zip.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const tag = process.argv[2]; + +if (!tag || !/^v[0-9]+\.[0-9]+\.[0-9]+$/.test(tag)) { + throw new Error("Usage: node scripts/verify-release-assets.mjs vX.Y.Z"); +} + +const skillName = discoverSkillName(); +const assets = path.join(root, "dist", "assets"); +const stage = path.join(root, "dist", "stage"); +const archives = [ + { file: `${skillName}-${tag}.zip`, stageRoot: skillName }, + { file: `${skillName}-codex-plugin-${tag}.zip`, stageRoot: `${skillName}-codex-plugin` }, + { file: `${skillName}-claude-plugin-${tag}.zip`, stageRoot: `${skillName}-claude-plugin` } +]; + +verifyChecksums(archives.map(({ file }) => file)); +for (const archive of archives) { + verifyArchive(archive); +} +console.log(`Verified three deterministic release archives for ${skillName} ${tag}.`); + +function discoverSkillName() { + const skillsRoot = path.join(root, "skills"); + const names = fs.readdirSync(skillsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(skillsRoot, entry.name, "SKILL.md"))) + .map((entry) => entry.name); + if (names.length !== 1) { + throw new Error(`Expected one canonical skill, found ${names.length}.`); + } + return names[0]; +} + +function verifyChecksums(files) { + const manifestPath = path.join(assets, "SHA256SUMS"); + const actual = fs.readFileSync(manifestPath, "utf8").trim().split(/\r?\n/).sort(); + const expected = files.map((file) => { + const bytes = fs.readFileSync(path.join(assets, file)); + return `${crypto.createHash("sha256").update(bytes).digest("hex")} ${file}`; + }).sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error("dist/assets/SHA256SUMS does not match the three release archives."); + } +} + +function verifyArchive({ file, stageRoot }) { + const archivePath = path.join(assets, file); + const archiveEntries = readStoredZip(archivePath); + const stagedDirectory = path.join(stage, stageRoot); + const expectedEntries = new Map(listFiles(stagedDirectory).map((relative) => [ + `${stageRoot}/${relative}`, + fs.readFileSync(path.join(stagedDirectory, relative)) + ])); + + if (JSON.stringify([...archiveEntries.keys()]) !== JSON.stringify([...expectedEntries.keys()])) { + throw new Error(`${file} inventory differs from its staged release tree.`); + } + + for (const [name, expected] of expectedEntries) { + const actual = archiveEntries.get(name); + if (!actual?.equals(expected)) { + throw new Error(`${file} entry ${name} differs from its staged release file.`); + } + inspectPublicContent(file, name, actual); + } +} + +function listFiles(directory, prefix = "") { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Release staging contains unsupported symbolic link ${relative}.`); + } + if (entry.isDirectory()) { + files.push(...listFiles(absolute, relative)); + } else if (entry.isFile()) { + files.push(relative); + } else { + throw new Error(`Release staging contains unsupported entry ${relative}.`); + } + } + return files.sort(); +} + +function inspectPublicContent(archive, name, bytes) { + const segments = name.toLowerCase().split("/"); + for (const forbidden of [".git", ".idea", ".intake", ".plans", ".template", "dist", "tmp", "bootstrap"]) { + if (segments.includes(forbidden)) { + throw new Error(`${archive} contains forbidden path ${name}.`); + } + } + + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return; + } + if (text.includes("\r\n")) { + throw new Error(`${archive} entry ${name} contains host-specific CRLF bytes.`); + } + for (const pattern of [/C:\\Users\\/i, /github_pat_[A-Za-z0-9_]+/, /ghp_[A-Za-z0-9]+/, /-----BEGIN [A-Z ]*PRIVATE KEY-----/]) { + if (pattern.test(text)) { + throw new Error(`${archive} entry ${name} contains forbidden local or credential material.`); + } + } +}