From dbafc7d847bb53c793de96f796495e572450d12c Mon Sep 17 00:00:00 2001 From: joe-broadhead <91376460+joe-broadhead@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:51:04 +0100 Subject: [PATCH] chore(release): CI release design B (release kit) --- .github/workflows/release.yml | 518 ++++++++- CONTRIBUTING.md | 104 +- scripts/attach-native.mjs | 56 +- scripts/changelog.mjs | 256 +++-- scripts/check-icons.mjs | 57 +- scripts/check-release-shape.mjs | 238 +++-- scripts/check.mjs | 1 + scripts/native-manifest.mjs | 5 +- scripts/publish-release.mjs | 16 +- scripts/release-commit.mjs | 226 ++-- scripts/release-github.mjs | 541 ++++++++++ scripts/release-kit.test.mjs | 1757 +++++++++++++++++++++++++++++++ scripts/release-pack.mjs | 21 +- scripts/release-rules.mjs | 375 +++++++ scripts/release-version.mjs | 33 + 15 files changed, 3739 insertions(+), 465 deletions(-) create mode 100644 scripts/release-github.mjs create mode 100644 scripts/release-kit.test.mjs create mode 100644 scripts/release-rules.mjs create mode 100644 scripts/release-version.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5798134..3fb8934 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,40 +1,73 @@ name: release -# Creates the GitHub Release for a v* tag. The tag points at a release commit that the release kit built on master -# (scripts/release-commit.mjs); this run verifies that commit and then publishes it. Nothing here touches npm. -# One approval per release: only the `release` job uses the `release` environment, so the owner approves once, after -# every verify job has passed. The verify jobs run on the tag push with a read-only token and no secrets. +# Releases v from a master commit CI passed. Started by hand, never by a tag: +# gh workflow run release.yml --ref master -f sha= [-f native_run=] +# The workflow builds dist/, makes the release commit on a temporary branch release/v with this run's token +# (GitHub signs it), checks it, installs it on every tested host, packs it and stages the Release files. Then it waits +# for the owner's approval of the `release` environment. Only after that does it create the tag v on that commit +# and the GitHub Release. Agents never approve: only the owner approves a release (CONTRIBUTING.md, "Releasing"). +# +# Least privilege: contents: write is held only by `commit`, `publish` and `cleanup`. Jobs are of two kinds, never +# mixed. The ones that run repository or dependency code (`build`, `rebuild`, `hosts`, `pack`) have no write token: their +# permissions are contents: read only, no step gets GH_TOKEN, and setup-node and setup-bun get an empty token. What they +# still expose, and only read-only: actions/checkout requires a token and fetches with the job's contents: read token, +# and its post step, like the upload-artifact steps (which carry the runner's artifact token), runs after repository +# code on the same runner, so that code could reach a read-only token for this public repository, and upload an +# artifact under another name; every download below is by id and is checked again. The jobs that use the API (`plan`, +# `commit`, `verify`, `stage`, `publish`, `cleanup`) check out the exact commit fresh, check that its scripts/ and +# .github/ are the parent's, and run only the kit's scripts. Jobs pass files only as artifacts downloaded by the id the uploading job gave, +# and each is checked again on arrival. No secret is used. Nothing touches npm. on: - push: - tags: ["v*"] + workflow_dispatch: + inputs: + sha: + description: The full sha of the master head whose CI passed, the release commit's parent + required: true + type: string + native_run: + description: For a native plugin, the id of the native workflow run on that sha; empty otherwise + required: false + type: string + default: "" -permissions: - contents: read +permissions: {} concurrency: - group: release-${{ github.ref }} + group: release cancel-in-progress: false env: # Set when the kit is copied into a plugin: the plugin's status RPC id and its skill id (the stock check reads them). PLUGIN_RPC: boxd.machines PLUGIN_SKILL: boxd + PARENT: ${{ inputs.sha }} jobs: - # The OpenCode hosts this release is tested on come from the plugin's own TESTED_HOSTS list, read here, so the matrix - # below never names a version (scripts/tested-hosts.mjs; the list is the plugin's, in src/). - setup: - name: read the tested OpenCode hosts + # Every refusal before anything is built or written: master's head is the sha, CI passed on it, the version and the + # CHANGELOG are ready, the release moves forward, no leftover branch, and the native run is right. A tag the kit made + # on this sha that never became a Release makes this a resume: no new commit, the same tag. Kit scripts only. + plan: + name: plan the release runs-on: ubuntu-24.04 - timeout-minutes: 5 + timeout-minutes: 10 permissions: contents: read + actions: read # the CI run and the native run on the sha outputs: - hosts: ${{ steps.hosts.outputs.hosts }} + version: ${{ steps.plan.outputs.version }} + tag: ${{ steps.plan.outputs.tag }} + hosts: ${{ steps.plan.outputs.hosts }} + native: ${{ steps.plan.outputs.native }} + mode: ${{ steps.plan.outputs.mode }} + commit: ${{ steps.plan.outputs.commit }} + ci_run: ${{ steps.plan.outputs.ci_run }} + manifest_sha256: ${{ steps.plan.outputs.manifest_sha256 }} + manifest_artifact: ${{ steps.manifest.outputs.artifact-id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.sha }} fetch-depth: 0 fetch-tags: true persist-credentials: false @@ -42,20 +75,36 @@ jobs: with: node-version: 24 package-manager-cache: false - - id: hosts - name: Read TESTED_HOSTS from the tag - run: node scripts/tested-hosts.mjs >> "$GITHUB_OUTPUT" + - id: plan + name: Plan, or refuse + env: + GH_TOKEN: ${{ github.token }} + NATIVE_RUN: ${{ inputs.native_run }} + run: node scripts/release-github.mjs plan --sha "$PARENT" --native-run "$NATIVE_RUN" --manifest-out "$RUNNER_TEMP/native/native.json" + - id: manifest + name: Keep the vetted native manifest for the build + if: steps.plan.outputs.native == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: native-manifest-vetted + path: ${{ runner.temp }}/native/native.json + if-no-files-found: error - verify: - name: verify the tag + # Runs npm and the repository's build. No write token, no credentials in the checkout. + build: + name: build the release files + needs: plan + if: needs.plan.outputs.mode == 'new' runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: read - actions: read # the native run's facts, for a native plugin + outputs: + files_artifact: ${{ steps.files.outputs.artifact-id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.sha }} fetch-depth: 0 fetch-tags: true persist-credentials: false @@ -63,35 +112,186 @@ jobs: with: node-version: 24 package-manager-cache: false + token: '' # no token: this job runs repository code - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.4 # the Bun that every stock OpenCode host in TESTED_HOSTS bundles; a repo's build may run bun no-cache: true - - name: "Check the tag (annotated, signed by the owner, v0.0.0, one parent on master, the allowed change, no lifecycle scripts, and the native manifest when there is one)" + token: '' # no token: this job runs repository code + - name: Refuse an empty artifact id + if: needs.plan.outputs.native == 'true' + env: + ARTIFACT_ID: ${{ needs.plan.outputs.manifest_artifact }} + run: case "$ARTIFACT_ID" in ''|*[!0-9]*) echo "::error::the job before named no artifact"; exit 1 ;; esac + # Exactly the artifact the uploading job named, by its id, not one that has the same name. + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + if: needs.plan.outputs.native == 'true' + with: + artifact-ids: ${{ needs.plan.outputs.manifest_artifact }} + path: ${{ runner.temp }}/native + - name: Build dist/, and write the release commit's files + env: + NATIVE: ${{ needs.plan.outputs.native }} + run: | + if [ "$NATIVE" = "true" ]; then manifest="--manifest $RUNNER_TEMP/native/native.json"; else manifest=""; fi + # shellcheck disable=SC2086 + node scripts/release-commit.mjs --sha "$PARENT" --out "$RUNNER_TEMP/release-files" $manifest + - id: files + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-files + path: ${{ runner.temp }}/release-files + if-no-files-found: error + include-hidden-files: true + + # The first write: the temporary branch release/v at the parent, and one commit on it through GitHub's API, + # so GitHub signs it. Kit scripts only. The files are checked again here: paths.txt, the allowed set, package.json, + # and the native manifest byte for byte as the plan vetted it. + commit: + name: make the release commit + needs: [plan, build] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write # the temporary branch and its one commit + outputs: + commit: ${{ steps.commit.outputs.commit }} + tree: ${{ steps.commit.outputs.tree }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + package-manager-cache: false + - name: Refuse an empty artifact id + env: + ARTIFACT_ID: ${{ needs.build.outputs.files_artifact }} + run: case "$ARTIFACT_ID" in ''|*[!0-9]*) echo "::error::the job before named no artifact"; exit 1 ;; esac + # Exactly the artifact the uploading job named, by its id, not one that has the same name. + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.build.outputs.files_artifact }} + path: ${{ runner.temp }}/release-files + - id: commit + name: Commit the release files on release/v, and check what GitHub made env: GH_TOKEN: ${{ github.token }} - run: node scripts/check-release-shape.mjs --tag "$GITHUB_REF_NAME" --branch origin/master --verify github-api --notes "$RUNNER_TEMP/notes.md" - - name: Rebuild the tag's parent and compare dist/ byte for byte + TAG: ${{ needs.plan.outputs.tag }} + NATIVE: ${{ needs.plan.outputs.native }} + MANIFEST_SHA256: ${{ needs.plan.outputs.manifest_sha256 }} + run: node scripts/release-github.mjs commit --files "$RUNNER_TEMP/release-files" --parent "$PARENT" --tag "$TAG" --native "$NATIVE" --manifest-sha256 "$MANIFEST_SHA256" + + # An independent build of the parent, on its own runner. No write token. + rebuild: + name: rebuild the parent + needs: [plan, commit] + if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.commit.result == 'success' || (needs.plan.outputs.mode == 'resume' && needs.commit.result == 'skipped')) }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + outputs: + dist_artifact: ${{ steps.dist.outputs.artifact-id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + package-manager-cache: false + token: '' # no token: this job runs repository code + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 # the Bun that every stock OpenCode host in TESTED_HOSTS bundles; a repo's build may run bun + no-cache: true + token: '' # no token: this job runs repository code + - name: Build the parent + run: npm ci --ignore-scripts && npm run build + - id: dist + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: rebuilt-dist + path: ${{ github.workspace }}/dist + if-no-files-found: error + include-hidden-files: true + + # A fresh checkout of the release commit: its identity (GitHub API), one parent on master, the allowed change, no + # lifecycle scripts, the native manifest, and dist/ byte for byte equal to the independent rebuild. Kit scripts only. + verify: + name: verify the release commit + needs: [plan, commit, rebuild] + if: ${{ !cancelled() && needs.rebuild.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + actions: read # the native run, for a native plugin + env: + COMMIT: ${{ needs.commit.outputs.commit || needs.plan.outputs.commit }} + TAG: ${{ needs.plan.outputs.tag }} + MODE: ${{ needs.plan.outputs.mode }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.commit.outputs.commit || needs.plan.outputs.commit }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + package-manager-cache: false + - name: Refuse a release commit whose scripts/ or .github/ differ from the parent's run: | - parent="$(git rev-parse "$GITHUB_REF_NAME^")" - git worktree add --detach "$RUNNER_TEMP/parent" "$parent" - (cd "$RUNNER_TEMP/parent" && npm ci --ignore-scripts && npm run build) - node scripts/check-release-shape.mjs --tag "$GITHUB_REF_NAME" --branch origin/master --verify github-api --rebuilt "$RUNNER_TEMP/parent" + git diff --quiet "$PARENT" "$COMMIT" -- scripts .github || { echo "::error::the release commit changes scripts/ or .github/, so no kit script runs from it"; exit 1; } + - name: Refuse an empty artifact id + env: + ARTIFACT_ID: ${{ needs.rebuild.outputs.dist_artifact }} + run: case "$ARTIFACT_ID" in ''|*[!0-9]*) echo "::error::the job before named no artifact"; exit 1 ;; esac + # Exactly the artifact the uploading job named, by its id, not one that has the same name. + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.rebuild.outputs.dist_artifact }} + path: ${{ runner.temp }}/parent/dist + - name: Check the release commit, and its dist/ against the rebuild env: GH_TOKEN: ${{ github.token }} + run: | + [ "$(git rev-parse "$COMMIT^")" = "$PARENT" ] || { echo "::error::the release commit's parent is not $PARENT"; exit 1; } + if [ "$MODE" = "resume" ]; then target="--tag $TAG"; else target="--commit $COMMIT --tag-name $TAG"; fi + # shellcheck disable=SC2086 + node scripts/check-release-shape.mjs $target --branch origin/master --verify github-api --identity github-actions --rebuilt "$RUNNER_TEMP/parent" + # The OpenCode hosts of the plugin's TESTED_HOSTS install the exact release commit with `plugin add`, from a tag that + # exists only on this runner. No write token. hosts: name: stock OpenCode ${{ matrix.host }} - needs: [setup, verify] + needs: [plan, commit, verify] + if: ${{ !cancelled() && needs.verify.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 20 + permissions: + contents: read strategy: fail-fast: false matrix: - host: ${{ fromJSON(needs.setup.outputs.hosts) }} + host: ${{ fromJSON(needs.plan.outputs.hosts) }} + env: + COMMIT: ${{ needs.commit.outputs.commit || needs.plan.outputs.commit }} + TAG: ${{ needs.plan.outputs.tag }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.commit.outputs.commit || needs.plan.outputs.commit }} fetch-depth: 0 fetch-tags: true persist-credentials: false @@ -99,22 +299,34 @@ jobs: with: node-version: 24 package-manager-cache: false - - name: Install the tag as a Git plugin on the stock host, in an isolated HOME, and check it loads - run: sh scripts/check-release-plugin-add.sh "${{ matrix.host }}" "$GITHUB_WORKSPACE" "$GITHUB_REF_NAME" "$RUNNER_TEMP/host" "$PLUGIN_SKILL" "$PLUGIN_RPC" + token: '' # no token: this job runs repository code + - name: Refuse a release commit whose scripts/ or .github/ differ from the parent's + run: | + git diff --quiet "$PARENT" "$COMMIT" -- scripts .github || { echo "::error::the release commit changes scripts/ or .github/, so no kit script runs from it"; exit 1; } + - name: Install the release commit as a Git plugin on the stock host, in an isolated HOME, and check it loads + run: | + git rev-parse -q --verify "refs/tags/$TAG" >/dev/null || git tag "$TAG" "$COMMIT" + [ "$(git rev-parse "$TAG^{commit}")" = "$COMMIT" ] || { echo "::error::$TAG is not on the release commit"; exit 1; } + sh scripts/check-release-plugin-add.sh "${{ matrix.host }}" "$GITHUB_WORKSPACE" "$TAG" "$RUNNER_TEMP/host" "$PLUGIN_SKILL" "$PLUGIN_RPC" - release: - name: create the GitHub Release - needs: [verify, hosts] + # Packs the release commit first, with no dependency installed, and keeps that tarball. Then runs the repository's + # own pack stage against a copy of it. No write token: what the pack stage does cannot reach the kept tarball. + pack: + name: pack the release commit + needs: [plan, commit, verify] + if: ${{ !cancelled() && needs.verify.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 30 - environment: release permissions: - contents: write # the Release - actions: read # the native run's artifacts - attestations: read # the attestation of each native file + contents: read + outputs: + tarball_artifact: ${{ steps.tarball.outputs.artifact-id }} + env: + COMMIT: ${{ needs.commit.outputs.commit || needs.plan.outputs.commit }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.commit.outputs.commit || needs.plan.outputs.commit }} fetch-depth: 0 fetch-tags: true persist-credentials: false @@ -122,41 +334,239 @@ jobs: with: node-version: 24 package-manager-cache: false + token: '' # no token: this job runs repository code + - name: Refuse a release commit whose scripts/ or .github/ differ from the parent's + run: | + git diff --quiet "$PARENT" "$COMMIT" -- scripts .github || { echo "::error::the release commit changes scripts/ or .github/, so no kit script runs from it"; exit 1; } + - name: Pack the release commit, before any dependency is installed + run: | + mkdir -p "$RUNNER_TEMP/tarball" + npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/tarball" + - id: tarball + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-tarball + path: ${{ runner.temp }}/tarball + if-no-files-found: error - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.4 # the Bun that every stock OpenCode host in TESTED_HOSTS bundles + bun-version: 1.4.4 # the Bun that every stock OpenCode host in TESTED_HOSTS bundles; a repo's build may run bun no-cache: true - - name: Pack the tag and run the repository's own pack stage against that exact tarball + token: '' # no token: this job runs repository code + - name: Run the repository's own pack stage against a copy of that tarball run: | + mkdir -p "$RUNNER_TEMP/pack-test" + cp "$RUNNER_TEMP"/tarball/*.tgz "$RUNNER_TEMP/pack-test/" npm ci --ignore-scripts - mkdir -p "$RUNNER_TEMP/release" - npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/release" - node scripts/release-pack.mjs --tarball "$(ls "$RUNNER_TEMP"/release/*.tgz)" - # No second gate here, on purpose: the download runs inside the approved release job, and every file is checked - # against the manifest's sizes and sha256s and must carry a verified attestation before it is attached. - # Native plugins only (a repository with no manifest/native.json skips this step). The run is the one the manifest - # pins: its artifacts must match the manifest's sizes and sha256, the gzips must hold the pinned executables, and - # each file must carry a verified attestation. The release job builds nothing (NATIVE-DESIGN §4). + node scripts/release-pack.mjs --tarball "$(ls "$RUNNER_TEMP"/pack-test/*.tgz)" + + # Before the approval, so the owner approves known bytes: the kept tarball, the native files verified and attested, + # SHA256SUMS and the notes, all shown in the run summary. Kit scripts only, on a fresh checkout. + stage: + name: stage the Release files + needs: [plan, commit, hosts, pack] + if: ${{ !cancelled() && needs.hosts.result == 'success' && needs.pack.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + actions: read # the native run's artifacts + attestations: read # the attestation of each native file + outputs: + sums_sha256: ${{ steps.sums.outputs.sha256 }} + artifact_id: ${{ steps.upload.outputs.artifact-id }} + env: + COMMIT: ${{ needs.commit.outputs.commit || needs.plan.outputs.commit }} + TAG: ${{ needs.plan.outputs.tag }} + MODE: ${{ needs.plan.outputs.mode }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.commit.outputs.commit || needs.plan.outputs.commit }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + package-manager-cache: false + - name: Refuse a release commit whose scripts/ or .github/ differ from the parent's + run: | + git diff --quiet "$PARENT" "$COMMIT" -- scripts .github || { echo "::error::the release commit changes scripts/ or .github/, so no kit script runs from it"; exit 1; } + - name: Refuse an empty artifact id + env: + ARTIFACT_ID: ${{ needs.pack.outputs.tarball_artifact }} + run: case "$ARTIFACT_ID" in ''|*[!0-9]*) echo "::error::the job before named no artifact"; exit 1 ;; esac + # Exactly the artifact the uploading job named, by its id, not one that has the same name. + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.pack.outputs.tarball_artifact }} + path: ${{ runner.temp }}/assets/release + - name: Check that the pack job kept one tarball + run: | + files="$(find "$RUNNER_TEMP/assets/release" -type f)" + if [ "$(printf '%s\n' "$files" | grep -c .)" != 1 ] || ! printf '%s\n' "$files" | grep -q '\.tgz$'; then + echo "::error::the release-tarball artifact is not one tarball" + exit 1 + fi + # Native plugins only (a release commit with no manifest/native.json skips this step): the run is the one the + # manifest pins, its artifacts match the manifest's sizes and sha256, the gzips hold the pinned executables, and + # each file carries a verified attestation. - name: Verify and stage the pinned native binaries and licence files if: hashFiles('manifest/native.json') != '' - run: node scripts/attach-native.mjs --out "$RUNNER_TEMP/release/native" + run: node scripts/attach-native.mjs --out "$RUNNER_TEMP/assets/release/native" env: GH_TOKEN: ${{ github.token }} - - name: Write SHA256SUMS and the release notes + - id: sums + name: Write SHA256SUMS, and its own sha256 for the approver run: | - cd "$RUNNER_TEMP/release" + cd "$RUNNER_TEMP/assets/release" list="$RUNNER_TEMP/sums-list.txt" find . -type f ! -name SHA256SUMS | sed 's#^\./##' | sort > "$list" # Each line names the asset (the file's base name, which is how the Release stores it), not its folder. while IFS= read -r file; do sha256sum "$file" | sed "s# .*/# #"; done < "$list" > "$RUNNER_TEMP/SHA256SUMS" mv "$RUNNER_TEMP/SHA256SUMS" SHA256SUMS cat SHA256SUMS - cd "$GITHUB_WORKSPACE" && node scripts/check-release-shape.mjs --tag "$GITHUB_REF_NAME" --branch origin/master --verify github-api --notes "$RUNNER_TEMP/notes.md" + echo "sha256=$(sha256sum SHA256SUMS | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + - name: Write the release notes + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ "$MODE" = "resume" ]; then target="--tag $TAG"; else target="--commit $COMMIT --tag-name $TAG"; fi + # shellcheck disable=SC2086 + node scripts/check-release-shape.mjs $target --branch origin/master --verify github-api --identity github-actions --notes "$RUNNER_TEMP/assets/notes.md" + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-assets + path: ${{ runner.temp }}/assets + if-no-files-found: error + # The approver reads this on the run page. publish downloads exactly this artifact and refuses unless its + # SHA256SUMS has exactly this sha256 and every file matches SHA256SUMS. + - name: Summarise what the approval releases + env: + TREE: ${{ needs.commit.outputs.tree }} + NATIVE_RUN: ${{ inputs.native_run }} + CI_RUN: ${{ needs.plan.outputs.ci_run }} + SUMS_SHA256: ${{ steps.sums.outputs.sha256 }} + ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }} + ARTIFACT_DIGEST: ${{ steps.upload.outputs.artifact-digest }} + run: | + server="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + { + echo "## Approving \`release\` creates the tag $TAG and its GitHub Release" + echo + echo "| | |" + echo "|---|---|" + echo "| Tag | \`$TAG\` (mode: $MODE) |" + echo "| Parent (master, CI passed) | [\`$PARENT\`]($server/commit/$PARENT), CI run [$CI_RUN]($server/actions/runs/$CI_RUN) |" + echo "| Release commit | [\`$COMMIT\`]($server/commit/$COMMIT)$([ "$MODE" = "new" ] && echo " on [\`release/$TAG\`]($server/tree/release/$TAG), tree \`$TREE\`") |" + if [ -n "$NATIVE_RUN" ]; then echo "| Native run | [$NATIVE_RUN]($server/actions/runs/$NATIVE_RUN) |"; fi + echo "| sha256 of SHA256SUMS | \`$SUMS_SHA256\` (publish refuses any other) |" + echo "| Staged artifact | \`release-assets\`, id $ARTIFACT_ID, digest \`$ARTIFACT_DIGEST\` |" + echo + echo "### SHA256SUMS" + echo + echo '```' + cat "$RUNNER_TEMP/assets/release/SHA256SUMS" + echo '```' + echo + echo "### Release notes" + echo + cat "$RUNNER_TEMP/assets/notes.md" + } >> "$GITHUB_STEP_SUMMARY" + + # The gate: the owner approves the `release` environment. Then the staged bytes are checked, everything is checked + # again, and the tag and the Release are created. Builds nothing and runs no repository script. + publish: + name: tag and publish the Release + needs: [plan, commit, stage] + if: ${{ !cancelled() && needs.stage.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: release + permissions: + contents: write # the tag and the Release + actions: read # the native run, for a native plugin + env: + COMMIT: ${{ needs.commit.outputs.commit || needs.plan.outputs.commit }} + TAG: ${{ needs.plan.outputs.tag }} + MODE: ${{ needs.plan.outputs.mode }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.commit.outputs.commit || needs.plan.outputs.commit }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + package-manager-cache: false + - name: Refuse a release commit whose scripts/ or .github/ differ from the parent's + run: | + git diff --quiet "$PARENT" "$COMMIT" -- scripts .github || { echo "::error::the release commit changes scripts/ or .github/, so no kit script runs from it"; exit 1; } + - name: Refuse an empty artifact id + env: + ARTIFACT_ID: ${{ needs.stage.outputs.artifact_id }} + run: case "$ARTIFACT_ID" in ''|*[!0-9]*) echo "::error::the job before named no artifact"; exit 1 ;; esac + # Exactly the artifact the uploading job named, by its id, not one that has the same name. + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.stage.outputs.artifact_id }} + path: ${{ runner.temp }}/assets + - name: Check that SHA256SUMS is the one in the run summary, and every staged file against it + env: + SUMS_SHA256: ${{ needs.stage.outputs.sums_sha256 }} + run: node scripts/release-github.mjs sums --dir "$RUNNER_TEMP/assets/release" --sums-sha256 "$SUMS_SHA256" + # The pack stage ran in another job; this job, after the approval, runs no repository or dependency code. Every + # native file is checked against the release commit's own manifest, executables decompressed. + - name: Check every native file against the release commit's manifest/native.json + run: node scripts/release-github.mjs native --dir "$RUNNER_TEMP/assets/release" --commit "$COMMIT" + - name: Check again, then create the tag on the release commit (or reuse the kit's tag there) + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/release-github.mjs tag --tag "$TAG" --commit "$COMMIT" --parent "$PARENT" --mode "$MODE" + - name: Check the tagged release, and that its notes are the staged ones env: GH_TOKEN: ${{ github.token }} + run: | + node scripts/check-release-shape.mjs --tag "$TAG" --branch origin/master --verify github-api --identity github-actions --notes "$RUNNER_TEMP/notes.md" + cmp "$RUNNER_TEMP/notes.md" "$RUNNER_TEMP/assets/notes.md" # Creates the Release, or, on a re-run, adds the kit's missing files. It never deletes or replaces an asset, and it # leaves assets it does not know (attached later by hand) alone. - name: Create the GitHub Release on the tag, or add its missing kit files - run: node scripts/publish-release.mjs --tag "$GITHUB_REF_NAME" --dir "$RUNNER_TEMP/release" --notes "$RUNNER_TEMP/notes.md" env: GH_TOKEN: ${{ github.token }} + run: node scripts/publish-release.mjs --tag "$TAG" --dir "$RUNNER_TEMP/assets/release" --notes "$RUNNER_TEMP/notes.md" + - name: Delete release/v; the tag keeps the commit + if: env.MODE == 'new' + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/release-github.mjs cleanup --tag "$TAG" --parent "$PARENT" --commit "$COMMIT" + + # Deletes release/v whatever happened: a failed check, a rejected or expired approval, a cancelled run. Only + # when it points at the parent or at this run's release commit; never master, never another commit. + cleanup: + name: delete the temporary branch + needs: [plan, commit, publish] + if: ${{ always() && needs.plan.outputs.mode == 'new' && needs.commit.result != 'skipped' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write # deleting release/v + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.sha }} + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + package-manager-cache: false + - name: Delete release/v if it is still there + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.plan.outputs.tag }} + COMMIT: ${{ needs.commit.outputs.commit }} + run: node scripts/release-github.mjs cleanup --tag "$TAG" --parent "$PARENT" --commit "$COMMIT" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19bb184..1929fd1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,23 +41,95 @@ the laptop's OpenCode, the laptop ledger is the source of truth, and no check ru ## Releasing -Every plugin releases as `v0.0.0`, and it installs from that GitHub tag. The release is one commit on top of a `master` -head that CI passed, and the tag points at it. - -1. Set `version` to `0.0.0` in `package.json`, date the `## [0.0.0]` entry in `CHANGELOG.md`, then run `npm run check`. +Each release is the GitHub tag `v`, where `` is the `version` in `package.json`, and users install from +that tag. The release is one commit on top of a `master` head that CI passed, and the tag points at it. The release +workflow makes both, in GitHub Actions: nobody builds, commits or tags a release on their own machine, and no personal +key signs it. + +While changes land, their notes go under `## [Unreleased]` at the top of `CHANGELOG.md`. Each earlier GitHub release is +a `## [x.y.z] - YYYY-MM-DD` heading below it, newest first, and the npm history stays last, under +`## Earlier releases on npm`. + +1. On `master`, set `version` in `package.json` to the new version, and change every install spec in the README, + `CONTRIBUTING.md` and `docs/` to `#v` (`test/docs.test.ts` fails until they match). Then run + `node scripts/changelog.mjs release`: it turns `## [Unreleased]` into `## [] - `, above the earlier + releases, and refuses a CHANGELOG it cannot shape. Run `npm run check`. 2. Push that commit to `master`, and wait for CI to pass on it. -3. In a clean checkout of `master` at that commit, run `node scripts/release-commit.mjs --ci-sha `. It builds - `dist/`, removes the lifecycle scripts, and creates the signed commit and the signed annotated tag `v0.0.0`. It never - pushes; `--dry-run` shows the change first. -4. Push the tag only: `git push origin v0.0.0`. -5. Approve the run in the `release` environment. The release workflow checks the tag and its commit, installs the tag on - each stock OpenCode host (2.2.3 to 2.2.6), packs it, and creates the GitHub Release with the notes, the tarball and - `SHA256SUMS`. - -A tag that already exists stops step 3. Delete the old tag and its Release first, then start again. - - -Each release has its own tag, and a Git install does not update by itself. To move to a newer release, remove the old entry with `opencode plugin remove github:joe-broadhead/opencode-boxd#v0.0.0`, then add the new tag with `opencode plugin add github:joe-broadhead/opencode-boxd#v`. +3. Start the release with that commit's full sha: + `gh workflow run release.yml --ref master -f sha=`, or from Actions → release → + Run workflow, on `master`. +4. Read the run's summary, then approve the `release` environment. The summary is on the run page, under the `stage` + job: the tag, the parent and its CI run, the release commit and its tree, the sha256 of `SHA256SUMS` and the staged + artifact's id, `SHA256SUMS` itself, and the notes. Open the release commit on `release/v` and read them + before approving: the approval releases exactly those bytes. Only the owner approves a release. Agents never approve + one, never call the deployment-approval API, and never bypass a ruleset or the environment; the kit's scripts make + only their own GitHub calls and refuse every other. +5. Check the published Release: not a draft, its notes, and every asset against `SHA256SUMS`. + +Before the approval, the workflow: +- **plans:** refuses a run that is not on `master`, a sha that is not `master`'s head, a sha without a successful `ci.yml` + push run, a version that is not `x.y.z`, a CHANGELOG whose newest release is not this version, a release that does not + move forward from the last published Release, a leftover `release/v` branch, a native run that did not build + this sha on `master`, by hand, with success, and a missing `native_run` (or one given to a plugin that is not native); +- **builds** `dist/` with `npm ci --ignore-scripts` and `npm run build`, with a read-only token, and writes the release + files: `dist/`, `package.json` without its lifecycle scripts, and the vetted native manifest; +- **commits** them on a temporary branch `release/v` at the parent, through GitHub's API with the workflow's token, + so GitHub signs the commit (author `github-actions[bot]`, committer GitHub). This job runs no npm. It refuses a commit + that GitHub does not verify, or whose parent or tree is not the one the files give; +- **verifies** on a fresh runner: the commit's identity, one parent on `master`, `package.json` exactly the parent's + without the lifecycle scripts, only regular files in the allowed paths, no `package.json` under `dist/`, and a second, + independent build of the parent (its own job) equal to the committed `dist/` byte for byte; +- **installs** the release commit with `plugin add` on each stock OpenCode host (2.2.3 to 2.2.6); +- **packs** the commit before installing anything and keeps that tarball, then runs the pack stage against a copy; +- **stages** the Release: verifies the attested native files, writes `SHA256SUMS` and the notes (the version's CHANGELOG + section), and shows them all in the run summary with the sha256 of `SHA256SUMS`. + +A job either runs repository or dependency code (`npm ci`, the build, the pack stage, a host install) and has no write +token, or uses the API and runs only the kit's scripts on a fresh checkout, after checking that the release commit's +`scripts/` and `.github/` are the parent's. The first kind is not token-free: `actions/checkout` fetches with the job's +read-only (`contents: read`) token, and its post step and the artifact uploads run after repository code on the same +runner, so that code could reach a read-only token for this public repository and the runner's artifact token. Jobs +pass files as artifacts downloaded by the id the uploading job gave, and each is checked again on arrival. + +After the approval, it downloads the staged artifact by the id the summary names, and refuses unless `SHA256SUMS` has +the sha256 the summary shows and every staged file matches it, and every native file is the one the release commit's +`manifest/native.json` pins, size and sha256, with each gzip holding the pinned executable. Then it checks everything again, creates the annotated tag +`v` on the release commit (tagger `github-actions[bot]`, unsigned), checks the tag, creates the Release with the +notes, the tarball, the native files and `SHA256SUMS`, and deletes `release/v`. The tag keeps the commit. + +GitHub's signature and the bot identity show only that GitHub made the commit through its API for the workflow; anyone +with write access can make such a commit. What ties the release to `master` is its content: one parent CI passed, the +exact `package.json`, the allowed paths and the byte-for-byte rebuild. Never relax those because the commit is signed. + +**A rejected, expired or failed run** leaves nothing behind: the `cleanup` job deletes `release/v` when it points +at the parent or at that run's commit, and never touches another branch. No tag exists before the approval, so the +version is not burned: fix `master` and start again. If `cleanup` itself failed, the next run refuses until the branch +is deleted: `gh api -X DELETE repos/joe-broadhead/opencode-boxd/git/refs/heads/release/v`. + +**Resume.** If the run fails after creating the tag and before the Release is complete, start it again with the same `sha`. The plan finds the kit's tag on a release commit on that sha with no published Release, and resumes: +it makes no new commit, checks and installs the tagged commit, stages the files, and after a new approval reuses the tag +and adds what the Release lacks. Delete a draft Release a failed upload left first: the publish step refuses a draft. +Any other existing tag is refused: release the next version. + +The repository's tag ruleset keeps a `v*` tag from being moved or deleted. A stray `v*` tag, one that never became a +Release, does not block later releases: only published Releases count, and drafts do not. Its version cannot be +released, so release the next one. To remove the tag anyway, an admin disables the `release tags` ruleset, deletes the +tag, and restores the ruleset exactly as it was. + +**Known limit: a `v*` tag pushed by hand.** No ruleset limits who creates a `v*` tag, so anyone with write access can +push one by hand, pointing at any commit. Such a tag has no published Release, the forward-only check ignores it, and +Jelly installs only a published Release whose `SHA256SUMS` it verifies. But a user who installs +`github:joe-broadhead/opencode-boxd#v` directly gets whatever that tag points at. Only the release workflow makes +release tags; never push one by hand. + +The release kit is these files, the same in every plugin: `scripts/release-rules.mjs`, `scripts/release-commit.mjs`, +`scripts/release-github.mjs`, `scripts/check-release-shape.mjs`, `scripts/changelog.mjs`, `scripts/release-version.mjs`, +`scripts/release-pack.mjs`, `scripts/publish-release.mjs`, `scripts/attach-native.mjs`, `scripts/native-manifest.mjs`, +`scripts/tested-hosts.mjs`, `scripts/check-release-plugin-add.sh`, `scripts/release-kit.test.mjs` and +`.github/workflows/release.yml`. Do not re-run the older `apply-release-kit.mjs`: it writes back the kit that could +release only `v0.0.0`. + +Each release has its own tag, and a Git install does not update by itself. To move to a newer release, remove the old entry with `opencode plugin remove github:joe-broadhead/opencode-boxd#v`, then add the new tag with `opencode plugin add github:joe-broadhead/opencode-boxd#v`. Or add the plugin to `opencode.json` instead, with the same spec: diff --git a/scripts/attach-native.mjs b/scripts/attach-native.mjs index c471484..00fbcad 100644 --- a/scripts/attach-native.mjs +++ b/scripts/attach-native.mjs @@ -8,14 +8,14 @@ // Per file: size and sha256 equal the manifest; the attestation verifies with `gh attestation verify`. Per module, the // gzip decompresses to the executable the manifest pins (size and sha256). Any mismatch stops the release. A repository // with no manifest has nothing to attach: the script says so and exits 0. -import { execFileSync } from "node:child_process" import { createHash } from "node:crypto" -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync } from "node:fs" +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs" import { gunzipSync } from "node:zlib" import { join } from "node:path" import { tmpdir } from "node:os" import { fileURLToPath } from "node:url" import { assetUrl, manifestProblems, MANIFEST_PATH } from "./native-manifest.mjs" +import { ghOutput } from "./release-rules.mjs" function parseArgs(argv) { const options = { out: undefined, repo: process.env.GITHUB_REPOSITORY, manifest: MANIFEST_PATH } @@ -54,33 +54,39 @@ function main() { if (!options.repo) throw new Error("the repository is not known: set GITHUB_REPOSITORY or pass --repo") const downloads = mkdtempSync(join(tmpdir(), "native-artifacts-")) - execFileSync("gh", ["run", "download", String(manifest.built.run), "--repo", options.repo, "--dir", downloads], { stdio: "inherit" }) - const found = filesByName(downloads) - mkdirSync(options.out, { recursive: true }) + let staged + try { + ghOutput(["run", "download", String(manifest.built.run), "--repo", options.repo, "--dir", downloads], { inherit: true }) + const found = filesByName(downloads) + mkdirSync(options.out, { recursive: true }) - const entries = [] - for (const [module, platforms] of Object.entries(manifest.modules)) { - for (const [platform, entry] of Object.entries(platforms)) entries.push({ label: `${module} ${platform}`, entry, gzipped: true }) - } - for (const licence of manifest.licences ?? []) entries.push({ label: `licence ${licence.name}`, entry: licence, gzipped: false }) + const entries = [] + for (const [module, platforms] of Object.entries(manifest.modules)) { + for (const [platform, entry] of Object.entries(platforms)) entries.push({ label: `${module} ${platform}`, entry, gzipped: true }) + } + for (const licence of manifest.licences ?? []) entries.push({ label: `licence ${licence.name}`, entry: licence, gzipped: false }) - for (const { label, entry, gzipped } of entries) { - const candidates = found.get(entry.name) ?? [] - if (candidates.length !== 1) throw new Error(`${label}: the run's artifacts hold ${candidates.length} files named ${entry.name}; one is needed`) - const [path] = candidates - const bytes = readFileSync(path) - if (bytes.length !== entry.bytes) throw new Error(`${label}: ${entry.name} is ${bytes.length} bytes; the manifest pins ${entry.bytes}`) - if (sha256(bytes) !== entry.sha256) throw new Error(`${label}: ${entry.name} does not match the manifest's sha256`) - if (gzipped) { - const executable = gunzipSync(bytes) - if (executable.length !== entry.executable.bytes) throw new Error(`${label}: the executable is ${executable.length} bytes; the manifest pins ${entry.executable.bytes}`) - if (sha256(executable) !== entry.executable.sha256) throw new Error(`${label}: the executable does not match the manifest's sha256`) + for (const { label, entry, gzipped } of entries) { + const candidates = found.get(entry.name) ?? [] + if (candidates.length !== 1) throw new Error(`${label}: the run's artifacts hold ${candidates.length} files named ${entry.name}; one is needed`) + const [path] = candidates + const bytes = readFileSync(path) + if (bytes.length !== entry.bytes) throw new Error(`${label}: ${entry.name} is ${bytes.length} bytes; the manifest pins ${entry.bytes}`) + if (sha256(bytes) !== entry.sha256) throw new Error(`${label}: ${entry.name} does not match the manifest's sha256`) + if (gzipped) { + const executable = gunzipSync(bytes) + if (executable.length !== entry.executable.bytes) throw new Error(`${label}: the executable is ${executable.length} bytes; the manifest pins ${entry.executable.bytes}`) + if (sha256(executable) !== entry.executable.sha256) throw new Error(`${label}: the executable does not match the manifest's sha256`) + } + ghOutput(["attestation", "verify", path, "--repo", options.repo], { inherit: true }) + copyFileSync(path, join(options.out, entry.name)) + console.log(`ok ${label}: ${entry.name} (${bytes.length} bytes, attested) from ${assetUrl(manifest, entry)}`) } - execFileSync("gh", ["attestation", "verify", path, "--repo", options.repo], { stdio: "inherit" }) - copyFileSync(path, join(options.out, entry.name)) - console.log(`ok ${label}: ${entry.name} (${bytes.length} bytes, attested) from ${assetUrl(manifest, entry)}`) + staged = entries.length + } finally { + rmSync(downloads, { recursive: true, force: true }) } - console.log(`attach-native: ${entries.length} files verified and staged in ${options.out}`) + console.log(`attach-native: ${staged} files verified and staged in ${options.out}`) } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { diff --git a/scripts/changelog.mjs b/scripts/changelog.mjs index 349f367..5a7b7ae 100644 --- a/scripts/changelog.mjs +++ b/scripts/changelog.mjs @@ -1,28 +1,61 @@ -// The CHANGELOG at v0.0.0 (GH-RELEASE-BRIEF, "CHANGELOG shape at v0.0.0"): one release heading, `## [0.0.0] - `; the -// npm history under `## Earlier releases on npm` as `### npm - `; and the release notes, read from the -// release heading. apply-release-kit.mjs shapes a repo's CHANGELOG with this, and check-release-shape.mjs reads the notes. -// It works on text only and has no dependencies. +#!/usr/bin/env node +// The CHANGELOG's release shape. Each GitHub release is a top-level `## [x.y.z] - YYYY-MM-DD` heading, newest first. +// While a release is being prepared, its notes sit under `## [Unreleased]`, above the released ones. The npm history +// stays under `## Earlier releases on npm`, the last section, as `### npm - ` headings. The release +// notes are the section of the version being released. check-release-shape.mjs and release-github.mjs read the notes +// and the problems; it works on text only. +// +// node scripts/changelog.mjs release [--date YYYY-MM-DD] turns `## [Unreleased]` into `## [] - `, for +// package.json's version (the date is today's, UTC, by default) +// node scripts/changelog.mjs check prints what the release of package.json's version would refuse +// +// Known limits: the Markdown scan follows CommonMark for fences at the top level only (up to three spaces of +// indentation), and for `## ` headings at the start of a line. It does not read container blocks, so a fence inside a +// list item indented by four or more spaces is not seen as a fence. It does not read setext headings (a line of text +// underlined with `---` or `===`), or ATX headings written with a tab or with closing `#`s. +import { readFileSync, writeFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { compareVersions, RELEASE_VERSION, VERSION_SOURCE } from "./release-version.mjs" -export const VERSION = "0.0.0" +export const UNRELEASED_HEADING = "## [Unreleased]" export const EARLIER_HEADING = "## Earlier releases on npm" -export const EARLIER_PREFACE = - "These versions were published to npm before the plugin moved to GitHub releases. Their Git tags have been removed." -// Written under the release heading, so a later run finds the release even when no npm history sits below it. -export const MARKER = "" -const VERSION_HEADING = /^## \[(\d+\.\d+\.\d+)\](?: - (.+))?$/ +const VERSION_HEADING = new RegExp(`^## \\[(${VERSION_SOURCE})\\](?: - (.+))?$`) const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ -const FENCE = /^(```|~~~)/ +// CommonMark fences: up to three spaces of indentation, then three or more backticks or tildes. A backtick fence's info +// string has no backtick. The closing fence uses the same character, at least as many, and nothing after but spaces. +const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/ +const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/ +const INDENTED_HEADING = /^ {1,3}## / -/** Indexes of the `## ` heading lines, skipping fenced code. */ -function headingIndexes(lines) { - const found = [] - let fenced = false +/** + * The `## ` heading lines outside fenced code, the `## ` headings indented by one to three spaces (CommonMark renders + * them as headings too), and the line of a fence that is never closed (CommonMark runs it to the end of the file). + */ +function scan(lines) { + const headings = [] + const indented = [] + let fence lines.forEach((line, index) => { - if (FENCE.test(line)) fenced = !fenced - else if (!fenced && line.startsWith("## ")) found.push(index) + if (fence) { + const close = FENCE_CLOSE.exec(line) + if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = undefined + return + } + const open = FENCE_OPEN.exec(line) + if (open && !(open[1][0] === "`" && open[2].includes("`"))) fence = { char: open[1][0], length: open[1].length, line: index } + else if (line.startsWith("## ")) headings.push(index) + else if (INDENTED_HEADING.test(line)) indented.push(index) }) - return found + return { headings, indented, unclosed: fence?.line } +} + +/** What the text's Markdown structure hides from the release shape: an unclosed fence, or an indented heading. */ +function structureProblems(lines, { indented, unclosed }) { + const problems = [] + if (unclosed !== undefined) problems.push(`the code fence on line ${unclosed + 1} is never closed, so everything after it is code`) + for (const index of indented) problems.push(`line ${index + 1} is a heading indented by spaces: start "${lines[index].trim()}" at the line's beginning`) + return problems } function trimBlank(lines) { @@ -33,129 +66,142 @@ function trimBlank(lines) { return lines.slice(start, end) } -/** Blocks of lines, one blank line between the non-empty ones. */ -function joinBlocks(blocks) { - return blocks - .filter((block) => block.length > 0) - .reduce((out, block) => (out.length ? [...out, "", ...block] : [...block]), []) -} - -/** `### ` and deeper headings move down one level, outside code fences. */ -function demote(lines) { - let fenced = false - return lines.map((line) => { - if (FENCE.test(line)) { - fenced = !fenced - return line - } - return !fenced && /^#{3,5} /.test(line) ? `#${line}` : line - }) +function trimEnd(lines) { + let end = lines.length + while (end > 0 && lines[end - 1].trim() === "") end-- + return lines.slice(0, end) } function classify(heading, body) { - if (heading === "## [Unreleased]") return { kind: "unreleased", heading, body } + if (heading === UNRELEASED_HEADING) return { kind: "unreleased", heading, body } if (heading === EARLIER_HEADING) return { kind: "earlier", heading, body } const match = VERSION_HEADING.exec(heading) if (match) return { kind: "version", heading, version: match[1], date: match[2] ?? null, body } return { kind: "other", heading, body } } -/** The body of a section: no marker line, and no blank lines at either end. */ -function bodyOf(section) { - return trimBlank(section.body.filter((line) => line !== MARKER)) -} - -/** The lines before the first `## ` heading, and one section per `## ` heading. */ +/** The lines before the first `## ` heading, one section per `## ` heading, and what the structure hides. */ export function parseChangelog(text) { const lines = text.split("\n") - const starts = headingIndexes(lines) + const found = scan(lines) + const starts = found.headings const preamble = lines.slice(0, starts.length ? starts[0] : lines.length) const sections = starts.map((start, i) => { const end = i + 1 < starts.length ? starts[i + 1] : lines.length return classify(lines[start], lines.slice(start + 1, end)) }) - return { preamble, sections } -} - -/** - * The release heading. An earlier run's marker names it. Otherwise, with the npm history already under the Earlier section, - * it is the first version heading when that is 0.0.0. Otherwise it is a 0.0.0 first in the file that is unpublished (no - * date, or Unreleased) or dated the release date. A 0.0.0 dated on an earlier day is published history: never re-dated. - */ -function findRelease(sections, date, today) { - const versions = sections.filter((s) => s.kind === "version") - const marked = versions.find((s) => trimBlank(s.body)[0] === MARKER) - if (marked) return marked - const first = versions[0] - if (!first || first.version !== VERSION) return undefined - if (sections.some((s) => s.kind === "earlier")) return first - const unpublished = first.date === null || first.date === "Unreleased" - return unpublished || first.date === (date ?? today) ? first : undefined + return { preamble, sections, structure: structureProblems(lines, found) } } /** - * The CHANGELOG in shape. `date` is an explicit release date (it re-dates the unpublished release only); without it the - * release keeps its date, or takes `today`. Throws on a heading it does not know, so the caller writes nothing. - * Shaping a shaped CHANGELOG changes nothing. + * The CHANGELOG with `## [Unreleased]` released as `## [] - `, above every earlier release. Every other + * section keeps its text. When the version already has its heading and nothing is unreleased, the text is returned + * unchanged, so a second run changes nothing. Throws, so the caller writes nothing, on a heading the shape does not + * know, on an empty or repeated Unreleased section, on Unreleased notes next to a heading for the version, and on a + * release newer than the version. */ -export function shapeChangelog(text, { date, today }) { - const { preamble, sections } = parseChangelog(text) +export function shapeChangelog(text, { version, date }) { + if (!RELEASE_VERSION.test(version ?? "")) throw new Error(`the version ${JSON.stringify(version)} is not x.y.z`) + if (!ISO_DATE.test(date ?? "")) throw new Error(`the date ${JSON.stringify(date)} is not YYYY-MM-DD`) + const { preamble, sections, structure } = parseChangelog(text) + if (structure.length) throw new Error(`${structure[0]}: edit CHANGELOG.md by hand`) const unknown = sections.find((s) => s.kind === "other") if (unknown) throw new Error(`"${unknown.heading}" is not a heading the release shape knows: edit CHANGELOG.md by hand`) - const earlier = sections.filter((s) => s.kind === "earlier") - if (earlier.length > 1) throw new Error("CHANGELOG.md has more than one Earlier releases section") + const unreleased = sections.filter((s) => s.kind === "unreleased") + if (unreleased.length > 1) throw new Error(`CHANGELOG.md has ${unreleased.length} ${UNRELEASED_HEADING} sections: merge them by hand`) const versions = sections.filter((s) => s.kind === "version") - const release = findRelease(sections, date, today) - for (const version of versions) { - if (version !== release && (!version.date || version.date === "Unreleased")) { - throw new Error(`"${version.heading}" has no release date, so it cannot be moved as published history: edit CHANGELOG.md by hand`) - } + const existing = versions.filter((s) => s.version === version) + if (!unreleased.length) { + if (existing.length) return text + throw new Error(`CHANGELOG.md has no ${UNRELEASED_HEADING} section to release as ${version}`) } - const releaseDate = date ?? (release && release.date && ISO_DATE.test(release.date) ? release.date : today) - const folded = sections.filter((s) => s.kind === "unreleased").map((s) => trimBlank(s.body)) - const releaseBody = joinBlocks([...folded, release ? bodyOf(release) : []]) - const history = versions - .filter((s) => s !== release) - .map((s) => { - const body = bodyOf(s) - return [`### npm ${s.version} - ${s.date}`, ...(body.length ? ["", ...demote(body)] : [])] - }) - const existing = earlier.length ? bodyOf(earlier[0]) : [] - const keptEntries = existing[0] === EARLIER_PREFACE ? trimBlank(existing.slice(1)) : existing + if (existing.length) throw new Error(`CHANGELOG.md has both ${UNRELEASED_HEADING} and a ## [${version}] heading: merge them by hand`) + const newer = versions.find((s) => compareVersions(s.version, version) >= 0) + if (newer) throw new Error(`"${newer.heading}" is not older than ${version}: a release goes above every earlier one`) + const notes = trimEnd(unreleased[0].body) + if (!trimBlank(notes).length) throw new Error(`the ${UNRELEASED_HEADING} section has no notes`) const blocks = [] - const head = trimBlank(preamble) + const head = trimEnd(preamble) if (head.length) blocks.push(head) - blocks.push([`## [${VERSION}] - ${releaseDate}`, MARKER, ...(releaseBody.length ? ["", ...releaseBody] : [])]) - if (history.length || keptEntries.length) { - blocks.push([EARLIER_HEADING, "", ...joinBlocks([[EARLIER_PREFACE], joinBlocks(history), keptEntries])]) - } - return blocks.map((block) => block.join("\n")).join("\n\n") + "\n" + blocks.push([`## [${version}] - ${date}`, ...notes]) + for (const section of sections.filter((s) => s.kind !== "unreleased")) blocks.push([section.heading, ...trimEnd(section.body)]) + return `${blocks.map((block) => block.join("\n")).join("\n\n")}\n` } -/** What the release would refuse about a CHANGELOG (empty means it is in shape). */ -export function changelogProblems(text) { - const { sections } = parseChangelog(text) - const problems = [] +/** What the release of `version` would refuse about a CHANGELOG (empty means it is in shape). */ +export function changelogProblems(text, version) { + if (!RELEASE_VERSION.test(version ?? "")) return [`the version ${JSON.stringify(version)} is not x.y.z`] + const { sections, structure } = parseChangelog(text) + const problems = [...structure] for (const section of sections.filter((s) => s.kind === "other")) { problems.push(`"${section.heading}" is not a heading the release shape knows`) } - if (sections.some((s) => s.kind === "unreleased")) problems.push("an ## [Unreleased] section is still there") + if (sections.some((s) => s.kind === "unreleased")) { + problems.push(`an ${UNRELEASED_HEADING} section is still there: run node scripts/changelog.mjs release`) + } + const earlier = sections.filter((s) => s.kind === "earlier") + if (earlier.length > 1) problems.push(`there are ${earlier.length} "${EARLIER_HEADING}" sections; there may be one`) + if (earlier.length && sections.at(-1) !== earlier.at(-1)) problems.push(`"${EARLIER_HEADING}" must be the last section`) const versions = sections.filter((s) => s.kind === "version") - const release = versions.filter((s) => s.version === VERSION) - if (release.length !== 1) problems.push(`expected one ## [${VERSION}] heading, found ${release.length}`) - if (versions.length !== release.length) { - problems.push(`${versions.length - release.length} other ## [x.y.z] heading(s): they belong under "${EARLIER_HEADING}" as ### npm headings`) + for (const section of versions.filter((s) => !ISO_DATE.test(s.date ?? ""))) { + problems.push(`"${section.heading}" is not dated as ## [x.y.z] - YYYY-MM-DD`) + } + versions.forEach((section, index) => { + const next = versions[index + 1] + if (next && compareVersions(section.version, next.version) <= 0) { + problems.push(`"${section.heading}" is above "${next.heading}": GitHub releases go newest first, once each`) + } + }) + const release = versions.filter((s) => s.version === version) + if (release.length !== 1) problems.push(`expected one ## [${version}] heading, found ${release.length}`) + else { + if (versions[0] !== release[0]) problems.push(`## [${version}] is not the newest release heading`) + if (!trimBlank(release[0].body).length) problems.push(`the ## [${version}] section has no notes`) } - if (release.length === 1 && bodyOf(release[0]).length === 0) problems.push(`the ## [${VERSION}] section has no notes`) return problems } -/** The release notes: the `## [0.0.0]` section, up to the next `## ` heading (fenced code is not a heading), without the marker. */ -export function releaseNotes(text) { +/** The release notes of `version`: its `## [x.y.z]` section, up to the next `## ` heading (fenced code is not a heading). */ +export function releaseNotes(text, version) { const lines = text.split("\n") - const starts = headingIndexes(lines) - const start = starts.find((index) => /^## \[0\.0\.0\](?: - .*)?$/.test(lines[index])) - if (start === undefined) throw new Error(`CHANGELOG.md has no "## [${VERSION}]" heading`) + const found = scan(lines) + const structure = structureProblems(lines, found) + if (structure.length) throw new Error(`CHANGELOG.md: ${structure[0]}`) + const starts = found.headings + const start = starts.find((index) => VERSION_HEADING.exec(lines[index])?.[1] === version) + if (start === undefined) throw new Error(`CHANGELOG.md has no "## [${version}]" heading`) const next = starts.find((index) => index > start) - return trimBlank(lines.slice(start, next ?? lines.length).filter((line) => line !== MARKER)).join("\n") + return trimBlank(lines.slice(start, next ?? lines.length)).join("\n") +} + +function main(argv) { + const [command, ...rest] = argv + const version = JSON.parse(readFileSync("package.json", "utf8")).version + const text = readFileSync("CHANGELOG.md", "utf8") + if (command === "release") { + const at = rest.indexOf("--date") + if (rest.length !== (at >= 0 ? 2 : 0)) throw new Error("usage: changelog.mjs release [--date YYYY-MM-DD]") + const date = at >= 0 ? rest[at + 1] : new Date().toISOString().slice(0, 10) + const shaped = shapeChangelog(text, { version, date }) + const problems = changelogProblems(shaped, version) + if (problems.length) throw new Error(`CHANGELOG.md would still not be in the release shape: ${problems.join("; ")}`) + if (shaped === text) console.log(`changelog: CHANGELOG.md already releases ${version}; nothing changed`) + else { + writeFileSync("CHANGELOG.md", shaped) + console.log(`changelog: ${UNRELEASED_HEADING} is now ## [${version}] - ${date}`) + } + } else if (command === "check" && rest.length === 0) { + const problems = changelogProblems(text, version) + if (problems.length) throw new Error(`CHANGELOG.md is not in the release shape for ${version}: ${problems.join("; ")}`) + console.log(`changelog: CHANGELOG.md is in the release shape for ${version}`) + } else throw new Error("usage: changelog.mjs release [--date YYYY-MM-DD] | changelog.mjs check") +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + main(process.argv.slice(2)) + } catch (error) { + console.error(`changelog: ${error.message}`) + process.exit(1) + } } diff --git a/scripts/check-icons.mjs b/scripts/check-icons.mjs index ed0f8b2..741f272 100644 --- a/scripts/check-icons.mjs +++ b/scripts/check-icons.mjs @@ -1,11 +1,20 @@ #!/usr/bin/env node // check-icons.mjs -- the icon contract for the @joe-broadhead OpenCode plugins. // -// Contract: CONTRACT.md revision 1 (2026-11-06), in the family's icon kit +// Contract: CONTRACT.md revision 2 (2026-11-12), in the family's icon kit // (the family's shared icon set). Copy this file verbatim into a plugin as // scripts/check-icons.mjs; do not edit the copy. check-icons-copy.mjs in the // kit verifies a copy is byte-identical. // +// Revision 2 adds one opt-in, `opencode.vendor.mark: true`, for a package whose +// icon.svg is the vendor's own mark, shipped as the vendor publishes it, under +// the vendor's brand guidelines. For such a package the family tile rule does +// not apply: the root must be a square viewBox of any size (the vendor's own), +// path numbers are bounded by 4 x that viewBox width, and README.md must say +// "not affiliated with " and carry a trademark line. Every other +// rule, and the sanitizer block below, is unchanged. Without `mark`, the +// family rules are exactly revision 1's. +// // The sanitizer below is Jelly's, copied from // protocol/contracts/src/plugin-icon.ts (function cleanPluginIcon, lines // 22-160, and the ELEMENTS / ATTRIBUTES sets and the TAG, ATTRIBUTE, LABEL @@ -34,7 +43,7 @@ import { createHash } from "node:crypto" import path from "node:path" import { fileURLToPath } from "node:url" -const CONTRACT_REVISION = 1 +const CONTRACT_REVISION = 2 const SELF = fileURLToPath(import.meta.url) // >>> SHARED: plugin-icon.ts + plugin-icon.test.ts >>> @@ -231,7 +240,7 @@ function sanitizerProblems() { // <<< SHARED: plugin-icon.ts + plugin-icon.test.ts <<< -// --- the contract, revision 1 ---------------------------------------------- +// --- the contract, revision 2 ---------------------------------------------- const CANVAS = 48 const RADIUS = 11 @@ -381,6 +390,8 @@ const root = argDir ? path.resolve(argDir) : process.cwd() const manifestPath = path.join(root, "package.json") let manifest = null +let vendorMark = false +let vendorName = "" try { manifest = JSON.parse(readFileSync(manifestPath, "utf8")) } catch (error) { @@ -457,6 +468,8 @@ if (manifest) { } // opencode.vendor: only for a vendor's service, and then a bare domain. + vendorMark = opencode?.vendor?.mark === true && !OWN_PACKAGES.has(manifest.name) + vendorName = typeof opencode?.vendor?.name === "string" ? opencode.vendor.name.trim() : "" if (opencode && "vendor" in opencode) { const vendor = opencode.vendor if (OWN_PACKAGES.has(manifest.name)) @@ -465,8 +478,10 @@ if (manifest) { bad('opencode.vendor: it must be an object, such as { "id": "notion", "name": "Notion", "domain": "notion.so" }.') } else { for (const key of Object.keys(vendor)) { - if (!["id", "name", "domain"].includes(key)) bad(`opencode.vendor: "${key}" is not part of the declaration.`) + if (!["id", "name", "domain", "mark"].includes(key)) bad(`opencode.vendor: "${key}" is not part of the declaration.`) } + if ("mark" in vendor && vendor.mark !== true) + bad('opencode.vendor.mark: it must be true (the icon is the vendor\'s own mark) or left out.') if (typeof vendor.id !== "string" || !VENDOR_ID.test(vendor.id)) bad(`opencode.vendor.id: ${JSON.stringify(vendor.id)}; it must be lowercase, such as "notion".`) if (typeof vendor.name !== "string" || vendor.name.trim() === "") @@ -498,6 +513,8 @@ try { bad(`${ICON_PATH}: missing.`) } +let markCanvas = CANVAS + if (svgText !== null) { const bytes = Buffer.byteLength(svgText, "utf8") if (bytes > SVG_CAP) bad(`${ICON_PATH}: ${bytes} bytes; the family cap is ${SVG_CAP} (Jelly's own cap is ${PLUGIN_ICON_MAX_LENGTH}).`) @@ -531,15 +548,20 @@ if (svgText !== null) { if (viewBox === undefined) bad(`${ICON_PATH}: the root has no viewBox.`) else { const parts = viewBox.trim().split(/[\s,]+/u).map(Number) - const square = parts.length === 4 && parts[2] === parts[3] + const square = parts.length === 4 && parts.every(Number.isFinite) && parts[2] === parts[3] && parts[2] > 0 if (!square) bad(`${ICON_PATH}: viewBox "${viewBox}" is not square; the family canvas is square.`) - else if (parts[2] !== CANVAS) bad(`${ICON_PATH}: viewBox is "${viewBox}"; the family canvas is "0 0 ${CANVAS} ${CANVAS}".`) + else if (vendorMark) { + if (parts[0] !== 0 || parts[1] !== 0) bad(`${ICON_PATH}: viewBox "${viewBox}" must start at 0 0.`) + markCanvas = parts[2] + } else if (parts[2] !== CANVAS) bad(`${ICON_PATH}: viewBox is "${viewBox}"; the family canvas is "0 0 ${CANVAS} ${CANVAS}".`) } // The tile: one opaque rect covering the canvas, at the family corner radius. const body = (cleanPluginIcon(doc) ?? doc).replace(/^]*>/u, "") const tile = /^]*?)\/?>/u.exec(body) - if (!tile) bad(`${ICON_PATH}: the first element must be the tile .`) + if (vendorMark) { + // The vendor's own mark: shipped as the vendor publishes it, so no family tile is required. + } else if (!tile) bad(`${ICON_PATH}: the first element must be the tile .`) else { const attr = (name) => new RegExp(`(?:^|\\s)${name}="([^"]*)"`, "u").exec(tile[1])?.[1] if (attr("width") !== String(CANVAS) || attr("height") !== String(CANVAS)) @@ -554,8 +576,25 @@ if (svgText !== null) { const numbers = [...(cleanPluginIcon(doc) ?? doc).matchAll(/(?:^|\s)(?:d|points)="([^"]*)"/gu)] .flatMap((m) => m[1].match(/-?\d*\.?\d+(?:e-?\d+)?/gu) ?? []) .map(Number) - const outside = numbers.find((n) => Math.abs(n) > CANVAS * 4) - if (outside !== undefined) bad(`${ICON_PATH}: a path coordinate ${outside} is far outside the ${CANVAS}x${CANVAS} canvas.`) + const outside = numbers.find((n) => Math.abs(n) > markCanvas * 4) + if (outside !== undefined) bad(`${ICON_PATH}: a path coordinate ${outside} is far outside the ${markCanvas}x${markCanvas} canvas.`) +} + +// --- 2b. a vendor's own mark carries its notice ----------------------------- + +if (vendorMark && vendorName !== "") { + let readme = null + try { + readme = readFileSync(path.join(root, "README.md"), "utf8") + } catch { + bad("README.md: missing; a package that ships the vendor's own mark must say it is unofficial and carry a trademark line.") + } + if (readme !== null) { + if (!readme.toLowerCase().includes(`not affiliated with ${vendorName.toLowerCase()}`)) + bad(`README.md: it must say "not affiliated with ${vendorName}", because the icon is the vendor's own mark.`) + if (!/\btrademark\b/iu.test(readme)) + bad(`README.md: it must carry a trademark line, such as "${vendorName} is a trademark of .", because the icon is the vendor's own mark.`) + } } // --- 3. the rasters -------------------------------------------------------- diff --git a/scripts/check-release-shape.mjs b/scripts/check-release-shape.mjs index d320893..c844726 100644 --- a/scripts/check-release-shape.mjs +++ b/scripts/check-release-shape.mjs @@ -1,40 +1,81 @@ #!/usr/bin/env node -// Checks that a release tag has the shape D19 and the release brief require. The release workflow runs it; it runs -// locally too, after release-commit.mjs. It reads only git objects and never changes anything. +// Checks that a release commit, and its tag once it exists, have the shape the release kit makes. The release workflow +// runs it before the tag exists (verify, stage) and after (publish). It reads git objects and the GitHub API, and never +// changes anything. // -// node scripts/check-release-shape.mjs [--tag v0.0.0] [--branch master] [--allow ]... -// [--verify git --allowed-signers | --verify github-api] -// [--rebuilt ] a directory whose dist/ is a fresh build of the tag's parent: compared byte for byte -// [--notes ] writes the release notes: the CHANGELOG's 0.0.0 section up to the next ## heading, then the -// README's Known limits. The CHANGELOG must be in the release shape (scripts/changelog.mjs). +// node scripts/check-release-shape.mjs [--tag v | --commit --tag-name v] [--branch master] +// [--allow ]... [--verify github-api] [--identity github-actions] +// [--rebuilt ] a directory whose dist/ is a fresh build of the commit's parent: compared byte for byte +// [--notes ] writes the release notes: the CHANGELOG's section for the version up to the next ## heading, +// then the README's Known limits. The CHANGELOG must be in the release shape (scripts/changelog.mjs). // -// Checks, in order: the tag is annotated and signed by the owner; its name is v and the version is 0.0.0; -// its commit has one parent, which is on the branch; the commit and tag identities are the GitHub noreply domain; the -// paths it changes from its parent are exactly the allowed set; package.json has no lifecycle scripts and is not -// private; dist/ is committed. Emails are checked by domain and never printed. -import { execFileSync, spawnSync } from "node:child_process" +// --tag checks a tagged release; it defaults to v of the checkout's package.json. --commit checks a release +// commit before it is tagged, for the tag --tag-name: every check but the tag's own. Checks, in order: the tag (GitHub +// API) is annotated, named and titled exactly the tag, on the release commit, tagged by github-actions[bot] and +// unsigned; the release commit (GitHub API) is verified by GitHub, authored by github-actions[bot] and committed by +// GitHub's web-flow; the tag name is exactly v, where the version is the x.y.z of the commit's package.json; +// the commit has one parent, which is on the branch; the release moves forward from the last published GitHub Release +// (an older version, whose parent is an ancestor of this one); package.json is exactly the parent's without its +// lifecycle scripts and is not private; the paths it changes from its parent are exactly the allowed set, each a regular +// file (mode 100644, no symlink or executable); dist/ is committed; a native plugin's manifest and run are right. +// +// The signature and the identities prove only that GitHub made the commit through its API for the workflow: anyone with +// write access can make such a commit. What binds the release to master is the content: the parent, the package.json +// rule, the allowed paths and the byte-for-byte rebuild. Never relax those because the commit is signed. +// +// The repository is GITHUB_REPOSITORY, or the origin remote. The published Releases are read with `gh api`; a tag that +// never became a Release, or a draft, does not count. When they cannot be listed, the order check fails: it never passes +// without them. The parent need not be the branch's head: the branch can move while a release waits for its approval. +import { spawnSync } from "node:child_process" import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs" import { join, relative } from "node:path" import { fileURLToPath } from "node:url" -import { allowedPath, LIFECYCLE_SCRIPTS, NOREPLY_DOMAIN, RELEASE_VERSION, TAG } from "./release-commit.mjs" +import { + allowedPath, + api, + commitIdentityProblems, + fetchReleases, + ghOutput, + LIFECYCLE_SCRIPTS, + modeProblems, + nativeRunProblems, + originRepository, + RELEASE_MODE, + releaseOrder, + releasePackageText, + releasedTags, + tagObjectProblems, + tagRefProblems, +} from "./release-rules.mjs" import { MANIFEST_PATH, manifestProblems } from "./native-manifest.mjs" import { changelogProblems, releaseNotes } from "./changelog.mjs" +import { isReleaseTag, tagFor, tagProblem } from "./release-version.mjs" function parseArgs(argv) { - const options = { tag: TAG, branch: "master", allow: [], verify: "git", allowedSigners: undefined, rebuilt: undefined, notes: undefined } + const options = { tag: undefined, commit: undefined, tagName: undefined, branch: "master", allow: [], verify: "github-api", identity: "github-actions", rebuilt: undefined, notes: undefined } for (let i = 0; i < argv.length; i++) { const arg = argv[i] if (arg === "--tag") options.tag = argv[++i] + else if (arg === "--commit") options.commit = argv[++i] + else if (arg === "--tag-name") options.tagName = argv[++i] else if (arg === "--branch") options.branch = argv[++i] else if (arg === "--allow") options.allow.push(argv[++i]) else if (arg === "--verify") options.verify = argv[++i] - else if (arg === "--allowed-signers") options.allowedSigners = argv[++i] + else if (arg === "--identity") options.identity = argv[++i] else if (arg === "--rebuilt") options.rebuilt = argv[++i] else if (arg === "--notes") options.notes = argv[++i] else throw new Error(`unknown argument ${arg}`) } - if (!["git", "github-api"].includes(options.verify)) throw new Error('--verify is "git" or "github-api"') - if (options.verify === "git" && !options.allowedSigners) throw new Error("--verify git needs --allowed-signers ") + if (options.verify !== "github-api") throw new Error('--verify is "github-api": GitHub verifies the release commit; there is no key to check locally') + if (options.identity !== "github-actions") throw new Error('--identity is "github-actions": the release workflow makes the commit and the tag') + if (options.commit !== undefined) { + if (options.tag !== undefined) throw new Error("--commit and --tag are two modes: pass one") + if (!/^[0-9a-f]{40}$/.test(options.commit)) throw new Error("--commit must be a full sha") + if (!options.tagName) throw new Error("--commit needs --tag-name v: the tag it will get") + } else { + if (options.tagName !== undefined) throw new Error("--tag-name goes with --commit") + options.tag ??= tagFor(JSON.parse(readFileSync("package.json", "utf8")).version) + } return options } @@ -53,59 +94,68 @@ function main() { failures.push(`${name}: ${why}`) console.log(`FAIL ${name}: ${why}`) } - - const tag = options.tag - const tagRef = `refs/tags/${tag}` - const tagObject = git(["rev-parse", "-q", "--verify", tagRef]) - if (!tagObject) { - console.error(`check-release-shape: no tag ${tag}`) + const stop = (message) => { + console.error(`check-release-shape: ${message}`) process.exit(1) } - // The tag is annotated: its object is a tag object, not a commit. - if (git(["cat-file", "-t", tagRef]) === "tag") ok("the tag is annotated") - else fail("the tag is annotated", "the tag is a lightweight tag, not an annotated one") - - const target = git(["rev-parse", `${tag}^{commit}`]) - const pkgText = gitBuffer(["show", `${target}:package.json`])?.toString("utf8") - const pkg = pkgText ? JSON.parse(pkgText) : undefined - if (!pkg) { - fail("package.json", "the tagged commit has no package.json") - process.exit(1) + const repository = process.env.GITHUB_REPOSITORY || originRepository() + if (!repository) stop("neither GITHUB_REPOSITORY nor a GitHub origin names the repository") + const gh = (args) => ghOutput(args, { repository }) + const read = (path, allow404) => { + try { + return api(gh, `repos/${repository}/${path}`, { allow404 }) + } catch (error) { + stop(`GitHub could not be read (${path.split("/")[0]}: ${error.message})`) + } } - // The version: the tag is v, and every plugin releases as 0.0.0. - if (pkg.version === RELEASE_VERSION && tag === `v${pkg.version}`) ok(`the tag is v${pkg.version}, and the version is ${pkg.version}`) - else fail("the version", `the tag is ${tag} and package.json says ${pkg.version}; every plugin releases as ${TAG}`) - - // The signatures: the tag and its commit, by the owner. - if (options.verify === "git") { - const signers = ["-c", `gpg.ssh.allowedSignersFile=${options.allowedSigners}`] - const tagSigned = spawnSync("git", [...signers, "verify-tag", tag], { encoding: "utf8" }).status === 0 - const commitSigned = spawnSync("git", [...signers, "verify-commit", target], { encoding: "utf8" }).status === 0 - if (tagSigned) ok("the tag is signed by an allowed signer") - else fail("the tag is signed", "git verify-tag failed against the allowed signers") - if (commitSigned) ok("the release commit is signed by an allowed signer") - else fail("the release commit is signed", "git verify-commit failed against the allowed signers") - } else { - const repo = process.env.GITHUB_REPOSITORY - if (!repo) fail("the signatures", "GITHUB_REPOSITORY is not set") - else { - const api = (path) => JSON.parse(execFileSync("gh", ["api", path], { encoding: "utf8" })) - const tagVerification = api(`repos/${repo}/git/tags/${tagObject}`).verification - const commitVerification = api(`repos/${repo}/commits/${target}`).commit.verification - if (tagVerification?.verified && tagVerification.reason === "valid") ok("the tag is signed, and GitHub verifies the signature") - else fail("the tag is signed", `GitHub says ${tagVerification?.reason ?? "no verification"}`) - if (commitVerification?.verified && commitVerification.reason === "valid") ok("the release commit is signed, and GitHub verifies the signature") - else fail("the release commit is signed", `GitHub says ${commitVerification?.reason ?? "no verification"}`) + // The tag and the commit it points at: from GitHub, the tag the release job made. Before the tag, the commit itself. + const tag = options.tag ?? options.tagName + let target = options.commit + if (options.tag !== undefined) { + const ref = read(`git/ref/tags/${tag}`, true) + if (!ref) stop(`no tag ${tag} on GitHub`) + const refProblems = tagRefProblems(ref, tag) + if (refProblems.length) { + fail("the tag is annotated", refProblems.join("; ")) + target = ref.object.sha + } else { + ok("the tag is annotated") + const tagObject = read(`git/tags/${ref.object.sha}`) + target = tagObject?.object?.sha + const problems = tagObjectProblems(tagObject, { tag, commit: target }) + if (problems.length) fail("the tag", problems.join("; ")) + else ok(`the tag is the kit's: ${tag}, tagged by github-actions[bot], unsigned, on ${String(target).slice(0, 12)}`) } + const local = git(["rev-parse", "-q", "--verify", `refs/tags/${tag}^{commit}`]) + if (local && local !== target) fail("the tag", `the checkout's ${tag} points at ${local.slice(0, 12)}, and GitHub's at ${String(target).slice(0, 12)}`) + } else ok(`${tag} is not tagged yet: the tag's own checks run when the release job has tagged the commit`) + if (!target || spawnSync("git", ["cat-file", "-e", `${target}^{commit}`]).status !== 0) stop(`the release commit ${String(target).slice(0, 12)} is not in this checkout: fetch it`) + + // The release commit: GitHub verifies it, and the workflow made it. + const identity = commitIdentityProblems(read(`commits/${target}`)) + if (identity.length) fail("the release commit", identity.join("; ")) + else ok("GitHub verifies the release commit, made by github-actions[bot] through GitHub's API, with one parent") + + // package.json is a regular file before it is read: a symlink's blob is its target's path, not the package. + const pkgMode = (git(["ls-tree", target, "--", "package.json"]) ?? "").split(" ")[0] + const pkgText = pkgMode === RELEASE_MODE ? gitBuffer(["show", `${target}:package.json`])?.toString("utf8") : undefined + let pkg + try { + pkg = pkgText ? JSON.parse(pkgText) : undefined + } catch { + pkg = undefined + } + if (!pkg) { + fail("package.json", pkgMode && pkgMode !== RELEASE_MODE ? modeProblems([{ path: "package.json", mode: pkgMode }])[0] : "the release commit has no package.json that parses") + process.exit(1) } - // The identities: the commit's author and committer, and the tag's tagger, are the noreply domain. Domain only. - const emails = [...(git(["log", "-1", "--format=%ae%n%ce", target]) ?? "").split("\n"), ...((gitBuffer(["cat-file", "tag", tagRef])?.toString("utf8") ?? "").split("\n").filter((line) => line.startsWith("tagger ")).map((line) => line.slice(line.indexOf("<") + 1, line.indexOf(">"))))] - const foreign = emails.filter((email) => !email.endsWith(`@${NOREPLY_DOMAIN}`)) - if (emails.length >= 3 && !foreign.length) ok("the commit and the tag use the GitHub noreply identity") - else fail("the identities", `${foreign.length} of ${emails.length} identities are not the noreply domain`) + // The version: package.json's x.y.z, and the tag is exactly v. + const versionRefusal = tagProblem(tag, pkg.version) + if (!versionRefusal) ok(`the tag is ${tag}, and the version is ${pkg.version}`) + else fail("the version", versionRefusal) // One parent, on the branch. const parents = (git(["rev-list", "--parents", "-n", "1", target]) ?? "").split(" ").slice(1) @@ -116,30 +166,58 @@ function main() { if (onBranch) ok(`the parent ${parent.slice(0, 12)} is on ${options.branch}`) else fail("the parent", `the parent is not on ${options.branch}`) + // Releases move forward from the last published Release, so a release never ships source older than it. Only + // published Releases count: a v* tag cannot be deleted, so a stray one must not block every later release. + if (isReleaseTag(tag)) { + let released + try { + released = releasedTags(fetchReleases(repository, gh)) + } catch (error) { + fail("the release order", `the published Releases could not be listed (${error.message}), so the order is not checked`) + } + if (released) { + const order = releaseOrder({ tag, parent, released }) + if (order.problem) fail("the release order", order.problem) + else if (order.previous) ok(`the parent descends from the parent of ${order.previous}, the last published Release`) + else ok(`${tag} is the first published Release`) + } + } + + // package.json is exactly what release-commit.mjs writes from the parent's: nothing added, removed or reordered. + const parentPkg = gitBuffer(["show", `${parent}:package.json`])?.toString("utf8") + let expected + try { + expected = parentPkg === undefined ? undefined : releasePackageText(JSON.parse(parentPkg)) + } catch { + expected = undefined + } + if (expected === undefined) fail("package.json", "the parent has no package.json that parses") + else if (pkgText === expected) ok("package.json is the parent's without its lifecycle scripts") + else fail("package.json", "it is not exactly the parent's without its lifecycle scripts: release-commit.mjs writes nothing else") + // The change from the parent is exactly the allowed set. The manifest is in the set only when the commit carries one. const native = spawnSync("git", ["cat-file", "-e", `${target}:${MANIFEST_PATH}`]).status === 0 - const changed = (git(["diff", "--name-only", parent, target]) ?? "").split("\n").filter(Boolean) + // `--raw` lines read ": \t"; the new mode is the tag's. + const raw = (git(["diff", "--raw", "--no-renames", "-z", parent, target]) ?? "").split("\0").filter(Boolean) + const changes = [] + for (let i = 0; i + 1 < raw.length; i += 2) changes.push({ mode: raw[i].split(" ")[1], path: raw[i + 1] }) + const changed = changes.map((change) => change.path) const outside = changed.filter((path) => !allowedPath(path, { native }) && !options.allow.includes(path)) if (changed.length && !outside.length) ok(`the change from the parent is the allowed set (${changed.length} paths${native ? ", with the native manifest" : ""})`) else fail("the change from the parent", changed.length ? `paths outside the allowed set: ${outside.join(", ")}` : "the release commit changes nothing") + const modes = modeProblems(changes) + if (!modes.length) ok("every path it changes is a regular file") + else fail("the file modes", modes.join("; ")) // A native plugin's manifest: where its binaries come from, and the run that built them. The run is checked on GitHub. if (native) { const manifest = JSON.parse(gitBuffer(["show", `${target}:${MANIFEST_PATH}`]).toString("utf8")) - const repository = process.env.GITHUB_REPOSITORY || undefined const problems = manifestProblems(manifest, { plugin: pkg.name.replace(/^@[^/]+\//, ""), tag, commit: parent, repository }) if (problems.length) fail("the native manifest", problems.join("; ")) else ok(`the native manifest names ${tag}, the parent, and native run ${manifest.built.run}`) - if (options.verify === "github-api" && repository) { - const run = JSON.parse(execFileSync("gh", ["api", `repos/${repository}/actions/runs/${manifest.built.run}`], { encoding: "utf8" })) - const runProblems = [] - if (run.head_sha !== parent) runProblems.push("the run built another commit") - if (run.conclusion !== "success") runProblems.push(`the run concluded ${run.conclusion}`) - if (String(run.path ?? "").split("/").pop() !== "native.yml") runProblems.push("the run is not the native workflow") - if (run.head_branch !== "master") runProblems.push("the run is not on master") - if (runProblems.length) fail("the native run", runProblems.join("; ")) - else ok("the native run succeeded on master, for the parent") - } + const runProblems = nativeRunProblems(read(`actions/runs/${manifest.built.run}`), { sha: parent }) + if (runProblems.length) fail("the native run", runProblems.join("; ")) + else ok("the native run succeeded on master, for the parent, started by hand") } // The build is committed: dist/ holds the bundles the host loads. @@ -171,13 +249,13 @@ function main() { if (pkg.private !== true) ok("the package is not private") else fail("the package", 'package.json has "private": true') - if (options.notes) writeNotes(target, options.notes, (message) => fail("the release notes", message), ok) + if (options.notes) writeNotes(target, pkg.version, options.notes, (message) => fail("the release notes", message), ok) if (failures.length) { console.error(`check-release-shape: ${failures.length} check(s) failed`) process.exit(1) } - console.log(`check-release-shape: ${tag} has the release shape`) + console.log(`check-release-shape: ${options.tag !== undefined ? tag : `${target.slice(0, 12)}, for ${tag},`} has the release shape`) } function listFiles(dir) { @@ -194,13 +272,13 @@ function listFiles(dir) { } /** The CHANGELOG section for the version, then the README's Known limits (D3), written to the file. */ -function writeNotes(target, file, fail, ok) { +function writeNotes(target, version, file, fail, ok) { const changelog = gitBuffer(["show", `${target}:CHANGELOG.md`])?.toString("utf8") ?? "" - const shape = changelogProblems(changelog) - if (shape.length) return fail(`CHANGELOG.md is not in the release shape: ${shape.join("; ")}`) + const shape = changelogProblems(changelog, version) + if (shape.length) return fail(`CHANGELOG.md is not in the release shape for ${version}: ${shape.join("; ")}`) let section try { - section = releaseNotes(changelog) + section = releaseNotes(changelog, version) } catch (error) { return fail(error.message) } diff --git a/scripts/check.mjs b/scripts/check.mjs index a510f2e..733615b 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -37,6 +37,7 @@ const LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall", "preprepare", const stages = { typecheck: () => run("tsc", ["--noEmit", "-p", "tsconfig.json"]), unit: () => run("bun", ["test", "./test/unit"]), + kit: () => run("node", ["--test", "scripts/release-kit.test.mjs"]), build: () => run("node", ["scripts/build.mjs"]), icons: () => run("node", ["scripts/check-icons.mjs"]), copies: () => run("node", ["scripts/check-copies.mjs"]), diff --git a/scripts/native-manifest.mjs b/scripts/native-manifest.mjs index 1dafba4..c9dff74 100644 --- a/scripts/native-manifest.mjs +++ b/scripts/native-manifest.mjs @@ -1,5 +1,6 @@ -// The rules for `manifest/native.json` (NATIVE-DESIGN §2.2), shared by release-commit.mjs, check-release-shape.mjs and -// attach-native.mjs. A plugin with no native part has no manifest, and none of this applies to it. +// The rules for `manifest/native.json` (NATIVE-DESIGN §2.2), shared by release-commit.mjs, release-github.mjs, +// check-release-shape.mjs and attach-native.mjs. A plugin with no native part has no manifest, and none of this applies +// to it. // // The manifest names the tag, the master commit and the native run its binaries were built from. Each module lists per // platform the release asset (a gzip, served from this repository's release on the tag) and the executable it holds, diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs index 3921444..d212718 100644 --- a/scripts/publish-release.mjs +++ b/scripts/publish-release.mjs @@ -3,15 +3,16 @@ // It never deletes or replaces an asset, and never refuses an asset it does not know: assets attached later (a backup of // speech files, for example) stay where they are, and are listed. A kit file already on the Release must have the same // sha256 as the local file; if one does not, nothing is uploaded. The kit's files are every file under the folder, the -// native binaries in its native/ subfolder included; an asset's name is its file name. +// native binaries in its native/ subfolder included; an asset's name is its file name. `gh` runs only through ghOutput +// (scripts/release-rules.mjs), which allows only the kit's own calls. // // node scripts/publish-release.mjs --tag --dir --notes -import { execFileSync } from "node:child_process" import { createHash } from "node:crypto" import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { fileURLToPath } from "node:url" +import { ghOutput } from "./release-rules.mjs" const sha256 = (path) => createHash("sha256").update(readFileSync(path)).digest("hex") @@ -78,15 +79,6 @@ export function publish({ tag, dir, notes, gh }) { return { action: missing.length ? "uploaded" : "unchanged", uploaded: missing.map((file) => file.name), unknown } } -/** The GitHub CLI, with its error text kept (a missing Release is recognised by it). */ -function realGh(args) { - try { - return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) - } catch (error) { - throw new Error(String(error.stderr || error.message).trim()) - } -} - function main(argv) { const value = (flag) => { const at = argv.indexOf(flag) @@ -100,7 +92,7 @@ function main(argv) { process.exit(2) } try { - const result = publish({ tag, dir, notes, gh: realGh }) + const result = publish({ tag, dir, notes, gh: (args) => ghOutput(args) }) console.log(`release ${tag}: ${result.action}; uploaded now: ${result.uploaded.join(", ") || "none"}`) if (result.unknown.length) console.log(`left alone (not kit files): ${result.unknown.join(", ")}`) } catch (error) { diff --git a/scripts/release-commit.mjs b/scripts/release-commit.mjs index 33bbb4e..4cc0789 100644 --- a/scripts/release-commit.mjs +++ b/scripts/release-commit.mjs @@ -1,60 +1,39 @@ #!/usr/bin/env node -// Builds the GitHub release commit and its signed tag, locally. Run it in a clean checkout of `master` at the head CI -// passed. The release commit differs from its parent only by the built dist/ (force-added, because master ignores it), -// a package.json without lifecycle scripts, and, for a native plugin, manifest/native.json copied from a native run. -// The tag is annotated and signed, and points at that commit. +// The release workflow's build step (the `build` job of .github/workflows/release.yml). In a clean checkout of the master +// commit being released, it builds dist/ and writes the release commit's files to a folder: dist/, package.json without +// its lifecycle scripts, and, for a native plugin, the manifest the `plan` job vetted. It commits, tags and pushes +// nothing, and needs no token: the `commit` job, which runs no npm, turns the folder into the release commit +// (scripts/release-github.mjs commit). // -// node scripts/release-commit.mjs --ci-sha [--dry-run] [--native-run ] +// node scripts/release-commit.mjs --sha --out [--manifest ] // -// It never pushes. Refuses: a dirty tree, a branch other than master, a head other than --ci-sha, a git identity that is -// not the GitHub noreply address, a tag that already exists, a version other than 0.0.0, a private package, a native run -// whose head is not the parent, whose conclusion is not success, whose workflow is not native.yml, or whose manifest -// fails the manifest rules, and any staged path outside the allowed set. The identity is checked by domain only, and no -// address is printed. +// The folder holds the files at their repository paths, and paths.txt: one ` ` line per file, sorted, so +// the commit job can tell that it received exactly what was built. Refuses: a dirty tree, a head other than --sha, a +// version that is not x.y.z, a private package, package.json not in its canonical form, no build script, a native plugin +// (one with .github/workflows/native.yml) without --manifest or a plugin that is not native with one, a manifest that +// does not name this tag and commit, a build that changes tracked files or builds no dist/, and a built path outside the +// allowed set or that is not a regular file. import { spawnSync } from "node:child_process" -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" -import { basename, join } from "node:path" -import { tmpdir } from "node:os" +import { createHash } from "node:crypto" +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" import { MANIFEST_PATH, manifestProblems } from "./native-manifest.mjs" +import { allowedPath, releasePackageText, serialise } from "./release-rules.mjs" +import { tagFor, versionProblem } from "./release-version.mjs" -export const RELEASE_VERSION = "0.0.0" -export const TAG = `v${RELEASE_VERSION}` -export const NOREPLY_DOMAIN = "users.noreply.github.com" -/** Lifecycle scripts a git install runs, or that npm runs around install and publish. The release commit drops them. */ -export const LIFECYCLE_SCRIPTS = [ - "build", - "preinstall", - "install", - "postinstall", - "preprepare", - "prepare", - "postprepare", - "prepublish", - "prepublishOnly", - "prepack", - "postpack", - "publish", - "postpublish", -] - -/** The paths the release commit may add or change. The manifest is allowed only for a native plugin. */ -export function allowedPath(path, { native = false } = {}) { - return path === "package.json" || path.startsWith("dist/") || (native && path === MANIFEST_PATH) -} +/** The workflow whose presence makes a plugin native. */ +export const NATIVE_WORKFLOW = ".github/workflows/native.yml" -/** package.json with its lifecycle scripts removed. Keys keep their order, and the JSON form stays the repository's. */ -export function withoutLifecycle(pkg) { - const scripts = pkg.scripts ?? {} - const removed = Object.keys(scripts).filter((name) => LIFECYCLE_SCRIPTS.includes(name)) - const kept = Object.fromEntries(Object.entries(scripts).filter(([name]) => !LIFECYCLE_SCRIPTS.includes(name))) - const next = { ...pkg } - if (Object.keys(kept).length) next.scripts = kept - else delete next.scripts - return { next, removed } -} +export const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex") -export const serialise = (pkg) => `${JSON.stringify(pkg, null, 2)}\n` +/** paths.txt for a list of { path, bytes }: one ` ` line per file, sorted by path. */ +export function pathsText(files) { + return [...files] + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) + .map((file) => `${sha256(file.bytes)} ${file.path}\n`) + .join("") +} function die(message) { console.error(`release-commit: ${message}`) @@ -67,144 +46,81 @@ function git(args, { allowFailure = false } = {}) { return result.status === 0 ? result.stdout.trim() : undefined } -function run(command, args) { - const result = spawnSync(command, args, { stdio: "inherit" }) - if (result.status !== 0) die(`${command} ${args.join(" ")} failed (exit ${result.status})`) -} - -function capture(command, args) { - const result = spawnSync(command, args, { encoding: "utf8" }) - if (result.status !== 0) die(`${command} ${args[0]} failed: ${(result.stderr || "").trim().split("\n")[0]}`) - return result.stdout +function npm(args) { + const result = spawnSync("npm", args, { stdio: "inherit" }) + if (result.status !== 0) die(`npm ${args.join(" ")} failed (exit ${result.status})`) } function parseArgs(argv) { - const options = { ciSha: undefined, dryRun: false, nativeRun: undefined } + const options = { sha: undefined, out: undefined, manifest: undefined } for (let i = 0; i < argv.length; i++) { const arg = argv[i] - if (arg === "--dry-run") options.dryRun = true - else if (arg === "--ci-sha") options.ciSha = argv[++i] - else if (arg === "--native-run") options.nativeRun = argv[++i] + if (arg === "--sha") options.sha = argv[++i] + else if (arg === "--out") options.out = argv[++i] + else if (arg === "--manifest") options.manifest = resolve(argv[++i]) else die(`unknown argument ${arg}`) } - if (!options.ciSha) die("--ci-sha is required: the full sha of the master head whose CI passed") - if (!/^[0-9a-f]{40}$/.test(options.ciSha)) die("--ci-sha must be a full 40-character sha") - if (options.nativeRun !== undefined && !/^\d+$/.test(options.nativeRun)) die("--native-run must be a run id") + if (!options.sha || !/^[0-9a-f]{40}$/.test(options.sha)) die("--sha must be the full 40-character sha of the checkout") + if (!options.out) die("--out is required") return options } -/** owner/name of this repository, from its origin remote. */ -function repositoryOf() { - const url = git(["remote", "get-url", "origin"], { allowFailure: true }) ?? "" - const match = /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(url) - if (!match) die("origin is not a GitHub remote, so the native run cannot be checked") - return match[1] -} - -/** Reads the candidate manifest of a native run, after the run's own facts are checked (NATIVE-DESIGN §4, step 3). */ -function candidateManifest(id, head, repository) { - const run = JSON.parse(capture("gh", ["api", `repos/${repository}/actions/runs/${id}`])) - if (run.head_sha !== head) die(`native run ${id} built ${String(run.head_sha).slice(0, 12)}, not the parent ${head.slice(0, 12)}`) - if (run.conclusion !== "success") die(`native run ${id} did not succeed (conclusion: ${run.conclusion})`) - if (basename(String(run.path ?? "")) !== "native.yml") die(`run ${id} is not the native workflow`) - if (run.head_branch !== "master") die(`native run ${id} was not on master`) - const dir = mkdtempSync(join(tmpdir(), "native-manifest-")) - capture("gh", ["run", "download", id, "--repo", repository, "--name", "native-manifest", "--dir", dir]) - const file = join(dir, "native.json") - let manifest - try { - manifest = JSON.parse(readFileSync(file, "utf8")) - } catch { - die(`native run ${id} has no native-manifest artifact with native.json`) - } - return { manifest, text: `${JSON.stringify(manifest, null, 2)}\n` } -} - function main() { const options = parseArgs(process.argv.slice(2)) + const out = resolve(options.out) const root = git(["rev-parse", "--show-toplevel"]) process.chdir(root) - if (git(["status", "--porcelain", "--untracked-files=all"])) die("the tree is not clean: commit or stash every change first") - const branch = git(["symbolic-ref", "--short", "HEAD"], { allowFailure: true }) - if (branch !== "master") die(`the checkout is on "${branch ?? "a detached head"}", not master`) - const head = git(["rev-parse", "HEAD"]) - if (head !== options.ciSha) die("HEAD is not the head CI passed: check out that commit on master, or pass its sha") - - const email = git(["config", "user.email"], { allowFailure: true }) ?? "" - if (!email.endsWith(`@${NOREPLY_DOMAIN}`)) die(`git user.email is not a GitHub noreply address (domain ${NOREPLY_DOMAIN}); set it before release`) - - if (git(["rev-parse", "-q", "--verify", `refs/tags/${TAG}`], { allowFailure: true })) { - die(`tag ${TAG} already exists: delete the old tag first (D19a), then run this again`) - } + if (git(["status", "--porcelain", "--untracked-files=all"])) die("the tree is not clean") + if (git(["rev-parse", "HEAD"]) !== options.sha) die(`the checkout is not ${options.sha.slice(0, 12)}`) const pkgPath = join(root, "package.json") const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) - if (pkg.version !== RELEASE_VERSION) die(`package.json version is ${pkg.version}; every plugin releases as ${TAG}`) - if (pkg.private === true) die('package.json has "private": true, so the release would refuse it') - if (serialise(pkg) !== readFileSync(pkgPath, "utf8")) die("package.json is not in the repository's canonical JSON form; reformat it first") + const versionRefusal = versionProblem(pkg.version) + if (versionRefusal) die(versionRefusal) + const tag = tagFor(pkg.version) + if (pkg.private === true) die('package.json has "private": true') + if (serialise(pkg) !== readFileSync(pkgPath, "utf8")) die("package.json is not in the repository's canonical JSON form") + if (!pkg.scripts?.build) die('package.json has no "build" script: the release builds with npm run build') - // A native run's candidate manifest is read and checked before anything is built or written. - const native = options.nativeRun !== undefined + const native = existsSync(join(root, NATIVE_WORKFLOW)) + if (native && !options.manifest) die(`this plugin is native (${NATIVE_WORKFLOW}), and no vetted manifest was given`) + if (!native && options.manifest) die(`a manifest was given, and this plugin is not native (no ${NATIVE_WORKFLOW})`) let manifestText if (native) { - const repository = repositoryOf() - const candidate = candidateManifest(options.nativeRun, head, repository) - const problems = manifestProblems(candidate.manifest, { plugin: pkg.name.replace(/^@[^/]+\//, ""), tag: TAG, commit: head, repository }) + manifestText = readFileSync(options.manifest, "utf8") + const problems = manifestProblems(JSON.parse(manifestText), { plugin: pkg.name.replace(/^@[^/]+\//, ""), tag, commit: options.sha }) if (problems.length) die(`the native manifest is not right: ${problems.join("; ")}`) - manifestText = candidate.text } + if (existsSync(out) && readdirSync(out).length) die(`${options.out} is not empty`) + // The build the release ships: the repository's `build` script, after a clean `npm ci` with lifecycle scripts off. The - // release commit then drops `build` with the other lifecycle scripts (LIFECYCLE_SCRIPTS), so the install builds nothing. - if (!pkg.scripts?.build) die('package.json has no "build" script: the release builds with npm run build') - run("npm", ["ci", "--ignore-scripts"]) - run("npm", ["run", "build"]) + // release commit then drops `build` with the other lifecycle scripts, so the install builds nothing. + npm(["ci", "--ignore-scripts"]) + npm(["run", "build"]) if (git(["status", "--porcelain", "--untracked-files=all"])) die("the build changed tracked files; the release commit would not be the build") // dist/ is git-ignored on master, so the build's files are listed as ignored, untracked files. const built = (git(["ls-files", "--others", "--ignored", "--exclude-standard", "--", "dist"], { allowFailure: true }) ?? "").split("\n").filter(Boolean) if (!built.length) die("the build produced no dist/ files") - - const { next, removed } = withoutLifecycle(pkg) - const nextText = serialise(next) - - if (options.dryRun) { - console.log(`dry run: the release commit on ${head.slice(0, 12)} for ${TAG}, nothing is written or committed`) - console.log(` add (force): dist/ (${built.length} files)`) - for (const path of built.slice(0, 20)) console.log(` ${path}`) - if (built.length > 20) console.log(` … ${built.length - 20} more`) - console.log(` change package.json: remove scripts ${removed.length ? removed.join(", ") : "(none)"}`) - const before = serialise(pkg).split("\n") - const after = nextText.split("\n") - for (const line of before) if (!after.includes(line)) console.log(` - ${line}`) - for (const line of after) if (!before.includes(line)) console.log(` + ${line}`) - if (native) console.log(` add: ${MANIFEST_PATH} (the candidate of native run ${options.nativeRun})`) - console.log(` tag: ${TAG} (annotated, signed), on the new commit. Not pushed.`) - return - } - - writeFileSync(pkgPath, nextText) - if (native) { - mkdirSync(join(root, "manifest"), { recursive: true }) - writeFileSync(join(root, MANIFEST_PATH), manifestText) - } - run("git", ["add", "-f", "--", "dist", "package.json", ...(native ? [MANIFEST_PATH] : [])]) - - const staged = git(["diff", "--cached", "--name-only"]).split("\n").filter(Boolean) - const outside = staged.filter((path) => !allowedPath(path, { native })) - if (outside.length) { - git(["reset", "-q"]) - die(`staged paths outside the allowed set: ${outside.join(", ")}`) + const outside = built.filter((path) => !allowedPath(path, { native })) + if (outside.length) die(`built paths outside the allowed set: ${outside.join(", ")}`) + const irregular = built.filter((path) => !lstatSync(join(root, path)).isFile()) + if (irregular.length) die(`built paths that are not regular files: ${irregular.join(", ")}`) + + const files = [ + ...built.map((path) => ({ path, bytes: readFileSync(join(root, path)) })), + { path: "package.json", bytes: Buffer.from(releasePackageText(pkg)) }, + ...(native ? [{ path: MANIFEST_PATH, bytes: Buffer.from(manifestText) }] : []), + ] + for (const file of files) { + mkdirSync(dirname(join(out, file.path)), { recursive: true }) + writeFileSync(join(out, file.path), file.bytes) } - if (!staged.includes("package.json") || !staged.some((path) => path.startsWith("dist/"))) die("the release commit must add dist/ and change package.json") - - git(["commit", "-S", "-q", "-m", `chore(release): ${TAG} with the built dist/ and no lifecycle scripts`]) - const commit = git(["rev-parse", "HEAD"]) - git(["tag", "-s", "-a", TAG, "-m", TAG, commit]) - console.log(`release commit ${commit} on parent ${head}`) - console.log(`tag ${TAG} is annotated and signed on it${native ? `; manifest from native run ${options.nativeRun}` : ""}`) - console.log(`staged: ${staged.length} paths; nothing is pushed. Push the tag only, after review: git push origin ${TAG}`) + writeFileSync(join(out, "paths.txt"), pathsText(files)) + console.log(`release files for ${tag} on ${options.sha.slice(0, 12)}: ${built.length} dist/ files, package.json${native ? `, ${MANIFEST_PATH}` : ""}`) + console.log(`written to ${options.out}, with paths.txt; nothing is committed, tagged or pushed`) } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main() diff --git a/scripts/release-github.mjs b/scripts/release-github.mjs new file mode 100644 index 0000000..1ee6a1b --- /dev/null +++ b/scripts/release-github.mjs @@ -0,0 +1,541 @@ +#!/usr/bin/env node +// The release workflow's GitHub steps (.github/workflows/release.yml). Each subcommand is one job's write or check: +// +// plan --sha

[--native-run ] [--manifest-out ] +// Refuses a release that must not start: a run not on master, a sha that is not master's head, no green CI on +// it, a version or CHANGELOG not ready, a leftover release branch, a release that does not move forward, a +// native run that is wrong, or one given to a plugin that is not native. Finds a tag left by a run that failed +// after tagging (mode resume). Writes version, tag, hosts, native, mode, commit, ci_run and manifest_sha256 (the +// vetted native manifest's, which the commit job requires byte for byte) to GITHUB_OUTPUT. +// commit --files

--parent

--tag --native [--manifest-sha256 ] +// Makes the release commit on the temporary branch release/, with GraphQL createCommitOnBranch and the +// workflow's token, so GitHub signs it. Checks the files, computes the tree they must give, and refuses a commit +// that GitHub does not verify, that has another parent, or another tree. Deletes the branch when it fails. +// tag --tag --commit --parent

--mode +// After the owner's approval: checks everything again, then creates the annotated, unsigned tag on C, or +// reuses one the kit already made there. Never moves or replaces a tag. +// cleanup --tag --parent

[--commit ] +// Deletes release/ when it points at P, at C, or (C unknown) at a release commit the kit made on P. +// Never touches another branch, or the branch at any other commit. +// native --dir

--commit +// Checks the staged Release files against C's manifest/native.json: each native file's size and sha256, each +// gzip's executable, nothing unpinned, nothing missing, and only SHA256SUMS and one tarball besides. +// sums --dir --sums-sha256 +// Checks that the staged SHA256SUMS has the digest the approver saw in the run summary, and every staged +// Release file against it. +// +// The repository is GITHUB_REPOSITORY. Every `gh` call of the kit, here and in check-release-shape.mjs, +// publish-release.mjs and attach-native.mjs, goes through ghOutput (scripts/release-rules.mjs), which allows only the +// kit's own calls: never approving or rejecting a deployment, environments, rulesets, bypasses or branch protection. +import { spawnSync } from "node:child_process" +import { appendFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { gunzipSync } from "node:zlib" +import { basename, join, relative } from "node:path" +import { fileURLToPath } from "node:url" +import { changelogProblems } from "./changelog.mjs" +import { MANIFEST_PATH, manifestProblems } from "./native-manifest.mjs" +import { NATIVE_WORKFLOW, sha256 } from "./release-commit.mjs" +import { + ACTIONS_BOT, + allowedPath, + api, + COMMIT_MUTATION, + commitIdentityProblems, + fetchReleases, + ghOutput, + nativeRunProblems, + releaseBranch, + releasedTags, + releaseHeadline, + releaseOrder, + releasePackageText, + serialise, + tagObjectProblems, + tagRefProblems, +} from "./release-rules.mjs" +import { isReleaseTag, tagFor, versionProblem } from "./release-version.mjs" +import { testedHosts } from "./tested-hosts.mjs" + +const SHA1 = /^[0-9a-f]{40}$/ +const MB = 1024 * 1024 +/** createCommitOnBranch's limits, as tools/gh-push.mjs proved them: 5 MB per file, and the request's base64 under 40 MB. */ +export const MAX_FILE = 5 * MB +export const MAX_PAYLOAD = 40 * MB + +class Refusal extends Error {} +const refuse = (message) => { + throw new Refusal(message) +} + +function gitIn(cwd, args, { env, allowFailure = false } = {}) { + const result = spawnSync("git", args, { cwd, encoding: "utf8", env: env ?? process.env, maxBuffer: 64 * MB }) + if (result.status !== 0) { + if (allowFailure) return undefined + refuse(`git ${args[0]} failed: ${(result.stderr || "").trim().split("\n")[0]}`) + } + return result.stdout.trim() +} + +function repositoryOf(env) { + const repository = env.GITHUB_REPOSITORY + if (!repository || !/^[\w.-]+\/[\w.-]+$/.test(repository)) refuse("GITHUB_REPOSITORY is not set to owner/name") + return repository +} + +/** + * The CI run a release stands on: the newest run of ci.yml for a push to master on exactly `sha`. Returns { run } or + * { problem }. Every run on the sha must have finished, and the newest must have succeeded. + */ +export function ciProblem(runs, sha) { + const mine = (runs ?? []).filter((run) => run.head_sha === sha && run.event === "push" && run.head_branch === "master" && basename(String(run.path ?? "")) === "ci.yml") + if (!mine.length) return { problem: `no ci.yml run for a push to master on ${sha.slice(0, 12)}: push it to master, and wait for CI` } + const running = mine.filter((run) => run.status !== "completed") + if (running.length) return { problem: `CI is still running on ${sha.slice(0, 12)} (run ${running.map((run) => run.id).join(", ")}): wait for it` } + const newest = [...mine].sort((a, b) => (a.created_at === b.created_at ? b.id - a.id : a.created_at < b.created_at ? 1 : -1))[0] + if (newest.conclusion !== "success") return { problem: `the newest CI run on ${sha.slice(0, 12)} (run ${newest.id}) concluded ${newest.conclusion}` } + return { run: newest } +} + +/** The plan job: every refusal before anything is built or written. Returns the outputs; throws a Refusal. */ +export function plan({ sha, nativeRun, manifestOut, env, gh, cwd }) { + const repository = repositoryOf(env) + if (env.GITHUB_REF !== "refs/heads/master") refuse(`a release runs from master only, and this run is on ${env.GITHUB_REF}`) + if (!SHA1.test(sha ?? "")) refuse("--sha must be the full 40-character sha of the master head CI passed") + if (env.GITHUB_SHA !== sha) refuse(`the run is on master at ${String(env.GITHUB_SHA).slice(0, 12)}, not ${sha.slice(0, 12)}: dispatch again with master's head, or wait for CI on it`) + if (gitIn(cwd, ["rev-parse", "HEAD"]) !== sha) refuse(`the checkout is not ${sha.slice(0, 12)}`) + + const pkgText = readFileSync(join(cwd, "package.json"), "utf8") + const pkg = JSON.parse(pkgText) + const versionRefusal = versionProblem(pkg.version) + if (versionRefusal) refuse(versionRefusal) + const tag = tagFor(pkg.version) + if (pkg.private === true) refuse('package.json has "private": true') + if (serialise(pkg) !== pkgText) refuse("package.json is not in the repository's canonical JSON form") + if (!pkg.scripts?.build) refuse('package.json has no "build" script: the release builds with npm run build') + const changelogPath = join(cwd, "CHANGELOG.md") + if (!existsSync(changelogPath)) refuse("there is no CHANGELOG.md: the release notes come from it") + const changelog = changelogProblems(readFileSync(changelogPath, "utf8"), pkg.version) + if (changelog.length) refuse(`CHANGELOG.md is not ready for ${tag}: ${changelog.join("; ")}`) + + const native = existsSync(join(cwd, NATIVE_WORKFLOW)) + const run = nativeRun === undefined || nativeRun === "" ? undefined : nativeRun + if (native && run === undefined) refuse(`this plugin is native: run the native workflow on ${sha.slice(0, 12)}, then dispatch with native_run set to its run id`) + if (!native && run !== undefined) refuse(`native_run is set, and this plugin has no ${NATIVE_WORKFLOW}: dispatch without it`) + if (run !== undefined && !/^\d+$/.test(run)) refuse("native_run must be a run id") + + const ci = ciProblem(api(gh, `repos/${repository}/actions/workflows/ci.yml/runs?head_sha=${sha}&event=push&branch=master&per_page=100`).workflow_runs, sha) + if (ci.problem) refuse(ci.problem) + + let manifestText = "" + if (run !== undefined) { + const problems = nativeRunProblems(api(gh, `repos/${repository}/actions/runs/${run}`), { sha }) + if (problems.length) refuse(`native run ${run}: ${problems.join("; ")}`) + const dir = mkdtempSync(join(tmpdir(), "native-manifest-")) + let manifest + try { + gh(["run", "download", run, "--repo", repository, "--name", "native-manifest", "--dir", dir]) + try { + manifest = JSON.parse(readFileSync(join(dir, "native.json"), "utf8")) + } catch { + refuse(`native run ${run} has no native-manifest artifact with native.json`) + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + const manifestRefusals = manifestProblems(manifest, { plugin: pkg.name.replace(/^@[^/]+\//, ""), tag, commit: sha, repository }) + if (manifestRefusals.length) refuse(`the native manifest of run ${run} is not right: ${manifestRefusals.join("; ")}`) + // The bytes the build job copies into the release commit; the commit job refuses any other (manifest_sha256). + manifestText = `${JSON.stringify(manifest, null, 2)}\n` + if (manifestOut) { + mkdirSync(join(manifestOut, ".."), { recursive: true }) + writeFileSync(manifestOut, manifestText) + } + } + + const branch = releaseBranch(tag) + if (api(gh, `repos/${repository}/git/ref/heads/${branch}`, { allow404: true })) { + refuse(`the branch ${branch} is left from an earlier run: check it is not a run waiting for approval, then delete it (gh api -X DELETE repos/${repository}/git/refs/heads/${branch})`) + } + + let released + try { + released = releasedTags(fetchReleases(repository, gh)) + } catch (error) { + refuse(`the published Releases could not be listed (${error.message})`) + } + if (released.includes(tag)) refuse(`${tag} is already a published Release: release the next version`) + + // A tag that exists is a resume only when the kit made it, on a release commit on this sha, and it never became a + // published Release. Any other tag burns the version. + let mode = "new" + let commit = "" + const ref = api(gh, `repos/${repository}/git/ref/tags/${tag}`, { allow404: true }) + if (ref) { + const why = (problems) => refuse(`the tag ${tag} exists and is not one this release can resume (${problems.join("; ")}): release the next version`) + const refProblems = tagRefProblems(ref, tag) + if (refProblems.length) why(refProblems) + const tagObject = api(gh, `repos/${repository}/git/tags/${ref.object.sha}`) + const tagged = tagObject?.object?.sha + const objectProblems = tagObjectProblems(tagObject, { tag, commit: tagged }) + if (objectProblems.length) why(objectProblems) + const apiCommit = api(gh, `repos/${repository}/commits/${tagged}`) + const identity = commitIdentityProblems(apiCommit) + if (identity.length) why(identity) + if (apiCommit.parents[0].sha !== sha) why([`its release commit's parent is ${apiCommit.parents[0].sha.slice(0, 12)}, not ${sha.slice(0, 12)}`]) + mode = "resume" + commit = tagged + } + + const order = releaseOrder({ tag, parent: sha, released, cwd }) + if (order.problem) refuse(order.problem) + + let hosts + try { + hosts = testedHosts(cwd) + } catch (error) { + refuse(error.message) + } + return { version: pkg.version, tag, hosts: JSON.stringify(hosts), native: String(native), mode, commit, ci_run: String(ci.run.id), previous: order.previous ?? "", manifest_sha256: manifestText ? sha256(manifestText) : "" } +} + +/** Every file under a folder, as repository paths, refusing anything that is not a regular file or a folder. */ +function walkFiles(root) { + const found = [] + const walk = (dir) => { + for (const name of readdirSync(dir).sort()) { + const full = join(dir, name) + const stat = lstatSync(full) + const path = relative(root, full).split("\\").join("/") + if (stat.isDirectory()) walk(full) + else if (stat.isFile()) found.push(path) + else refuse(`${path} is not a regular file`) + } + } + walk(root) + return found +} + +/** + * The files the build job wrote, checked: exactly the paths and sha256s of its paths.txt, each in the allowed set, a + * package.json that is exactly the parent's without its lifecycle scripts, the manifest only for a native plugin, and the + * sizes createCommitOnBranch takes. Returns [{ path, bytes }]. + */ +export function releaseFiles({ dir, parent, native, cwd, manifestSha256, limits = { file: MAX_FILE, payload: MAX_PAYLOAD } }) { + const listedText = existsSync(join(dir, "paths.txt")) ? readFileSync(join(dir, "paths.txt"), "utf8") : refuse("the release files have no paths.txt") + const listed = new Map() + for (const line of listedText.split("\n").filter(Boolean)) { + const match = /^([0-9a-f]{64}) {2}(.+)$/.exec(line) + if (!match || listed.has(match[2])) refuse(`paths.txt has a line that is not one " ": ${line.slice(0, 80)}`) + listed.set(match[2], match[1]) + } + const found = walkFiles(dir).filter((path) => path !== "paths.txt") + const unlisted = found.filter((path) => !listed.has(path)) + const missing = [...listed.keys()].filter((path) => !found.includes(path)) + if (unlisted.length || missing.length) refuse(`the release files are not what the build listed: ${[...unlisted.map((p) => `extra ${p}`), ...missing.map((p) => `missing ${p}`)].join(", ")}`) + + const outside = found.filter((path) => !allowedPath(path, { native })) + if (outside.length) refuse(`paths outside the allowed set: ${outside.join(", ")}`) + if (!found.includes("package.json") || !found.some((path) => path.startsWith("dist/"))) refuse("the release commit must add dist/ and change package.json") + if (native !== found.includes(MANIFEST_PATH)) refuse(native ? `a native plugin's release needs ${MANIFEST_PATH}` : `${MANIFEST_PATH} is only for a native plugin`) + if (native && !/^[0-9a-f]{64}$/.test(manifestSha256 ?? "")) refuse("a native plugin's release needs the sha256 of the manifest the plan job vetted (--manifest-sha256)") + + let payload = 0 + const files = found.map((path) => { + const bytes = readFileSync(join(dir, path)) + if (sha256(bytes) !== listed.get(path)) refuse(`${path} does not match its sha256 in paths.txt`) + if (bytes.length > limits.file) refuse(`${path} is ${(bytes.length / MB).toFixed(1)} MB, over the ${(limits.file / MB).toFixed(1)} MB a file can be`) + if (path === MANIFEST_PATH && sha256(bytes) !== manifestSha256) refuse(`${MANIFEST_PATH} is not, byte for byte, the manifest the plan job vetted`) + payload += Math.ceil(bytes.length / 3) * 4 + path.length + return { path, bytes } + }) + if (payload > limits.payload) refuse(`the release commit needs a ${(payload / MB).toFixed(1)} MB request, over the ${(limits.payload / MB).toFixed(1)} MB limit`) + + const parentPkg = gitIn(cwd, ["show", `${parent}:package.json`]) + const expected = releasePackageText(JSON.parse(parentPkg)) + if (files.find((file) => file.path === "package.json").bytes.toString("utf8") !== expected) refuse("package.json is not exactly the parent's without its lifecycle scripts") + return files +} + +/** The tree the release commit must have: the parent's, with the files written as regular files. Computed locally. */ +export function expectedTree({ files, parent, cwd, dir }) { + const folder = mkdtempSync(join(tmpdir(), "release-index-")) + const env = { ...process.env, GIT_INDEX_FILE: join(folder, "index") } + try { + gitIn(cwd, ["read-tree", parent], { env }) + for (const file of files) { + const blob = gitIn(cwd, ["hash-object", "-w", "--no-filters", "--", join(dir, file.path)], { env }) + gitIn(cwd, ["update-index", "--add", "--cacheinfo", `100644,${blob},${file.path}`], { env }) + } + return gitIn(cwd, ["write-tree"], { env }) + } finally { + rmSync(folder, { recursive: true, force: true }) + } +} + +/** The commit job. Returns { commit, tree }; throws after deleting the branch it made, when anything is wrong. */ +export function commit({ dir, parent, tag, native, manifestSha256, env, gh, cwd, limits, sleep = pause, output = () => {}, log = () => {} }) { + const repository = repositoryOf(env) + if (!isReleaseTag(tag)) refuse(`${tag} is not a release tag`) + if (!SHA1.test(parent ?? "")) refuse("--parent must be a full sha") + const files = releaseFiles({ dir, parent, native, cwd, manifestSha256, limits }) + const tree = expectedTree({ files, parent, cwd, dir }) + log(`${files.length} files checked; the release commit's tree must be ${tree}`) + + const branch = releaseBranch(tag) + if (api(gh, `repos/${repository}/git/ref/heads/${branch}`, { allow404: true })) refuse(`the branch ${branch} already exists`) + gh(["api", "-X", "POST", `repos/${repository}/git/refs`, "-f", `ref=refs/heads/${branch}`, "-f", `sha=${parent}`]) + log(`created ${branch} at ${parent}`) + + let made + const fail = (message) => { + let cleaned + try { + cleaned = cleanup({ tag, parent, commit: made, env, gh }).message + } catch (error) { + cleaned = `${branch} could not be deleted (${error.message}); the cleanup job tries again` + } + refuse(`${message}; ${cleaned}`) + } + const input = { + branch: { repositoryNameWithOwner: repository, branchName: branch }, + message: { headline: releaseHeadline(tag) }, + fileChanges: { additions: files.map((file) => ({ path: file.path, contents: file.bytes.toString("base64") })) }, + expectedHeadOid: parent, + } + try { + made = JSON.parse(gh(["api", "graphql", "--input", "-"], { input: JSON.stringify({ query: COMMIT_MUTATION, variables: { input } }) })).data.createCommitOnBranch.commit.oid + } catch (error) { + fail(`createCommitOnBranch failed: ${error.message}`) + } + if (!SHA1.test(made ?? "")) fail("createCommitOnBranch returned no commit") + output("commit", made) + + let apiCommit + for (let attempt = 0; ; attempt++) { + apiCommit = api(gh, `repos/${repository}/commits/${made}`) + const reason = apiCommit?.commit?.verification?.reason + if (apiCommit?.commit?.verification?.verified || attempt >= 5 || !/gpgverify_(unavailable|error)/.test(String(reason))) break + sleep(2000) + } + const problems = commitIdentityProblems(apiCommit) + if (apiCommit?.parents?.[0]?.sha !== parent) problems.push(`its parent is not ${parent.slice(0, 12)}`) + if (apiCommit?.commit?.tree?.sha !== tree) problems.push(`its tree is ${String(apiCommit?.commit?.tree?.sha).slice(0, 12)}, and the files give ${tree.slice(0, 12)}`) + if (problems.length) fail(`the release commit ${made.slice(0, 12)} is not right: ${problems.join("; ")}`) + output("tree", tree) + log(`release commit ${made} on ${branch}: verified (valid), author ${ACTIONS_BOT.name}, parent ${parent.slice(0, 12)}, tree ${tree.slice(0, 12)}`) + return { commit: made, tree } +} + +/** + * Deletes release/ when it points at the parent, at the run's commit, or, when the run's commit is unknown, at a + * release commit the kit made on the parent. Returns { action, message }: absent, deleted or left. + */ +export function cleanup({ tag, parent, commit: made, env, gh }) { + const repository = repositoryOf(env) + if (!isReleaseTag(tag)) refuse(`${tag} is not a release tag, so there is no release branch to clean up`) + const branch = releaseBranch(tag) + const ref = api(gh, `repos/${repository}/git/ref/heads/${branch}`, { allow404: true }) + if (!ref) return { action: "absent", message: `${branch} does not exist` } + const at = ref.object?.sha + let ours = at === parent || (made && at === made) + if (!ours && !made && at) { + const apiCommit = api(gh, `repos/${repository}/commits/${at}`, { allow404: true }) + ours = Boolean(apiCommit) && !commitIdentityProblems(apiCommit).length && apiCommit.parents[0].sha === parent && apiCommit.commit.message.split("\n")[0] === releaseHeadline(tag) + } + if (!ours) return { action: "left", message: `${branch} points at ${String(at).slice(0, 12)}, which is neither the parent nor this run's commit: left alone` } + gh(["api", "-X", "DELETE", `repos/${repository}/git/refs/heads/${branch}`]) + return { action: "deleted", message: `deleted ${branch} (it pointed at ${at.slice(0, 12)})` } +} + +/** The publish job's tag step. Returns { action: created | reused, tagObject }; throws before any write when a check fails. */ +export function tagRelease({ tag, commit: release, parent, mode, env, gh, cwd, now = () => new Date().toISOString() }) { + const repository = repositoryOf(env) + if (!isReleaseTag(tag)) refuse(`${tag} is not a release tag`) + if (!SHA1.test(release ?? "") || !SHA1.test(parent ?? "")) refuse("--commit and --parent must be full shas") + if (!["new", "resume"].includes(mode)) refuse("--mode is new or resume") + + const apiCommit = api(gh, `repos/${repository}/commits/${release}`) + const identity = commitIdentityProblems(apiCommit) + if (identity.length) refuse(`the release commit is not right: ${identity.join("; ")}`) + if (apiCommit.parents[0].sha !== parent) refuse(`the release commit's parent is not ${parent.slice(0, 12)}`) + const compare = api(gh, `repos/${repository}/compare/${parent}...master`) + if (compare?.merge_base_commit?.sha !== parent) refuse(`${parent.slice(0, 12)} is no longer on master`) + + let released + try { + released = releasedTags(fetchReleases(repository, gh)) + } catch (error) { + refuse(`the published Releases could not be listed (${error.message})`) + } + if (released.includes(tag)) refuse(`${tag} is already a published Release`) + const order = releaseOrder({ tag, parent, released, cwd }) + if (order.problem) refuse(order.problem) + + const readTag = () => { + const ref = api(gh, `repos/${repository}/git/ref/tags/${tag}`, { allow404: true }) + if (!ref) return undefined + const refProblems = tagRefProblems(ref, tag) + if (refProblems.length) return { problems: refProblems } + const tagObject = api(gh, `repos/${repository}/git/tags/${ref.object.sha}`) + return { problems: tagObjectProblems(tagObject, { tag, commit: release }), tagObject } + } + const existing = readTag() + if (existing) { + if (existing.problems.length) refuse(`the tag ${tag} already exists and is not the kit's tag on ${release.slice(0, 12)} (${existing.problems.join("; ")}): nothing was written`) + return { action: "reused", tagObject: existing.tagObject } + } + if (mode === "resume") refuse(`resume: the tag ${tag} is gone, so there is nothing to resume`) + const branch = api(gh, `repos/${repository}/git/ref/heads/${releaseBranch(tag)}`, { allow404: true }) + if (branch?.object?.sha !== release) refuse(`${releaseBranch(tag)} no longer points at the release commit ${release.slice(0, 12)}`) + + const body = { tag, message: tag, object: release, type: "commit", tagger: { name: ACTIONS_BOT.name, email: ACTIONS_BOT.email, date: now() } } + const made = JSON.parse(gh(["api", "-X", "POST", `repos/${repository}/git/tags`, "--input", "-"], { input: JSON.stringify(body) })) + gh(["api", "-X", "POST", `repos/${repository}/git/refs`, "--input", "-"], { input: JSON.stringify({ ref: `refs/tags/${tag}`, sha: made.sha }) }) + const confirmed = readTag() + if (!confirmed || confirmed.problems.length) refuse(`the tag ${tag} was created, and reading it back fails: ${confirmed?.problems.join("; ") ?? "it is missing"}`) + return { action: "created", tagObject: confirmed.tagObject } +} + +/** + * The problems with a folder of Release files: its SHA256SUMS must have the sha256 `sumsSha256`, the digest the stage job + * showed the approver in the run summary, and every file must be listed in it once, by its name, and equal. Without the + * digest the files are refused: a SHA256SUMS that only agrees with itself binds nothing to what was approved. + */ +export function sumsProblems(dir, { sumsSha256 } = {}) { + if (!/^[0-9a-f]{64}$/.test(sumsSha256 ?? "")) return ["the sha256 of SHA256SUMS that the approver saw was not given"] + const files = walkFiles(dir).filter((path) => basename(path) !== "SHA256SUMS") + const sumsPaths = walkFiles(dir).filter((path) => basename(path) === "SHA256SUMS") + if (sumsPaths.length !== 1) return [`there are ${sumsPaths.length} SHA256SUMS files; one is needed`] + const sumsBytes = readFileSync(join(dir, sumsPaths[0])) + if (sha256(sumsBytes) !== sumsSha256) return [`SHA256SUMS has the sha256 ${sha256(sumsBytes)}, not ${sumsSha256}, the one the approver saw: nothing is published`] + const sums = new Map() + const problems = [] + for (const line of sumsBytes.toString("utf8").split("\n").filter(Boolean)) { + const match = /^([0-9a-f]{64}) {2}([^/]+)$/.exec(line) + if (!match || sums.has(match[2])) problems.push(`SHA256SUMS has a line that is not one " "`) + else sums.set(match[2], match[1]) + } + const names = files.map((path) => basename(path)) + for (const path of files) { + const name = basename(path) + if (!sums.has(name)) problems.push(`${name} is not in SHA256SUMS`) + else if (sha256(readFileSync(join(dir, path))) !== sums.get(name)) problems.push(`${name} does not match SHA256SUMS`) + } + for (const name of sums.keys()) if (!names.includes(name)) problems.push(`${name} is in SHA256SUMS and not staged`) + if (new Set(names).size !== names.length) problems.push("two staged files have one name") + return problems +} + +/** + * The problems with the staged Release files against the manifest the release commit carries (`manifest`, parsed, or + * undefined for a plugin that is not native). The publish job runs it after the approval, where no repository or + * dependency code has run: every file under native/ is one the manifest pins, with its size and sha256, every gzip + * decompresses to the executable the manifest pins (size and sha256), every pinned file is there, and outside native/ + * there is only SHA256SUMS and one tarball. + */ +export function nativeAssetProblems({ dir, manifest }) { + const problems = [] + const files = walkFiles(dir) + const outside = files.filter((path) => !path.startsWith("native/") && path !== "SHA256SUMS") + if (outside.length !== 1 || !/^[^/]+\.tgz$/.test(outside[0])) problems.push(`outside native/ the staged files must be SHA256SUMS and one tarball, and they are ${outside.join(", ") || "none"}`) + const native = files.filter((path) => path.startsWith("native/")) + if (!manifest) { + if (native.length) problems.push(`the release commit has no ${MANIFEST_PATH}, and native files are staged: ${native.join(", ")}`) + return problems + } + const expected = new Map() + for (const [module, platforms] of Object.entries(manifest.modules ?? {})) { + for (const [platform, entry] of Object.entries(platforms ?? {})) expected.set(entry?.name, { label: `${module} ${platform}`, entry, gzipped: true }) + } + for (const licence of manifest.licences ?? []) expected.set(licence?.name, { label: `licence ${licence?.name}`, entry: licence, gzipped: false }) + for (const path of native) if (!expected.has(path.slice("native/".length))) problems.push(`${path} is staged, and the manifest does not pin it`) + for (const [name, { label, entry, gzipped }] of expected) { + const path = `native/${name}` + if (!native.includes(path)) { + problems.push(`${label}: ${path} is not staged`) + continue + } + const bytes = readFileSync(join(dir, path)) + if (bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) { + problems.push(`${label}: ${name} is not the file the manifest pins (${bytes.length} bytes; the manifest pins ${entry.bytes} and its sha256)`) + continue + } + if (!gzipped) continue + let executable + try { + executable = gunzipSync(bytes) + } catch { + problems.push(`${label}: ${name} does not decompress`) + continue + } + if (executable.length !== entry.executable?.bytes || sha256(executable) !== entry.executable?.sha256) { + problems.push(`${label}: ${name} does not hold the executable the manifest pins (${executable.length} bytes; the manifest pins ${entry.executable?.bytes} and its sha256)`) + } + } + return problems +} + +const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) + +function parseFlags(argv) { + const flags = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (!arg.startsWith("--") || i + 1 >= argv.length) refuse(`unexpected argument ${arg}`) + flags[arg.slice(2)] = argv[++i] + } + return flags +} + +function writeOutput(env, key, value) { + if (/[\n\r]/.test(String(value))) refuse(`the output ${key} has a line break`) + if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`) +} + +function main(argv) { + const [command, ...rest] = argv + const flags = parseFlags(rest) + const env = process.env + const cwd = process.cwd() + const gh = (args, options) => ghOutput(args, options) + const output = (key, value) => writeOutput(env, key, value) + if (command === "plan") { + const outputs = plan({ sha: flags.sha, nativeRun: flags["native-run"], manifestOut: flags["manifest-out"], env, gh, cwd }) + for (const [key, value] of Object.entries(outputs)) output(key, value) + console.log(`plan: ${outputs.tag} (${outputs.mode}${outputs.commit ? `, the tagged commit ${outputs.commit}` : ""}) on ${flags.sha}; CI run ${outputs.ci_run}; ${outputs.previous ? `after ${outputs.previous}` : "the first published Release"}; hosts ${outputs.hosts}${outputs.native === "true" ? `; native run ${flags["native-run"]}` : ""}`) + } else if (command === "commit") { + commit({ dir: flags.files, parent: flags.parent, tag: flags.tag, native: flags.native === "true", manifestSha256: flags["manifest-sha256"] || undefined, env, gh, cwd, output, log: console.log }) + } else if (command === "tag") { + const result = tagRelease({ tag: flags.tag, commit: flags.commit, parent: flags.parent, mode: flags.mode, env, gh, cwd }) + console.log(`tag ${flags.tag}: ${result.action} (tag object ${result.tagObject.sha}) on ${flags.commit}`) + } else if (command === "cleanup") { + const result = cleanup({ tag: flags.tag, parent: flags.parent, commit: flags.commit || undefined, env, gh }) + console.log(`cleanup: ${result.message}`) + if (result.action === "left") process.exitCode = 1 + } else if (command === "sums") { + const problems = sumsProblems(flags.dir, { sumsSha256: flags["sums-sha256"] }) + if (problems.length) refuse(`the staged Release files are not the approved ones: ${problems.join("; ")}`) + console.log(`sums: SHA256SUMS is ${flags["sums-sha256"]}, the digest in the run summary, and every staged file matches it`) + } else if (command === "native") { + if (!SHA1.test(flags.commit ?? "")) refuse("--commit must be the release commit's full sha") + const text = gitIn(cwd, ["show", `${flags.commit}:${MANIFEST_PATH}`], { allowFailure: true }) + const manifest = text === undefined ? undefined : JSON.parse(text) + if (manifest) { + const structure = manifestProblems(manifest, { repository: env.GITHUB_REPOSITORY }) + if (structure.length) refuse(`the release commit's ${MANIFEST_PATH} is not right: ${structure.join("; ")}`) + } + const problems = nativeAssetProblems({ dir: flags.dir, manifest }) + if (problems.length) refuse(`the staged Release files are not the ones the release commit pins: ${problems.join("; ")}`) + console.log(manifest ? `native: every staged native file is the one ${MANIFEST_PATH} pins, executables included` : "native: no native files, as the release commit has no manifest") + } else refuse(`unknown command ${command ?? "(none)"}: plan, commit, tag, cleanup, sums or native`) +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + main(process.argv.slice(2)) + } catch (error) { + console.error(`release-github: ${error.message}`) + process.exit(1) + } +} diff --git a/scripts/release-kit.test.mjs b/scripts/release-kit.test.mjs new file mode 100644 index 0000000..52ab79a --- /dev/null +++ b/scripts/release-kit.test.mjs @@ -0,0 +1,1757 @@ +// Tests for the release kit: the CHANGELOG shape, the version and its tag, the release commit's and tag's identities, the +// calls the kit never makes, the shape check, the workflow's plan, build, commit, tag, cleanup and sums steps against a +// GitHub in memory, the workflow file's shape, the pack stage's tarball check, and the Release upload that never deletes +// or replaces an asset. They run with Node's own runner and need only git; `gh` and `npm` are stand-ins: +// +// node --test scripts/release-kit.test.mjs +import assert from "node:assert/strict" +import { spawn, spawnSync } from "node:child_process" +import { createHash } from "node:crypto" +import { lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { gunzipSync, gzipSync } from "node:zlib" +import { dirname, join } from "node:path" +import { after, describe, test } from "node:test" +import { fileURLToPath } from "node:url" +import { changelogProblems, releaseNotes, shapeChangelog } from "./changelog.mjs" +import { publish } from "./publish-release.mjs" +import { pathsText } from "./release-commit.mjs" +import { cleanup, commit, MAX_FILE, MAX_PAYLOAD, nativeAssetProblems, plan, releaseFiles, sumsProblems, tagRelease } from "./release-github.mjs" +import { + ACTIONS_BOT, + allowedPath, + COMMIT_MUTATION, + commitIdentityProblems, + fetchReleases, + forbiddenCall, + ghOutput, + modeProblems, + strictJson, + releasedTags, + releasePackageText, + serialise, + tagObjectProblems, + tagRefProblems, + WEB_FLOW, +} from "./release-rules.mjs" +import { namesTarball, readsTarball } from "./release-pack.mjs" +import { RELEASE_VERSION, tagFor, tagProblem, versionProblem } from "./release-version.mjs" + +const SCRIPTS = fileURLToPath(new URL(".", import.meta.url)) +const scratch = mkdtempSync(join(tmpdir(), "release-kit-test-")) +after(() => rmSync(scratch, { recursive: true, force: true })) +let made = 0 +const folder = () => { + const dir = join(scratch, String(made++)) + mkdirSync(dir, { recursive: true }) + return dir +} + +const PREAMBLE = "# Changelog\n\nAll notable changes to this project are documented here.\n" +const EARLIER = "## Earlier releases on npm\n\nThese versions were published to npm.\n\n### npm 0.0.10 - 2026-11-08\n\n#### Added\n\n- An npm feature.\n" +const V000 = "## [0.0.0] - 2026-11-10\n\n### Changed\n\n- Installed from GitHub.\n" +const V001 = "## [0.0.1] - 2026-11-12\n\n### Fixed\n\n- A config fix.\n" +const UNRELEASED = "## [Unreleased]\n\n### Fixed\n\n- A config fix.\n" +const doc = (...sections) => [PREAMBLE, ...sections].join("\n") + +/** One GitHub release, then the npm history. */ +const ONE = doc(V000, EARLIER) +/** Two GitHub releases, newest first, then the npm history. */ +const TWO = doc(V001, V000, EARLIER) + +describe("the CHANGELOG with one GitHub release", () => { + test("is in the release shape for that version, and its notes are its section", () => { + assert.deepEqual(changelogProblems(ONE, "0.0.0"), []) + assert.equal(releaseNotes(ONE, "0.0.0"), V000.trim()) + }) + + test("is refused for a version it has no heading for", () => { + assert.deepEqual(changelogProblems(ONE, "0.0.1"), ["expected one ## [0.0.1] heading, found 0"]) + assert.throws(() => releaseNotes(ONE, "0.0.1"), /no "## \[0\.0\.1\]" heading/) + }) +}) + +describe("the CHANGELOG with two GitHub releases", () => { + test("is in the release shape for the newest, and the notes stop at the next release", () => { + assert.deepEqual(changelogProblems(TWO, "0.0.1"), []) + assert.equal(releaseNotes(TWO, "0.0.1"), V001.trim()) + assert.equal(releaseNotes(TWO, "0.0.0"), V000.trim()) + }) + + test("is refused for the older release, which is not the newest heading", () => { + assert.deepEqual(changelogProblems(TWO, "0.0.0"), ["## [0.0.0] is not the newest release heading"]) + }) + + test("is refused with the releases out of order, repeated or undated", () => { + assert.match(changelogProblems(doc(V000, V001, EARLIER), "0.0.1").join("; "), /"## \[0\.0\.0\] - 2026-11-10" is above "## \[0\.0\.1\] - 2026-11-12"/) + assert.match(changelogProblems(doc(V001, V001, V000), "0.0.1").join("; "), /expected one ## \[0\.0\.1\] heading, found 2/) + assert.match(changelogProblems(doc("## [0.0.1]\n\n- A fix.\n", V000), "0.0.1").join("; "), /"## \[0\.0\.1\]" is not dated/) + assert.match(changelogProblems(doc("## [0.0.1] - soon\n\n- A fix.\n", V000), "0.0.1").join("; "), /is not dated/) + }) + + test("is refused with the npm history above a release, or a heading the shape does not know", () => { + assert.match(changelogProblems(doc(V001, EARLIER, V000), "0.0.1").join("; "), /"## Earlier releases on npm" must be the last section/) + assert.match(changelogProblems(doc(V001, "## Notes\n\n- Something.\n", V000), "0.0.1").join("; "), /"## Notes" is not a heading the release shape knows/) + }) + + test("is refused when the release has no notes", () => { + assert.match(changelogProblems(doc("## [0.0.1] - 2026-11-12\n", V000), "0.0.1").join("; "), /the ## \[0\.0\.1\] section has no notes/) + }) +}) + +describe("a leftover ## [Unreleased]", () => { + test("is refused, and names the step that releases it", () => { + const problems = changelogProblems(doc(UNRELEASED, V000, EARLIER), "0.0.1") + assert.match(problems.join("; "), /an ## \[Unreleased\] section is still there: run node scripts\/changelog\.mjs release/) + assert.match(problems.join("; "), /expected one ## \[0\.0\.1\] heading, found 0/) + assert.match(changelogProblems(doc(UNRELEASED, V001, V000), "0.0.1").join("; "), /an ## \[Unreleased\] section is still there/) + }) +}) + +describe("shapeChangelog", () => { + const release = (text, version = "0.0.1") => shapeChangelog(text, { version, date: "2026-11-12" }) + + test("turns ## [Unreleased] into the dated release, above the earlier ones, and changes nothing else", () => { + assert.equal(release(doc(UNRELEASED, V000, EARLIER)), TWO) + }) + + test("is idempotent: a released CHANGELOG comes back unchanged", () => { + assert.equal(release(TWO), TWO) + assert.equal(release(release(doc(UNRELEASED, V000, EARLIER))), TWO) + assert.equal(release(ONE, "0.0.0"), ONE) + }) + + test("moves an ## [Unreleased] found below a release to the top", () => { + assert.equal(release(doc(V000, UNRELEASED, EARLIER)), TWO) + }) + + test("ignores a ## line inside fenced code", () => { + const fenced = "## [Unreleased]\n\n- A fix:\n\n```md\n## [9.9.9] - not a heading\n```\n" + const shaped = release(doc(fenced, V000)) + assert.equal(shaped, doc(fenced.replace("## [Unreleased]", "## [0.0.1] - 2026-11-12"), V000)) + assert.deepEqual(changelogProblems(shaped, "0.0.1"), []) + assert.match(releaseNotes(shaped, "0.0.1"), /## \[9\.9\.9\] - not a heading\n```$/) + }) + + test("refuses what it cannot shape, so nothing is written", () => { + assert.throws(() => release(ONE), /no ## \[Unreleased\] section to release as 0\.0\.1/) + assert.throws(() => release(doc(UNRELEASED, V001, V000)), /both ## \[Unreleased\] and a ## \[0\.0\.1\] heading/) + assert.throws(() => release(doc(UNRELEASED, "## [0.0.2] - 2026-11-11\n\n- Later.\n", V000)), /"## \[0\.0\.2\] - 2026-11-11" is not older than 0\.0\.1/) + assert.throws(() => release(doc("## [Unreleased]\n", V000)), /has no notes/) + assert.throws(() => release(doc(UNRELEASED, UNRELEASED, V000)), /2 ## \[Unreleased\] sections/) + assert.throws(() => release(doc(UNRELEASED, "## Notes\n\n- Something.\n")), /"## Notes" is not a heading/) + assert.throws(() => shapeChangelog(ONE, { version: "0.0.1-rc.1", date: "2026-11-12" }), /is not x\.y\.z/) + assert.throws(() => shapeChangelog(ONE, { version: "0.0.1", date: "12 Nov" }), /is not YYYY-MM-DD/) + }) +}) + +describe("code fences, as CommonMark reads them", () => { + const fenced = (...lines) => `${lines.join("\n")}\n` + + test("refuse a CHANGELOG whose fence is never closed: it would swallow every later section", () => { + const open = doc(V001 + fenced("```md", "- code"), V000, EARLIER) + assert.match(changelogProblems(open, "0.0.1").join("; "), /the code fence on line \d+ is never closed/) + assert.throws(() => releaseNotes(open, "0.0.1"), /is never closed/) + assert.throws(() => shapeChangelog(doc(UNRELEASED + fenced("~~~"), V000), { version: "0.0.1", date: "2026-11-12" }), /is never closed/) + }) + + test("close only with the same character, at least as many: a ``` fence does not end at ~~~", () => { + // The ~~~ line is code; the ``` after it closes the fence, so the ## [0.0.2] below is a real heading, out of order. + const text = doc(V001 + fenced("```md", "~~~", "```"), "## [0.0.2] - 2026-11-13\n\n- Hidden before.\n", V000) + assert.match(changelogProblems(text, "0.0.1").join("; "), /"## \[0\.0\.1\] - 2026-11-12" is above "## \[0\.0\.2\] - 2026-11-13"/) + // Inside a ~~~ fence, ``` is code, and so is the ## line; ~~~ closes it. + const tilde = doc(V001 + fenced("~~~", "```", "## [9.9.9] - code", "~~~"), V000) + assert.deepEqual(changelogProblems(tilde, "0.0.1"), []) + assert.match(releaseNotes(tilde, "0.0.1"), /## \[9\.9\.9\] - code\n~~~$/) + // A longer fence needs a closing fence as long; one with an info string does not close. + assert.deepEqual(changelogProblems(doc(V001 + fenced("````", "```", "## [9.9.9] - code", "````"), V000), "0.0.1"), []) + assert.deepEqual(changelogProblems(doc(V001 + fenced("```", "```js", "## [9.9.9] - code", "```"), V000), "0.0.1"), []) + }) + + test("recognise a fence indented by up to three spaces, and not by four", () => { + assert.deepEqual(changelogProblems(doc(V001 + fenced(" ```", "## [9.9.9] - code", " ```"), V000), "0.0.1"), []) + assert.match(changelogProblems(doc(V001 + fenced(" ```", "## [9.9.9] - code", " ```"), V000), "0.0.1").join("; "), /9\.9\.9/) + // A backtick fence's info string has no backtick, so this line opens nothing. + assert.match(changelogProblems(doc(V001 + fenced("``` a`b", "## [9.9.9] - code"), V000), "0.0.1").join("; "), /9\.9\.9/) + }) + + test("refuse a heading indented by spaces, which renders as a heading but is not one here", () => { + assert.match(changelogProblems(doc(V001 + fenced(" ## [0.0.2] - 2026-11-13"), V000), "0.0.1").join("; "), /heading indented by spaces/) + }) +}) + +describe("one version pattern", () => { + test("x.y.z with no leading zeros and no prerelease or build part, in every kit script", () => { + for (const version of ["0.0.0", "0.0.1", "1.20.3", "10.0.0"]) assert.match(version, RELEASE_VERSION) + for (const version of ["01.0.0", "0.01.0", "0.0.01", "0.0.1-rc.1", "0.0.1+build", "0.0", "v0.0.1"]) assert.doesNotMatch(version, RELEASE_VERSION) + assert.match(changelogProblems(doc("## [01.0.0] - 2026-11-12\n\n- A fix.\n", V000), "0.0.0").join("; "), /"## \[01\.0\.0\] - 2026-11-12" is not a heading the release shape knows/) + assert.deepEqual(changelogProblems(ONE, "00.0.0"), ['the version "00.0.0" is not x.y.z']) + assert.throws(() => shapeChangelog(doc(UNRELEASED, V000), { version: "0.0.01", date: "2026-11-12" }), /is not x\.y\.z/) + for (const script of ["changelog.mjs", "release-commit.mjs", "release-github.mjs", "release-rules.mjs", "check-release-shape.mjs"]) { + const source = readFileSync(join(SCRIPTS, script), "utf8") + assert.match(source, /from "\.\/release-version\.mjs"/, script) + assert.doesNotMatch(source, /\\d\+\\\.|\[1-9\]\\d\*/, `${script} has a version pattern of its own`) + } + }) +}) + +describe("the version and its tag", () => { + test("the tag is exactly v, for an x.y.z version", () => { + assert.equal(tagFor("0.0.1"), "v0.0.1") + assert.equal(tagProblem("v0.0.1", "0.0.1"), undefined) + assert.equal(tagProblem("v1.20.3", "1.20.3"), undefined) + }) + + test("refuses a tag that differs from v", () => { + assert.equal(tagProblem("v0.0.0", "0.0.1"), "the tag is v0.0.0, and package.json's version 0.0.1 releases only as v0.0.1") + for (const tag of ["0.0.1", "v0.0.1 ", "V0.0.1", "v0.0.10", "release-0.0.1"]) assert.match(tagProblem(tag, "0.0.1"), /releases only as v0\.0\.1/, tag) + }) + + test("refuses a version that is not x.y.z", () => { + for (const version of ["0.0.1-rc.1", "0.0.1+build", "01.0.0", "0.0", "v0.0.1", "", undefined, 1]) { + assert.match(versionProblem(version), /is not x\.y\.z/, String(version)) + assert.match(tagProblem(`v${version}`, version), /is not x\.y\.z/, String(version)) + } + }) +}) + +/** A published GitHub Release of a tag, as the Releases API lists it, and a draft (never published). */ +const published = (tag) => ({ tag_name: tag, draft: false, published_at: "2026-11-10T12:00:00Z" }) +const draft = (tag) => ({ tag_name: tag, draft: true, published_at: null }) + +/** A release commit as GET repos/{r}/commits/{sha} returns it when the release workflow made it. */ +const botCommit = ({ sha, tree = "t".repeat(40), parents, message = "chore(release)" }) => ({ + sha, + commit: { + author: { name: ACTIONS_BOT.name, email: ACTIONS_BOT.email, date: "2026-11-11T00:00:00Z" }, + committer: { name: WEB_FLOW.name, email: WEB_FLOW.email, date: "2026-11-11T00:00:00Z" }, + message, + tree: { sha: tree }, + verification: { verified: true, reason: "valid", signature: "-----BEGIN PGP SIGNATURE-----" }, + }, + author: { login: ACTIONS_BOT.login, type: "Bot" }, + committer: { login: "web-flow", type: "User" }, + parents: parents.map((parent) => ({ sha: parent })), +}) + +/** A tag object as GET repos/{r}/git/tags/{sha} returns the one the kit makes. */ +const kitTag = ({ sha, tag, commit }) => ({ + sha, + tag, + message: `${tag}\n`, + tagger: { name: ACTIONS_BOT.name, email: ACTIONS_BOT.email, date: "2026-11-11T00:00:00Z" }, + object: { type: "commit", sha: commit }, + verification: { verified: false, reason: "unsigned", signature: null, payload: null }, +}) + +/** + * A `gh` for the scripts run as processes. Answers from the routes file first (a path to a JSON body, or 404), then: + * the Releases from FAKE_GH_RELEASES; a commit, as the workflow would have made it, from the local git; a tag ref and + * tag object from the local tags, as the kit makes them. FAKE_GH_FAIL fails every call as a refused request does. + */ +const FAKE_GH = `#!/usr/bin/env node +const ACTIONS_BOT = ${JSON.stringify(ACTIONS_BOT)} +const WEB_FLOW = ${JSON.stringify(WEB_FLOW)} +const { readFileSync } = require("node:fs") +const { spawnSync } = require("node:child_process") +const args = process.argv.slice(2) +const fail = (message) => { process.stderr.write(message + "\\n"); process.exit(1) } +if (process.env.FAKE_GH_FAIL) fail("HTTP 403: Resource not accessible by integration") +if (args[0] !== "api" || args.length !== 2) fail("fake gh: unexpected call: " + args.join(" ")) +const routes = JSON.parse(readFileSync(process.env.FAKE_GH_ROUTES, "utf8")) +const git = (...a) => { const r = spawnSync("git", a, { encoding: "utf8" }); return r.status === 0 ? r.stdout.trim() : undefined } +const print = (body) => { process.stdout.write(JSON.stringify(body)); process.exit(0) } +const prefix = "repos/owner/kit-test/" +const path = args[1] +if (!path.startsWith(prefix)) fail("fake gh: unexpected path " + path) +const rest = path.slice(prefix.length) +if (rest in routes) routes[rest] === 404 ? fail("gh: Not Found (HTTP 404)") : print(routes[rest]) +let match +if (rest === "releases?per_page=100&page=1") print(JSON.parse(readFileSync(process.env.FAKE_GH_RELEASES, "utf8"))) +if (rest.startsWith("releases?per_page=100&page=")) print([]) +if ((match = /^commits\\/([0-9a-f]{40})$/.exec(rest))) { + const parents = (git("rev-list", "--parents", "-n", "1", match[1]) ?? fail("gh: Not Found (HTTP 404)")).split(" ").slice(1) + print((${botCommit.toString().replace(/\n\s*/g, " ")})({ sha: match[1], tree: git("rev-parse", match[1] + "^{tree}"), parents, message: git("log", "-1", "--format=%B", match[1]) })) +} +if ((match = /^git\\/ref\\/tags\\/(.+)$/.exec(rest))) { + const type = git("cat-file", "-t", "refs/tags/" + match[1]) + if (!type) fail("gh: Not Found (HTTP 404)") + print({ ref: "refs/tags/" + match[1], object: { type, sha: git("rev-parse", "refs/tags/" + match[1]) } }) +} +if ((match = /^git\\/tags\\/([0-9a-f]{40})$/.exec(rest))) { + const text = git("cat-file", "tag", match[1]) ?? fail("gh: Not Found (HTTP 404)") + const field = (name) => (new RegExp("^" + name + " (.*)$", "m").exec(text) || [])[1] + print((${kitTag.toString().replace(/\n\s*/g, " ")})({ sha: match[1], tag: field("tag"), commit: field("object") })) +} +fail("fake gh: unexpected path " + path) +` + +/** Writes files at their paths under a folder, making the folders. */ +function writeTree(dir, files) { + for (const [path, text] of Object.entries(files)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }) + writeFileSync(join(dir, path), text) + } +} + +/** + * A git repository in a scratch folder, with no global or system config, committing as kit-test. Its origin is + * owner/kit-test on GitHub, and `gh` is the fake above, answering with `releases` and `setRoutes`. `npm` is a fake that + * builds with `node build.mjs`. + */ +function repository(files, { releases = [] } = {}) { + const dir = folder() + const home = folder() + const bin = folder() + writeFileSync(join(home, ".gitconfig"), "") + writeFileSync(join(bin, "gh"), FAKE_GH, { mode: 0o755 }) + writeFileSync(join(bin, "npm"), '#!/bin/sh\ncase "$1 $2" in "ci --ignore-scripts") exit 0 ;; "run build") exec node build.mjs ;; esac\necho "fake npm: $*" >&2\nexit 1\n', { mode: 0o755 }) + const releasesFile = join(home, "releases.json") + const routesFile = join(home, "routes.json") + const env = { + ...process.env, + HOME: home, + GIT_CONFIG_GLOBAL: join(home, ".gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + PATH: `${bin}:${process.env.PATH}`, + GITHUB_REPOSITORY: "owner/kit-test", + FAKE_GH_RELEASES: releasesFile, + FAKE_GH_ROUTES: routesFile, + FAKE_GH_FAIL: "", + } + const git = (...args) => { + const result = spawnSync("git", args, { cwd: dir, env, encoding: "utf8" }) + assert.equal(result.status, 0, `git ${args.join(" ")}: ${result.stderr}`) + return result.stdout.trim() + } + git("init", "-q", "-b", "master") + git("config", "user.name", "kit-test") + git("config", "user.email", "kit-test@example.invalid") + git("config", "commit.gpgsign", "false") + git("config", "tag.gpgsign", "false") + git("remote", "add", "origin", "https://github.com/owner/kit-test.git") + writeTree(dir, files) + git("add", ".") + git("commit", "-q", "-m", "chore: the source") + const setReleases = (list) => writeFileSync(releasesFile, JSON.stringify(list)) + const setRoutes = (routes) => writeFileSync(routesFile, JSON.stringify(routes)) + setReleases(releases) + setRoutes({}) + const node = (script, args, extra = {}) => spawnSync(process.execPath, [join(SCRIPTS, script), ...args], { cwd: dir, env: { ...env, ...extra }, encoding: "utf8" }) + return { dir, env, git, node, setReleases, setRoutes, head: git("rev-parse", "HEAD") } +} + +/** A later commit on master, so a release can have older source than another. Returns its sha. */ +function laterCommit(repo, name = "later.txt") { + writeFileSync(join(repo.dir, name), `${name}\n`) + repo.git("add", name) + repo.git("commit", "-q", "-m", `chore: ${name}`) + return repo.git("rev-parse", "HEAD") +} + +/** + * A release commit on `from`: dist/ and the given package.json text, then back to master. Tagged (annotated) unless + * `tag` is null; `name` is then the version it is for. `tweak` changes the index before the commit (a mode, a symlink, + * an extra file). Returns the commit. + */ +function releaseOn(repo, { tag, pkgText, from, tweak }) { + repo.git("checkout", "-q", "--detach", from ?? repo.git("rev-parse", "master")) + mkdirSync(join(repo.dir, "dist"), { recursive: true }) + writeFileSync(join(repo.dir, "dist", "index.js"), "export default 1\n") + writeFileSync(join(repo.dir, "dist", "tui.js"), "export default 2\n") + writeFileSync(join(repo.dir, "package.json"), pkgText) + repo.git("add", "-f", "dist", "package.json") + tweak?.(repo) + repo.git("commit", "-q", "-m", `chore(release): ${tag ?? "untagged"}`) + const commit = repo.git("rev-parse", "HEAD") + if (tag) repo.git("tag", "-a", tag, "-m", tag) + repo.git("checkout", "-q", "-f", "master") + return commit +} + +/** check-release-shape.mjs on a tag (GitHub's view of it from the fake gh), or on a commit before its tag. */ +const shapeOf = (repo, tag, extra) => repo.node("check-release-shape.mjs", ["--tag", tag, "--branch", "master"], extra).stdout +const shapeOfCommit = (repo, commit, tagName, extra) => repo.node("check-release-shape.mjs", ["--commit", commit, "--tag-name", tagName, "--branch", "master"], extra).stdout + +describe("the release commit's identity", () => { + const good = () => botCommit({ sha: "c".repeat(40), parents: ["p".repeat(40)] }) + + test("accepts github-actions[bot] as author, GitHub's web-flow as committer, verified and valid, one parent", () => { + assert.deepEqual(commitIdentityProblems(good()), []) + }) + + test("refuses each of these alone: unverified, a reason other than valid, a person's author, the bot's address on another account, another committer, two parents", () => { + const cases = { + unverified: [(c) => (c.commit.verification = { verified: false, reason: "unsigned" }), /does not verify the release commit's signature \(reason: unsigned\)/], + "reason not valid": [(c) => (c.commit.verification.reason = "expired_key"), /reason: expired_key/], + "a person's noreply author": [(c) => (c.commit.author.email = "91376460+joe-broadhead@users.noreply.github.com"), /author is another address, not github-actions\[bot\]/], + "the bot's address, another login": [(c) => (c.author = { login: "joe-broadhead", type: "User" }), /author account is not the github-actions\[bot\] app/], + "the bot's address, not a Bot": [(c) => (c.author.type = "User"), /author account is not the github-actions\[bot\] app/], + "no author account": [(c) => (c.author = null), /author account is not/], + "another committer": [(c) => (c.commit.committer = { name: "GitHub", email: "someone@example.com" }), /committer is not GitHub's web-flow/], + "two parents": [(c) => c.parents.push({ sha: "q".repeat(40) }), /has 2 parents; it needs exactly one/], + } + for (const [label, [change, expected]] of Object.entries(cases)) { + const apiCommit = good() + change(apiCommit) + const problems = commitIdentityProblems(apiCommit) + assert.equal(problems.length, 1, `${label}: ${problems.join("; ")}`) + assert.match(problems[0], expected, label) + } + }) + + test("never prints an address it does not know", () => { + const apiCommit = good() + apiCommit.commit.author.email = "private.person@example.com" + apiCommit.commit.committer.email = "another.person@example.com" + const text = commitIdentityProblems(apiCommit).join("; ") + assert.doesNotMatch(text, /private\.person|another\.person|example\.com/) + assert.match(text, /another address/) + }) +}) + +describe("the release tag object", () => { + const COMMIT = "c".repeat(40) + const good = () => kitTag({ sha: "a".repeat(40), tag: "v0.0.1", commit: COMMIT }) + + test("accepts the kit's tag, with or without the message's trailing newline", () => { + assert.deepEqual(tagObjectProblems(good(), { tag: "v0.0.1", commit: COMMIT }), []) + assert.deepEqual(tagObjectProblems({ ...good(), message: "v0.0.1" }, { tag: "v0.0.1", commit: COMMIT }), []) + assert.deepEqual(tagRefProblems({ object: { type: "tag", sha: "a".repeat(40) } }, "v0.0.1"), []) + }) + + test("refuses a lightweight ref, the wrong name or message, another commit, a non-commit object, another tagger, and any signature", () => { + assert.match(tagRefProblems({ object: { type: "commit", sha: COMMIT } }, "v0.0.1").join(), /lightweight tag/) + assert.match(tagRefProblems(undefined, "v0.0.1").join(), /there is no tag v0\.0\.1/) + const cases = { + "another name": [(t) => (t.tag = "v0.0.2"), /named v0\.0\.2, not v0\.0\.1/], + "another message": [(t) => (t.message = "Release v0.0.1\n"), /message is not exactly v0\.0\.1/], + "another commit": [(t) => (t.object.sha = "d".repeat(40)), /points at dddddddddddd, not the release commit/], + "a tree": [(t) => (t.object.type = "tree"), /points at a tree, not a commit/], + "another tagger": [(t) => (t.tagger.email = "91376460+joe-broadhead@users.noreply.github.com"), /tagger is not github-actions\[bot\]/], + "another tagger name": [(t) => (t.tagger.name = "joe-broadhead"), /tagger is not github-actions\[bot\]/], + "a signature GitHub cannot check": [(t) => (t.verification = { verified: false, reason: "invalid" }), /must be unsigned, and GitHub says not verified \(invalid\)/], + "a bad email": [(t) => (t.verification = { verified: false, reason: "bad_email" }), /\(bad_email\)/], + "a valid signature": [(t) => (t.verification = { verified: true, reason: "valid" }), /says verified \(valid\)/], + } + for (const [label, [change, expected]] of Object.entries(cases)) { + const tagObject = good() + change(tagObject) + const problems = tagObjectProblems(tagObject, { tag: "v0.0.1", commit: COMMIT }) + assert.equal(problems.length, 1, `${label}: ${problems.join("; ")}`) + assert.match(problems[0], expected, label) + } + }) +}) + +describe("the calls the kit makes, and no other", () => { + const SHA = "a".repeat(40) + const TAG_BODY = JSON.stringify({ tag: "v0.0.1", message: "v0.0.1", object: SHA, type: "commit", tagger: { name: ACTIONS_BOT.name, email: ACTIONS_BOT.email, date: "2026-11-12T00:00:00Z" } }) + const REF_BODY = JSON.stringify({ ref: "refs/tags/v0.0.1", sha: SHA }) + const R = { repository: "o/r" } + // Absolute local paths, as the workflow passes them ($RUNNER_TEMP/...), built at run time: no machine path is written here. + const local = (name) => join(tmpdir(), name) + const HEADLINE = "chore(release): v0.0.1 with the built dist/ and no lifecycle scripts" + const commitInput = (change = {}) => ({ + branch: { repositoryNameWithOwner: "o/r", branchName: "release/v0.0.1" }, + message: { headline: HEADLINE }, + fileChanges: { additions: [{ path: "dist/index.js", contents: "YQ==" }, { path: "package.json", contents: "e30=" }, { path: "manifest/native.json", contents: "e30=" }] }, + expectedHeadOid: SHA, + ...change, + }) + const commitBody = (change) => JSON.stringify({ query: COMMIT_MUTATION, variables: { input: commitInput(change) } }) + const COMMIT_BODY = commitBody() + + test("approving or rejecting a deployment, environments, rulesets, bypasses and protection are refused before gh runs, reads included", () => { + const refused = [ + ["api", "-X", "POST", "repos/o/r/actions/runs/1/pending_deployments", "-f", "state=approved"], + ["api", "repos/o/r/actions/runs/1/pending_deployments"], + ["api", "-X", "POST", "repos/o/r/actions/runs/1/deployment_protection_rule"], + ["api", "-X", "PUT", "repos/o/r/environments/release"], + ["api", "repos/o/r/environments/release"], + ["api", "repos/o/r/environments/release/deployment-branch-policies"], + ["api", "-X", "PUT", "repos/o/r/rulesets/1", "--input", "-"], + ["api", "repos/o/r/rulesets"], + ["api", "-X", "DELETE", "repos/o/r/rulesets/1"], + ["api", "repos/o/r/bypass-requests/push-rules"], + ["api", "-X", "PUT", "repos/o/r/branches/master/protection"], + ["api", "repos/o/r/branches/master/protection"], + ["run", "approve", "1"], + ["run", "rerun", "1"], + ["workflow", "run", "release.yml"], + ["api", "graphql", "-f", "query=mutation { approveDeployments(input: {}) { clientMutationId } }"], + ] + for (const args of refused) assert.ok(forbiddenCall(args, undefined, R), args.join(" ")) + for (const query of ["mutation($i: X!) { approveDeployments(input: $i) { a } }", "mutation { updateRepositoryRuleset(input: {}) { a } }", "mutation { updateEnvironment(input: {}) { a } }", "mutation { createBypassRequest(input: {}) { a } }"]) { + assert.ok(forbiddenCall(["api", "graphql", "--input", "-"], JSON.stringify({ query }), R), query) + } + assert.throws(() => ghOutput(["api", "-X", "POST", "repos/o/r/actions/runs/1/pending_deployments"], R), /refused: the release kit makes only its own GitHub calls, and not POST repos\/o\/r\/actions\/runs\/1\/pending_deployments; only the owner approves a release/) + assert.throws(() => ghOutput(["api", "repos/o/r/environments/release"], R), /refused: the release kit makes only its own GitHub calls/) + }) + + test("refuses the shapes a denylist missed: attached and long field flags, a query file, an input file, GraphQL comments, encoded and relative paths", () => { + const approve = "mutation { approveDeployments(input: {}) { clientMutationId } }" + const refused = [ + [["api", "graphql", `-fquery=${approve}`]], + [["api", "graphql", `--raw-field=query=${approve}`]], + [["api", "graphql", "--raw-field", `query=${approve}`]], + [["api", "graphql", "-F", "query=@approve.graphql"]], + [["api", "graphql", "--field", "query=@approve.graphql"]], + [["api", "graphql", "--input", "approve.json"]], + [["api", "graphql", "--input", "-"], JSON.stringify({ query: `mutation { approve#\nDeployments(input: {}) { a } }` })], + [["api", "graphql", "--input", "-"], JSON.stringify({ query: `mutation { approveDeployments #(\n(input: {}) { a } }` })], + [["api", "graphql", "--input", "-"], JSON.stringify({ query: `${COMMIT_MUTATION} # with a comment` })], + [["api", "graphql", "--input", "-"], JSON.stringify({ query: COMMIT_MUTATION, variables: { input: commitInput() }, operationName: "x" })], + [["api", "graphql", "--input", "-"], "not json"], + [["api", "repos/o/r/actions/runs/1/pending%5Fdeployments"]], + [["api", "-X", "POST", "repos/o/r/actions/runs/1/pending%5fdeployments"]], + [["api", "repos/o/r/environments%2Frelease"]], + [["api", "repos/o/r/git/refs/heads/../../environments/release"]], + [["api", "repos/o/r/commits/../../../rulesets"]], + [["api", "repos//o/r/rulesets"]], + [["api", "/repos/o/r/rulesets"]], + [["api", "-XPOST", "repos/o/r/actions/runs/1/pending_deployments"]], + [["api", "--method", "POST", "repos/o/r/actions/runs/1/pending_deployments"]], + [["api", "-X", "GET", `repos/o/r/commits/${SHA}`, "-f", "x=y"]], + [["api", `repos/o/r/commits/${SHA}`, "--hostname", "evil.example"]], + [["api", "-X", "PATCH", "repos/o/r/git/refs/heads/master", "-f", `sha=${SHA}`, "-F", "force=true"]], + [["api", "-X", "DELETE", "repos/o/r/git/refs/heads/master"]], + [["api", "-X", "DELETE", "repos/o/r/git/refs/tags/v0.0.1"]], + [["api", "-X", "POST", "repos/o/r/git/refs", "-f", "ref=refs/heads/master", "-f", `sha=${SHA}`]], + [["api", "-X", "POST", "repos/o/r/git/refs", "--input", "-"], JSON.stringify({ ref: "refs/heads/master", sha: SHA })], + [["api", "-X", "POST", "repos/o/r/git/refs", "--input", "-"], JSON.stringify({ ref: "refs/tags/v0.0.1", sha: SHA, force: true })], + [["api", "-X", "POST", "repos/o/r/git/tags", "--input", "-"], JSON.stringify({ ...JSON.parse(TAG_BODY), tag: "nightly", message: "nightly" })], + [["release", "delete", "v0.0.1"]], + [["release", "upload", "v0.0.1", "--clobber", "a.tgz"]], + [["release", "create", "v0.0.1", "--draft"]], + [["run", "download", "1", "--repo", "o/r", "--dir", "--name"]], + [["attestation", "verify", "a.gz", "--repo", "o/r", "--owner", "x"]], + ] + for (const [args, input] of refused) assert.ok(forbiddenCall(args, input, R), `${args.join(" ")} ${input ?? ""}`) + }) + + test("the release commit's createCommitOnBranch is pinned: this repository, release/v, an expected head, the headline, release paths only", () => { + const refused = { + "another repository": commitBody({ branch: { repositoryNameWithOwner: "o/other", branchName: "release/v0.0.1" } }), + master: commitBody({ branch: { repositoryNameWithOwner: "o/r", branchName: "master" } }), + "another branch": commitBody({ branch: { repositoryNameWithOwner: "o/r", branchName: "release/v0.0.1-x" } }), + "no expected head": JSON.stringify({ query: COMMIT_MUTATION, variables: { input: (({ expectedHeadOid, ...rest }) => rest)(commitInput()) } }), + "another headline": commitBody({ message: { headline: "chore: tidy" } }), + "a body": commitBody({ message: { headline: HEADLINE, body: "x" } }), + "a deletion of README.md": commitBody({ fileChanges: { additions: [{ path: "package.json", contents: "e30=" }], deletions: [{ path: "README.md" }] } }), + "only deletions": commitBody({ fileChanges: { deletions: [{ path: "README.md" }] } }), + "a path outside the release": commitBody({ fileChanges: { additions: [{ path: "README.md", contents: "eA==" }] } }), + "a workflow": commitBody({ fileChanges: { additions: [{ path: ".github/workflows/ci.yml", contents: "eA==" }] } }), + "a package.json under dist/": commitBody({ fileChanges: { additions: [{ path: "dist/package.json", contents: "e30=" }] } }), + "one path twice": commitBody({ fileChanges: { additions: [{ path: "package.json", contents: "e30=" }, { path: "package.json", contents: "e30=" }] } }), + "no additions": commitBody({ fileChanges: { additions: [] } }), + "an extra input key": commitBody({ clientMutationId: "x" }), + } + for (const path of ["dist/../package.json", "dist/../.github/workflows/ci.yml", "dist//index.js", "/dist/index.js", "./dist/index.js", "dist/./index.js", "dist\\index.js", "dist/index.js\u0000", "dist/a\nb.js", "dist/.."]) { + refused[`the path ${JSON.stringify(path)}`] = commitBody({ fileChanges: { additions: [{ path, contents: "eA==" }] } }) + } + for (const [label, body] of Object.entries(refused)) assert.ok(forbiddenCall(["api", "graphql", "--input", "-"], body, R), label) + assert.equal(forbiddenCall(["api", "graphql", "--input", "-"], COMMIT_BODY, R), undefined) + assert.match(forbiddenCall(["api", "graphql", "--input", "-"], COMMIT_BODY, { repository: "" }), /another repository than GITHUB_REPOSITORY/) + assert.match(forbiddenCall(["api", `repos/o/other/commits/${SHA}`], undefined, R), /another repository than o\/r/) + assert.match(forbiddenCall(["run", "download", "1", "--repo", "o/other", "--dir", local("x")], undefined, R), /another repository than o\/r/) + assert.match(forbiddenCall(["api", "graphql", "--input", "-"], commitBody({ fileChanges: { additions: [{ path: "dist/../package.json", contents: "e30=" }] } }), R), /an empty, relative, absolute or control part/) + }) + + test("a call on a repository is refused while GITHUB_REPOSITORY is unset or not owner/name", () => { + for (const repository of ["", "o", "o/r/x", "o/r ", "../r", 7]) { + assert.match(forbiddenCall(["api", `repos/o/r/commits/${SHA}`], undefined, { repository }), /GITHUB_REPOSITORY is unset or not owner\/name|another repository/, String(repository)) + assert.ok(forbiddenCall(["run", "download", "1", "--repo", "o/r", "--dir", local("x")], undefined, { repository }), String(repository)) + assert.ok(forbiddenCall(["attestation", "verify", local("a.gz"), "--repo", "o/r"], undefined, { repository }), String(repository)) + } + assert.match(forbiddenCall(["api", `repos/o/r/commits/${SHA}`], undefined, { repository: "" }), /GITHUB_REPOSITORY is unset or not owner\/name/) + assert.equal(forbiddenCall(["api", `repos/o/r/commits/${SHA}`], undefined, R), undefined) + assert.equal(forbiddenCall(["release", "view", "v0.0.1", "--json", "assets,isDraft"], undefined, { repository: "" }), undefined, "gh release names no repository") + }) + + test("a body with a repeated key is refused, and gh is sent only the body that was checked", () => { + const deleteRef = "mutation { deleteRef(input: {refId: \"x\"}) { clientMutationId } }" + const twoQueries = `{"query":${JSON.stringify(deleteRef)},"query":${JSON.stringify(COMMIT_MUTATION)},"variables":${JSON.stringify({ input: commitInput() })}}` + assert.equal(JSON.parse(twoQueries).query, COMMIT_MUTATION, "JSON.parse alone would see the kit's mutation") + assert.ok(forbiddenCall(["api", "graphql", "--input", "-"], twoQueries, R)) + const twoRefs = `{"ref":"refs/heads/evil","ref":"refs/tags/v0.0.1","sha":"${SHA}"}` + assert.equal(JSON.parse(twoRefs).ref, "refs/tags/v0.0.1") + assert.ok(forbiddenCall(["api", "-X", "POST", "repos/o/r/git/refs", "--input", "-"], twoRefs, R)) + const nested = JSON.stringify({ query: COMMIT_MUTATION, variables: { input: commitInput() } }).replace('"branchName":"release/v0.0.1"', '"branchName":"master","branchName":"release/v0.0.1"') + assert.ok(forbiddenCall(["api", "graphql", "--input", "-"], nested, R)) + assert.throws(() => strictJson(twoRefs), /the key "ref" appears twice in one object/) + assert.deepEqual(strictJson('{"a":{"b":1},"b":[{"b":1},{"b":2}],"c":"\\"b\\":1,\\"b\\":2"}'), { a: { b: 1 }, b: [{ b: 1 }, { b: 2 }], c: '"b":1,"b":2' }) + + const bin = folder() + writeFileSync(join(bin, "gh"), "#!/bin/sh\ncat\n", { mode: 0o755 }) + const saved = { PATH: process.env.PATH, GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY } + process.env.PATH = `${bin}:${process.env.PATH}` + process.env.GITHUB_REPOSITORY = "o/r" + try { + const spaced = `{ "ref" : "refs/tags/v0.0.1",\n "sha": "${SHA}" }` + assert.equal(ghOutput(["api", "-X", "POST", "repos/o/r/git/refs", "--input", "-"], { input: spaced }), JSON.stringify({ ref: "refs/tags/v0.0.1", sha: SHA })) + assert.throws(() => ghOutput(["api", "-X", "POST", "repos/o/r/git/refs", "--input", "-"], { input: twoRefs }), /refused/) + } finally { + process.env.PATH = saved.PATH + if (saved.GITHUB_REPOSITORY === undefined) delete process.env.GITHUB_REPOSITORY + else process.env.GITHUB_REPOSITORY = saved.GITHUB_REPOSITORY + } + }) + + test("the kit's own calls pass", () => { + const allowed = [ + [["api", `repos/o/r/actions/workflows/ci.yml/runs?head_sha=${SHA}&event=push&branch=master&per_page=100`]], + [["api", "repos/o/r/actions/runs/1"]], + [["api", "repos/o/r/git/ref/heads/release/v0.0.1"]], + [["api", "repos/o/r/git/ref/tags/v0.0.1"]], + [["api", `repos/o/r/git/tags/${SHA}`]], + [["api", `repos/o/r/commits/${SHA}`]], + [["api", "repos/o/r/releases?per_page=100&page=2"]], + [["api", `repos/o/r/compare/${SHA}...master`]], + [["run", "download", "1", "--repo", "o/r", "--name", "native-manifest", "--dir", local("x")]], + [["run", "download", "1", "--repo", "o/r", "--dir", local("x")]], + [["attestation", "verify", local("x/a.gz"), "--repo", "o/r"]], + [["api", "-X", "POST", "repos/o/r/git/refs", "-f", "ref=refs/heads/release/v0.0.1", "-f", `sha=${SHA}`]], + [["api", "-X", "POST", "repos/o/r/git/refs", "--input", "-"], REF_BODY], + [["api", "-X", "POST", "repos/o/r/git/tags", "--input", "-"], TAG_BODY], + [["api", "-X", "DELETE", "repos/o/r/git/refs/heads/release/v0.0.1"]], + [["api", "graphql", "--input", "-"], COMMIT_BODY], + [["release", "view", "v0.0.1", "--json", "assets,isDraft"]], + [["release", "create", "v0.0.1", "--verify-tag", "--title", "v0.0.1", "--notes-file", local("notes.md"), local("a.tgz"), local("SHA256SUMS")]], + [["release", "download", "v0.0.1", "--pattern", "SHA256SUMS", "--dir", local("x")]], + [["release", "upload", "v0.0.1", local("a.tgz")]], + ] + for (const [args, input] of allowed) assert.equal(forbiddenCall(args, input, R), undefined, args.join(" ")) + }) + + test("every kit script runs gh only through ghOutput, and runs only programs it names", () => { + for (const script of ["release-github.mjs", "release-commit.mjs", "check-release-shape.mjs", "publish-release.mjs", "attach-native.mjs", "changelog.mjs", "release-pack.mjs", "native-manifest.mjs", "tested-hosts.mjs", "release-version.mjs", "release-rules.mjs"]) { + const source = readFileSync(join(SCRIPTS, script), "utf8") + // Only spawnSync, imported by name, from node:child_process; no require, no dynamic import, no other way in. + for (const [, names] of source.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']node:child_process["']/g)) assert.deepEqual(names.split(",").map((name) => name.trim()).filter(Boolean), ["spawnSync"], script) + assert.doesNotMatch(source, /import\s+\*\s+as\s+\w+\s+from\s*["'](node:)?child_process|import\s+\w+\s+from\s*["'](node:)?child_process|\brequire\s*\(|import\s*\(|["']child_process["']|\bprocess\.binding|\bexecve\b/, script) + const programs = [...source.matchAll(/\bspawnSync\s*\(\s*([^,)]+)/g)].map((match) => match[1].trim()) + for (const program of programs) assert.ok(['"git"', '"npm"', "process.execPath", ...(script === "release-rules.mjs" ? ['"gh"'] : [])].includes(program), `${script} runs ${program}`) + assert.doesNotMatch(source, /(?", () => { + test("refuses a tag that is not v, and accepts the one that is", () => { + const repo = repository({ "package.json": serialise({ name: "kit-test", version: "0.0.1" }), "CHANGELOG.md": TWO }) + repo.git("tag", "-a", "v0.0.2", "-m", "v0.0.2") + repo.git("tag", "-a", "v0.0.1", "-m", "v0.0.1") + const wrong = shapeOf(repo, "v0.0.2") + assert.match(wrong, /FAIL the version: the tag is v0\.0\.2, and package\.json's version 0\.0\.1 releases only as v0\.0\.1/) + const right = shapeOf(repo, "v0.0.1") + assert.match(right, /ok {3}the tag is v0\.0\.1, and the version is 0\.0\.1/) + assert.doesNotMatch(right, /FAIL the version/) + }) +}) + +describe("check-release-shape.mjs: the tag and the commit, as GitHub has them", () => { + const SOURCE = { name: "kit-test", version: "0.0.1", scripts: { build: "node build.mjs" } } + const tagged = () => { + const repo = repository({ "package.json": serialise(SOURCE), "CHANGELOG.md": TWO }) + const commit = releaseOn(repo, { tag: "v0.0.1", pkgText: releasePackageText(SOURCE) }) + return { repo, commit } + } + + test("accepts the kit's tag on a commit the workflow made, and the whole release shape", () => { + const { repo } = tagged() + const result = repo.node("check-release-shape.mjs", ["--tag", "v0.0.1", "--branch", "master"]) + assert.match(result.stdout, /ok {3}the tag is annotated/) + assert.match(result.stdout, /ok {3}the tag is the kit's: v0\.0\.1, tagged by github-actions\[bot\], unsigned/) + assert.match(result.stdout, /ok {3}GitHub verifies the release commit, made by github-actions\[bot\]/) + assert.doesNotMatch(result.stdout, /FAIL/) + assert.equal(result.status, 0, result.stdout + result.stderr) + }) + + test("refuses a lightweight tag, another tagger, and a commit GitHub does not verify or the workflow did not make", () => { + const light = repository({ "package.json": serialise(SOURCE), "CHANGELOG.md": TWO }) + const commit = releaseOn(light, { tag: null, pkgText: releasePackageText(SOURCE) }) + light.git("tag", "v0.0.1", commit) + assert.match(shapeOf(light, "v0.0.1"), /FAIL the tag is annotated: v0\.0\.1 is a lightweight tag/) + + const { repo, commit: release } = tagged() + const tagSha = repo.git("rev-parse", "refs/tags/v0.0.1") + repo.setRoutes({ [`git/tags/${tagSha}`]: { ...kitTag({ sha: tagSha, tag: "v0.0.1", commit: release }), tagger: { name: "joe-broadhead", email: "91376460+joe-broadhead@users.noreply.github.com" } } }) + assert.match(shapeOf(repo, "v0.0.1"), /FAIL the tag: the tagger is not github-actions\[bot\]/) + + const parent = repo.git("rev-parse", `${release}^`) + const unverified = botCommit({ sha: release, parents: [parent] }) + unverified.commit.verification = { verified: false, reason: "unsigned" } + repo.setRoutes({ [`commits/${release}`]: unverified }) + assert.match(shapeOf(repo, "v0.0.1"), /FAIL the release commit: GitHub does not verify the release commit's signature \(reason: unsigned\)/) + const person = botCommit({ sha: release, parents: [parent] }) + person.commit.author.email = "private.person@example.com" + repo.setRoutes({ [`commits/${release}`]: person }) + const out = shapeOf(repo, "v0.0.1") + assert.match(out, /FAIL the release commit: the release commit's author is another address/) + assert.doesNotMatch(out, /private\.person/) + }) + + test("refuses a tag GitHub has on another commit than the checkout's", () => { + const { repo } = tagged() + const tagSha = repo.git("rev-parse", "refs/tags/v0.0.1") + repo.setRoutes({ [`git/tags/${tagSha}`]: kitTag({ sha: tagSha, tag: "v0.0.1", commit: repo.head }) }) + assert.match(shapeOf(repo, "v0.0.1"), /FAIL the tag: the checkout's v0\.0\.1 points at [0-9a-f]{12}, and GitHub's at/) + }) + + test("--commit checks a release commit before its tag exists: every content check, and the tag name against the version", () => { + const repo = repository({ "package.json": serialise(SOURCE), "CHANGELOG.md": TWO }) + const commit = releaseOn(repo, { tag: null, pkgText: releasePackageText(SOURCE) }) + const result = repo.node("check-release-shape.mjs", ["--commit", commit, "--tag-name", "v0.0.1", "--branch", "master"]) + assert.match(result.stdout, /ok {3}v0\.0\.1 is not tagged yet/) + for (const line of [/the tag is v0\.0\.1, and the version is 0\.0\.1/, /the parent [0-9a-f]{12} is on master/, /v0\.0\.1 is the first published Release/, /package\.json is the parent's without its lifecycle scripts/, /the change from the parent is the allowed set \(3 paths\)/, /every path it changes is a regular file/, /dist\/ is committed \(2 files\)/]) { + assert.match(result.stdout, new RegExp(`ok {3}${line.source}`)) + } + assert.equal(result.status, 0, result.stdout + result.stderr) + assert.match(shapeOfCommit(repo, commit, "v0.0.2"), /FAIL the version: the tag is v0\.0\.2, and package\.json's version 0\.0\.1 releases only as v0\.0\.1/) + }) + + test("refuses --verify git, an identity other than the workflow's, and --commit without a tag name", () => { + const { repo, commit } = tagged() + assert.match(repo.node("check-release-shape.mjs", ["--tag", "v0.0.1", "--verify", "git"]).stderr, /--verify is "github-api"/) + assert.match(repo.node("check-release-shape.mjs", ["--tag", "v0.0.1", "--identity", "owner"]).stderr, /--identity is "github-actions"/) + assert.match(repo.node("check-release-shape.mjs", ["--commit", commit]).stderr, /--commit needs --tag-name/) + }) +}) + +describe("check-release-shape.mjs: package.json is exactly the parent's without its lifecycle scripts", () => { + const SOURCE = { name: "kit-test", version: "0.0.1", main: "dist/index.js", scripts: { build: "node build.mjs", test: "node --test" } } + const RELEASED = JSON.parse(releasePackageText(SOURCE)) + const caseOf = (pkgText, tag = "v0.0.1") => { + const repo = repository({ "package.json": serialise(SOURCE), "CHANGELOG.md": TWO }) + releaseOn(repo, { tag, pkgText }) + return shapeOf(repo, tag) + } + + test("accepts what release-commit.mjs writes", () => { + assert.deepEqual(RELEASED.scripts, { test: "node --test" }) + assert.match(caseOf(releasePackageText(SOURCE)), /ok {3}package\.json is the parent's without its lifecycle scripts/) + }) + + test("refuses an added dependency, a changed main or version, reordered keys, a kept build script and another form", () => { + const { name, ...rest } = RELEASED + const cases = { + "an added dependency": serialise({ ...RELEASED, dependencies: { "left-pad": "1.3.0" } }), + "a changed main": serialise({ ...RELEASED, main: "dist/tui.js" }), + "reordered keys": serialise({ ...rest, name }), + "a kept build script": serialise(SOURCE), + "another JSON form": `${JSON.stringify(RELEASED, null, "\t")}\n`, + } + for (const [label, pkgText] of Object.entries(cases)) { + assert.match(caseOf(pkgText), /FAIL package\.json: it is not exactly the parent's without its lifecycle scripts/, label) + } + const version = caseOf(serialise({ ...RELEASED, version: "0.0.2" }), "v0.0.2") + assert.match(version, /ok {3}the tag is v0\.0\.2/) + assert.match(version, /FAIL package\.json: it is not exactly the parent's/) + }) +}) + +describe("check-release-shape.mjs: regular files only, and no package.json in dist/", () => { + const SOURCE = { name: "kit-test", version: "0.0.1", scripts: { build: "node build.mjs" } } + const caseOf = (tweak) => { + const repo = repository({ "package.json": serialise(SOURCE), "CHANGELOG.md": TWO }) + releaseOn(repo, { tag: "v0.0.1", pkgText: releasePackageText(SOURCE), tweak }) + return shapeOf(repo, "v0.0.1") + } + const symlink = (path, to) => (repo) => { + const blob = spawnSync("git", ["hash-object", "-w", "--stdin"], { cwd: repo.dir, input: to, encoding: "utf8" }).stdout.trim() + repo.git("update-index", "--add", "--cacheinfo", `120000,${blob},${path}`) + } + + test("accepts regular files", () => { + assert.match(caseOf(), /ok {3}every path it changes is a regular file/) + }) + + test("refuses an executable or a symlink in dist/", () => { + assert.match(caseOf((repo) => repo.git("update-index", "--chmod=+x", "dist/index.js")), /FAIL the file modes: dist\/index\.js is an executable, not a regular file/) + assert.match(caseOf(symlink("dist/tui.js", "../../outside.js")), /FAIL the file modes: dist\/tui\.js is a symlink, not a regular file/) + }) + + test("refuses a package.json that is executable or a symlink, before reading it", () => { + assert.match(caseOf((repo) => repo.git("update-index", "--chmod=+x", "package.json")), /FAIL package\.json: package\.json is an executable, not a regular file/) + const linked = caseOf(symlink("package.json", "dist/index.js")) + assert.match(linked, /FAIL package\.json: package\.json is a symlink, not a regular file/) + }) + + test("refuses a package.json added anywhere under dist/", () => { + for (const path of ["dist/package.json", "dist/browser/package.json"]) { + const out = caseOf((repo) => { + mkdirSync(join(repo.dir, path, ".."), { recursive: true }) + writeFileSync(join(repo.dir, path), '{ "type": "commonjs" }\n') + repo.git("add", "-f", path) + }) + assert.match(out, new RegExp(`FAIL the change from the parent: paths outside the allowed set: ${path.replaceAll(".", "\\.")}`), path) + } + }) + + test("allowedPath keeps dist/ files and refuses a nested package.json; modeProblems names each mode", () => { + assert.equal(allowedPath("dist/index.js"), true) + assert.equal(allowedPath("dist/browser/vega.js"), true) + assert.equal(allowedPath("dist/package.json"), false) + assert.equal(allowedPath("dist/a/package.json"), false) + assert.equal(allowedPath("dist/package.json.map"), true) + assert.deepEqual(modeProblems([{ path: "a", mode: "100644" }, { path: "b", mode: "100755" }, { path: "c", mode: "120000" }, { path: "d", mode: "160000" }]), [ + "b is an executable, not a regular file", + "c is a symlink, not a regular file", + "d is a submodule, not a regular file", + ]) + }) +}) + +describe("check-release-shape.mjs: releases move forward from the last published Release", () => { + const PKG = (version) => releasePackageText({ name: "kit-test", version }) + const twoCommits = () => { + const repo = repository({ "package.json": serialise({ name: "kit-test", version: "0.0.0" }), "CHANGELOG.md": ONE }) + return { repo, first: repo.head, second: laterCommit(repo) } + } + + test("accepts the first Release, and one whose parent descends from the last Release's parent", () => { + const { repo, first, second } = twoCommits() + releaseOn(repo, { tag: "v0.0.0", pkgText: PKG("0.0.0"), from: first }) + assert.match(shapeOf(repo, "v0.0.0"), /ok {3}v0\.0\.0 is the first published Release/) + repo.setReleases([published("v0.0.0")]) + releaseOn(repo, { tag: "v0.0.1", pkgText: PKG("0.0.1"), from: second }) + assert.match(shapeOf(repo, "v0.0.1"), /ok {3}the parent descends from the parent of v0\.0\.0, the last published Release/) + }) + + test("refuses a release built on older source than the last Release", () => { + const { repo, first, second } = twoCommits() + releaseOn(repo, { tag: "v0.0.0", pkgText: PKG("0.0.0"), from: second }) + repo.setReleases([published("v0.0.0")]) + releaseOn(repo, { tag: "v0.0.1", pkgText: PKG("0.0.1"), from: first }) + assert.match(shapeOf(repo, "v0.0.1"), /FAIL the release order: the parent does not descend from the parent of v0\.0\.0, the last Release/) + }) + + test("refuses a version that is not newer than a published Release", () => { + const { repo, second } = twoCommits() + releaseOn(repo, { tag: "v0.0.2", pkgText: PKG("0.0.2"), from: second }) + repo.setReleases([published("v0.0.2")]) + releaseOn(repo, { tag: "v0.0.1", pkgText: PKG("0.0.1"), from: second }) + assert.match(shapeOf(repo, "v0.0.1"), /FAIL the release order: the published Release v0\.0\.2 is not older than v0\.0\.1/) + }) + + test("is not blocked by a stray higher tag with no Release, or by a draft", () => { + const { repo, first, second } = twoCommits() + releaseOn(repo, { tag: "v0.0.0", pkgText: PKG("0.0.0"), from: first }) + releaseOn(repo, { tag: "v9.9.9", pkgText: PKG("9.9.9"), from: second }) + releaseOn(repo, { tag: "v0.0.2", pkgText: PKG("0.0.2"), from: second }) + repo.setReleases([published("v0.0.0"), draft("v0.0.2"), { ...published("v0.0.3"), draft: true }]) + releaseOn(repo, { tag: "v0.0.1", pkgText: PKG("0.0.1"), from: second }) + const out = shapeOf(repo, "v0.0.1") + assert.match(out, /ok {3}the parent descends from the parent of v0\.0\.0, the last published Release/) + assert.doesNotMatch(out, /FAIL the release order/) + }) + + test("refuses, and never passes, when GitHub cannot be read", () => { + const { repo, first } = twoCommits() + releaseOn(repo, { tag: "v0.0.1", pkgText: PKG("0.0.1"), from: first }) + const result = repo.node("check-release-shape.mjs", ["--tag", "v0.0.1", "--branch", "master"], { FAKE_GH_FAIL: "1" }) + assert.equal(result.status, 1) + assert.match(result.stderr, /GitHub could not be read \(git: HTTP 403: Resource not accessible by integration\)/) + assert.doesNotMatch(result.stdout, /ok {3}.*(first published Release|descends from)/) + }) + + test("refuses the order when only the Releases cannot be listed", () => { + const { repo, first } = twoCommits() + const commit = releaseOn(repo, { tag: null, pkgText: PKG("0.0.1"), from: first }) + repo.setRoutes({ "releases?per_page=100&page=1": { message: "Not Found" } }) + assert.match(shapeOfCommit(repo, commit, "v0.0.1"), /FAIL the release order: the published Releases could not be listed \(the Releases API did not answer with a list\), so the order is not checked/) + }) + + test("releasedTags keeps published, non-draft v Releases; fetchReleases reads every page", () => { + assert.deepEqual(releasedTags([published("v0.0.0"), draft("v0.0.1"), { ...published("v0.0.2"), published_at: null }, published("v1.0.0-rc.1"), published("nightly")]), ["v0.0.0"]) + const calls = [] + const pages = { 1: Array.from({ length: 100 }, (_, i) => published(`v0.0.${i}`)), 2: [published("v0.1.0")] } + const gh = (args) => { + calls.push(args[1]) + return JSON.stringify(pages[Number(/page=(\d+)$/.exec(args[1])[1])] ?? []) + } + assert.equal(fetchReleases("owner/kit-test", gh).length, 101) + assert.deepEqual(calls, ["repos/owner/kit-test/releases?per_page=100&page=1", "repos/owner/kit-test/releases?per_page=100&page=2"]) + assert.throws(() => fetchReleases("owner/kit-test", () => '{"message":"Not Found"}'), /did not answer with a list/) + }) +}) + +/** A plugin's source, as the release workflow sees it: a build, TESTED_HOSTS, and, when native, native.yml. */ +const pluginSource = (version, { native = false, changelog = TWO } = {}) => ({ + "package.json": serialise({ name: "@owner/kit-test", version, scripts: { build: "node build.mjs", test: "node --test" } }), + "CHANGELOG.md": changelog, + ".gitignore": "dist/\nnode_modules/\n", + "build.mjs": 'import { mkdirSync, writeFileSync } from "node:fs"\nmkdirSync("dist", { recursive: true })\nwriteFileSync("dist/index.js", "export default 1\\n")\nwriteFileSync("dist/tui.js", "export default 2\\n")\n', + "src/host.ts": 'export const TESTED_HOSTS = ["2.2.3", "2.2.6"] as const\n', + ...(native ? { ".github/workflows/native.yml": "name: native\n" } : {}), +}) + +/** A manifest the native run would write for the release of `tag` on `commit`. */ +const nativeManifest = ({ tag, commit, run = 77 }) => ({ + schema: 1, + plugin: "kit-test", + repository: "owner/kit-test", + tag, + built: { commit, run, workflow: "native" }, + modules: { + tool: { + "linux-x64-gnu": { name: "tool-linux-x64-gnu.gz", bytes: 10, sha256: "a".repeat(64), executable: { name: "tool", bytes: 20, sha256: "b".repeat(64) } }, + }, + }, +}) + +/** + * GitHub, in memory, for the release-github.mjs functions: refs, tag objects, commits, CI and native runs, Releases. Its + * createCommitOnBranch writes a real commit into `repo` from the request's files, so trees are git's own. Records every + * call; `onCommit(apiCommit)` may change what GitHub answers about a new commit. + */ +function fakeGitHub(repo, { onCommit, mergeBase } = {}) { + const state = { refs: new Map(), tagObjects: new Map(), commits: new Map(), runs: new Map(), manifests: new Map(), ciRuns: [], releases: [] } + const calls = [] + let made = 0 + const notFound = () => { + throw new Error("gh: Not Found (HTTP 404)") + } + const R = "repos/owner/kit-test/" + const gh = (args, { input } = {}) => { + const forbidden = forbiddenCall(args, input, { repository: "owner/kit-test" }) + if (forbidden) throw new Error(`refused: ${forbidden}`) + calls.push({ args, input: input === undefined ? undefined : JSON.parse(input) }) + if (args[0] === "run" && args[1] === "download") { + const manifest = state.manifests.get(args[2]) + if (!manifest) throw new Error("no artifact matches any of the names provided") + writeFileSync(join(args[args.indexOf("--dir") + 1], "native.json"), JSON.stringify(manifest)) + return "" + } + if (args[1] === "graphql") return JSON.stringify({ data: { createCommitOnBranch: { commit: { oid: createCommit(JSON.parse(input).variables.input) } } } }) + const method = args[1] === "-X" ? args[2] : "GET" + const path = (method === "GET" ? args[1] : args[3]).slice(R.length) + const field = (name) => args.find((arg) => arg.startsWith(`${name}=`))?.slice(name.length + 1) + let match + const hit = (pattern) => { + match = pattern.exec(path) + return match + } + if (method === "GET") { + if (path.startsWith("actions/workflows/ci.yml/runs?")) return JSON.stringify({ workflow_runs: state.ciRuns }) + if (hit(/^actions\/runs\/(\d+)$/)) return JSON.stringify(state.runs.get(match[1]) ?? notFound()) + if (hit(/^git\/ref\/(.+)$/)) { + const ref = state.refs.get(match[1]) ?? notFound() + return JSON.stringify({ ref: `refs/${match[1]}`, object: ref }) + } + if (hit(/^git\/tags\/(\w+)$/)) return JSON.stringify(state.tagObjects.get(match[1]) ?? notFound()) + if (hit(/^commits\/(\w+)$/)) return JSON.stringify(state.commits.get(match[1]) ?? notFound()) + if (path === "releases?per_page=100&page=1") return JSON.stringify(state.releases) + if (path.startsWith("releases?")) return "[]" + if (hit(/^compare\/(\w+)\.\.\.master$/)) return JSON.stringify({ merge_base_commit: { sha: mergeBase ?? match[1] } }) + } + if (method === "POST" && path === "git/refs") { + const body = input ? JSON.parse(input) : { ref: field("ref"), sha: field("sha") } + const name = body.ref.slice("refs/".length) + if (state.refs.has(name)) throw new Error("gh: Reference already exists (HTTP 422)") + state.refs.set(name, { type: state.tagObjects.has(body.sha) ? "tag" : "commit", sha: body.sha }) + return JSON.stringify({ ref: body.ref, object: state.refs.get(name) }) + } + if (method === "POST" && path === "git/tags") { + const body = JSON.parse(input) + const sha = `${String(++made).padStart(4, "0")}${"e".repeat(36)}` + state.tagObjects.set(sha, { sha, tag: body.tag, message: body.message, tagger: body.tagger, object: { type: body.type, sha: body.object }, verification: { verified: false, reason: "unsigned" } }) + return JSON.stringify(state.tagObjects.get(sha)) + } + if (method === "DELETE" && hit(/^git\/refs\/(.+)$/)) { + if (!state.refs.delete(match[1])) notFound() + return "" + } + throw new Error(`fake GitHub: unexpected call ${args.join(" ")}`) + } + function createCommit(input) { + const name = `heads/${input.branch.branchName}` + const head = state.refs.get(name) + if (!head) throw new Error("gh: GraphQL: A ref named ... does not exist") + if (head.sha !== input.expectedHeadOid) throw new Error("gh: GraphQL: Expected branch to point to ...") + const index = join(folder(), "index") + const env = { ...repo.env, GIT_INDEX_FILE: index, GIT_AUTHOR_NAME: ACTIONS_BOT.name, GIT_AUTHOR_EMAIL: ACTIONS_BOT.email, GIT_COMMITTER_NAME: WEB_FLOW.name, GIT_COMMITTER_EMAIL: WEB_FLOW.email } + const git = (args, stdin) => { + const result = spawnSync("git", args, { cwd: repo.dir, env, input: stdin, encoding: stdin === undefined ? "utf8" : undefined }) + assert.equal(result.status, 0, `git ${args.join(" ")}: ${result.stderr}`) + return String(result.stdout).trim() + } + git(["read-tree", head.sha]) + for (const file of input.fileChanges.additions) { + const blob = git(["hash-object", "-w", "--stdin"], Buffer.from(file.contents, "base64")) + git(["update-index", "--add", "--cacheinfo", `100644,${blob},${file.path}`]) + } + const tree = git(["write-tree"]) + const sha = git(["commit-tree", tree, "-p", head.sha, "-m", input.message.headline]) + const apiCommit = botCommit({ sha, tree, parents: [head.sha], message: input.message.headline }) + onCommit?.(apiCommit) + state.commits.set(sha, apiCommit) + head.sha = sha + return sha + } + return { gh, state, calls, writes: () => calls.filter(({ args }) => args[1] === "-X" || args[1] === "graphql") } +} + +const ENV = (repo, extra = {}) => ({ GITHUB_REPOSITORY: "owner/kit-test", GITHUB_REF: "refs/heads/master", GITHUB_SHA: repo.head, ...extra }) +const ciRun = (sha, extra = {}) => ({ id: 501, head_sha: sha, event: "push", head_branch: "master", path: ".github/workflows/ci.yml", status: "completed", conclusion: "success", created_at: "2026-11-11T10:00:00Z", ...extra }) +const nativeRun = (sha, extra = {}) => ({ id: 77, head_sha: sha, conclusion: "success", path: ".github/workflows/native.yml", head_branch: "master", event: "workflow_dispatch", ...extra }) + +describe("release-github.mjs plan", () => { + const setup = ({ version = "0.0.1", native = false, changelog } = {}) => { + const repo = repository(pluginSource(version, { native, changelog })) + const github = fakeGitHub(repo) + github.state.ciRuns = [ciRun(repo.head)] + const run = (options = {}) => plan({ sha: repo.head, env: ENV(repo), gh: github.gh, cwd: repo.dir, ...options }) + return { repo, github, run } + } + + test("plans a new release: the version, the tag, the hosts, the CI run", () => { + const { run } = setup() + assert.deepEqual(run(), { version: "0.0.1", tag: "v0.0.1", hosts: '["2.2.3","2.2.6"]', native: "false", mode: "new", commit: "", ci_run: "501", previous: "", manifest_sha256: "" }) + }) + + test("refuses a run that is not on master, or a sha that is not the run's", () => { + const { repo, run } = setup() + assert.throws(() => run({ env: ENV(repo, { GITHUB_REF: "refs/heads/side" }) }), /a release runs from master only, and this run is on refs\/heads\/side/) + assert.throws(() => run({ env: ENV(repo, { GITHUB_SHA: "f".repeat(40) }) }), /the run is on master at ffffffffffff, not [0-9a-f]{12}: dispatch again with master's head/) + assert.throws(() => run({ sha: "abc" }), /--sha must be the full 40-character sha/) + }) + + test("refuses without a green CI push run on exactly the sha", () => { + const { repo, github, run } = setup() + github.state.ciRuns = [] + assert.throws(run, /no ci\.yml run for a push to master on [0-9a-f]{12}: push it to master, and wait for CI/) + github.state.ciRuns = [ciRun(repo.head, { status: "in_progress", conclusion: null })] + assert.throws(run, /CI is still running on [0-9a-f]{12} \(run 501\): wait for it/) + github.state.ciRuns = [ciRun(repo.head, { conclusion: "failure" })] + assert.throws(run, /the newest CI run on [0-9a-f]{12} \(run 501\) concluded failure/) + github.state.ciRuns = [ciRun(repo.head, { id: 500 }), ciRun(repo.head, { id: 502, created_at: "2026-11-11T11:00:00Z", conclusion: "cancelled" })] + assert.throws(run, /\(run 502\) concluded cancelled/) + github.state.ciRuns = [ciRun(repo.head, { event: "pull_request" }), ciRun("f".repeat(40))] + assert.throws(run, /no ci\.yml run for a push to master/) + }) + + test("refuses a version or a CHANGELOG that is not ready", () => { + assert.throws(setup({ version: "0.0.1-rc.1" }).run, /package\.json version "0\.0\.1-rc\.1" is not x\.y\.z/) + assert.throws(setup({ changelog: doc(UNRELEASED, V000, EARLIER) }).run, /CHANGELOG\.md is not ready for v0\.0\.1: an ## \[Unreleased\] section is still there/) + }) + + test("refuses a leftover release branch, a tag that is not a resume, and a release that is already published", () => { + const { repo, github, run } = setup() + github.state.refs.set("heads/release/v0.0.1", { type: "commit", sha: repo.head }) + assert.throws(run, /the branch release\/v0\.0\.1 is left from an earlier run: check it is not a run waiting for approval, then delete it/) + github.state.refs.clear() + github.state.refs.set("tags/v0.0.1", { type: "commit", sha: repo.head }) + assert.throws(run, /the tag v0\.0\.1 exists and is not one this release can resume \(v0\.0\.1 is a lightweight tag/) + github.state.refs.clear() + github.state.releases = [published("v0.0.1")] + assert.throws(run, /v0\.0\.1 is already a published Release: release the next version/) + }) + + test("refuses a release that does not move forward", () => { + const { github, run } = setup() + github.state.releases = [published("v0.0.2")] + assert.throws(run, /the published Release v0\.0\.2 is not older than v0\.0\.1/) + }) + + test("a native plugin needs native_run, and a plugin that is not native refuses one", () => { + assert.throws(() => setup({ native: true }).run(), /this plugin is native: run the native workflow on [0-9a-f]{12}, then dispatch with native_run/) + assert.throws(() => setup().run({ nativeRun: "77" }), /native_run is set, and this plugin has no \.github\/workflows\/native\.yml: dispatch without it/) + assert.throws(() => setup({ native: true }).run({ nativeRun: "seven" }), /native_run must be a run id/) + }) + + test("refuses a native run on another sha, failed, of another workflow, branch or event, and a bad manifest", () => { + const { repo, github, run } = setup({ native: true }) + github.state.manifests.set("77", nativeManifest({ tag: "v0.0.1", commit: repo.head })) + const cases = { + "another sha": [{ head_sha: "f".repeat(40) }, /the run built ffffffffffff, not/], + failed: [{ conclusion: "failure" }, /the run concluded failure/], + "another workflow": [{ path: ".github/workflows/ci.yml" }, /the run is not the native workflow/], + "another branch": [{ head_branch: "side" }, /the run is not on master/], + "another event": [{ event: "push" }, /the run was started by push, not workflow_dispatch/], + } + for (const [label, [change, expected]] of Object.entries(cases)) { + github.state.runs.set("77", nativeRun(repo.head, change)) + assert.throws(() => run({ nativeRun: "77" }), expected, label) + } + github.state.runs.set("77", nativeRun(repo.head)) + github.state.manifests.set("77", nativeManifest({ tag: "v0.0.0", commit: repo.head })) + assert.throws(() => run({ nativeRun: "77" }), /the native manifest of run 77 is not right: tag is v0\.0\.0; the release tag is v0\.0\.1/) + github.state.manifests.delete("77") + assert.throws(() => run({ nativeRun: "77" }), /no artifact matches/) + }) + + test("a native plugin with a right run plans, and keeps the vetted manifest for the build", () => { + const { repo, github, run } = setup({ native: true }) + github.state.runs.set("77", nativeRun(repo.head)) + github.state.manifests.set("77", nativeManifest({ tag: "v0.0.1", commit: repo.head })) + const out = join(folder(), "native", "native.json") + const leftovers = () => readdirSync(tmpdir()).filter((name) => name.startsWith("native-manifest-")).length + const before = leftovers() + const planned = run({ nativeRun: "77", manifestOut: out }) + assert.equal(planned.native, "true") + assert.deepEqual(JSON.parse(readFileSync(out, "utf8")), nativeManifest({ tag: "v0.0.1", commit: repo.head })) + assert.equal(planned.manifest_sha256, createHash("sha256").update(readFileSync(out)).digest("hex")) + assert.equal(leftovers(), before, "the download folder is removed") + }) +}) + +describe("release-github.mjs plan: resuming a release whose tag exists", () => { + const setup = () => { + const repo = repository(pluginSource("0.0.1")) + const github = fakeGitHub(repo) + github.state.ciRuns = [ciRun(repo.head)] + const release = "c".repeat(40) + github.state.commits.set(release, botCommit({ sha: release, parents: [repo.head] })) + github.state.tagObjects.set("a".repeat(40), kitTag({ sha: "a".repeat(40), tag: "v0.0.1", commit: release })) + github.state.refs.set("tags/v0.0.1", { type: "tag", sha: "a".repeat(40) }) + const run = () => plan({ sha: repo.head, env: ENV(repo), gh: github.gh, cwd: repo.dir }) + return { repo, github, release, run } + } + + test("resumes the kit's tag on a release commit on this sha with no published Release, even with a draft", () => { + const { github, release, run } = setup() + assert.equal(run().mode, "resume") + assert.equal(run().commit, release) + github.state.releases = [draft("v0.0.1")] + assert.equal(run().mode, "resume") + }) + + test("refuses it when the Release is published, the tagger is another, or the commit has another parent", () => { + let { github, run } = setup() + github.state.releases = [published("v0.0.1")] + assert.throws(run, /v0\.0\.1 is already a published Release/) + ;({ github, run } = setup()) + github.state.tagObjects.get("a".repeat(40)).tagger = { name: "joe-broadhead", email: "91376460+joe-broadhead@users.noreply.github.com" } + assert.throws(run, /the tag v0\.0\.1 exists and is not one this release can resume \(the tagger is not github-actions\[bot\]\): release the next version/) + const other = setup() + other.github.state.commits.set(other.release, botCommit({ sha: other.release, parents: ["f".repeat(40)] })) + assert.throws(other.run, /its release commit's parent is ffffffffffff, not/) + const unverified = setup() + unverified.github.state.commits.get(unverified.release).commit.verification = { verified: false, reason: "unsigned" } + assert.throws(unverified.run, /does not verify the release commit's signature/) + }) +}) + +describe("release-commit.mjs: the build step", () => { + const build = (repo, extra = [], env) => repo.node("release-commit.mjs", ["--sha", repo.git("rev-parse", "HEAD"), "--out", join(repo.dir, "..", `files-${made++}`), ...extra], env) + + test("builds dist/ and writes the release files with paths.txt, and commits nothing", () => { + const repo = repository(pluginSource("0.0.1")) + const out = join(folder(), "files") + const result = repo.node("release-commit.mjs", ["--sha", repo.head, "--out", out]) + assert.equal(result.status, 0, result.stderr) + assert.equal(readFileSync(join(out, "package.json"), "utf8"), releasePackageText(JSON.parse(pluginSource("0.0.1")["package.json"]))) + assert.equal(readFileSync(join(out, "dist", "index.js"), "utf8"), "export default 1\n") + const lines = readFileSync(join(out, "paths.txt"), "utf8").trim().split("\n") + assert.deepEqual(lines.map((line) => line.split(" ")[1]), ["dist/index.js", "dist/tui.js", "package.json"]) + assert.equal(repo.git("rev-parse", "HEAD"), repo.head) + assert.equal(repo.git("tag", "--list"), "") + }) + + test("refuses another head, a dirty tree, a version that is not x.y.z, and a manifest that does not fit the plugin", () => { + const repo = repository(pluginSource("0.0.1")) + assert.match(repo.node("release-commit.mjs", ["--sha", "f".repeat(40), "--out", join(folder(), "x")]).stderr, /the checkout is not ffffffffffff/) + writeFileSync(join(repo.dir, "stray.txt"), "x\n") + assert.match(build(repo).stderr, /the tree is not clean/) + assert.match(build(repository(pluginSource("01.0.0"))).stderr, /is not x\.y\.z/) + assert.match(build(repository(pluginSource("0.0.1", { native: true }))).stderr, /this plugin is native \(\.github\/workflows\/native\.yml\), and no vetted manifest was given/) + const manifest = join(folder(), "native.json") + writeFileSync(manifest, "{}") + assert.match(build(repository(pluginSource("0.0.1")), ["--manifest", manifest]).stderr, /a manifest was given, and this plugin is not native/) + }) + + test("adds the vetted manifest for a native plugin, and refuses one for another commit", () => { + const repo = repository(pluginSource("0.0.1", { native: true })) + const manifest = join(folder(), "native.json") + writeFileSync(manifest, `${JSON.stringify(nativeManifest({ tag: "v0.0.1", commit: "f".repeat(40) }), null, 2)}\n`) + assert.match(build(repo, ["--manifest", manifest]).stderr, /built\.commit is ffffffffffff; the tag's parent is/) + writeFileSync(manifest, `${JSON.stringify(nativeManifest({ tag: "v0.0.1", commit: repo.head }), null, 2)}\n`) + const out = join(folder(), "files") + assert.equal(repo.node("release-commit.mjs", ["--sha", repo.head, "--out", out, "--manifest", manifest]).status, 0) + assert.match(readFileSync(join(out, "paths.txt"), "utf8"), / {2}manifest\/native\.json\n/) + }) + + test("refuses a build that changes tracked files or writes a package.json under dist/", () => { + const touch = repository({ ...pluginSource("0.0.1"), "build.mjs": 'import { writeFileSync } from "node:fs"\nwriteFileSync("CHANGELOG.md", "changed\\n")\n' }) + assert.match(build(touch).stderr, /the build changed tracked files/) + const nested = repository({ ...pluginSource("0.0.1"), "build.mjs": `${pluginSource("0.0.1")["build.mjs"]}writeFileSync("dist/package.json", "{}\\n")\n` }) + assert.match(build(nested).stderr, /built paths outside the allowed set: dist\/package\.json/) + }) +}) + +describe("release-github.mjs commit", () => { + const setup = (options = {}) => { + const repo = repository(pluginSource("0.0.1")) + const files = join(folder(), "files") + const result = repo.node("release-commit.mjs", ["--sha", repo.head, "--out", files]) + assert.equal(result.status, 0, result.stderr) + const github = fakeGitHub(repo, options) + const outputs = {} + const run = (extra = {}) => commit({ dir: files, parent: repo.head, tag: "v0.0.1", native: false, env: ENV(repo), gh: github.gh, cwd: repo.dir, sleep: () => {}, output: (key, value) => (outputs[key] = value), ...extra }) + return { repo, files, github, outputs, run } + } + + test("creates release/v at the parent, then one createCommitOnBranch with exactly the files", () => { + const { repo, files, github, outputs, run } = setup() + const result = run() + const [branch, mutation] = github.writes() + assert.deepEqual(branch.args, ["api", "-X", "POST", "repos/owner/kit-test/git/refs", "-f", "ref=refs/heads/release/v0.0.1", "-f", `sha=${repo.head}`]) + const { input } = mutation.input.variables + assert.deepEqual(input.branch, { repositoryNameWithOwner: "owner/kit-test", branchName: "release/v0.0.1" }) + assert.deepEqual(input.message, { headline: "chore(release): v0.0.1 with the built dist/ and no lifecycle scripts" }) + assert.equal(input.expectedHeadOid, repo.head) + assert.deepEqual(input.fileChanges.additions.map((file) => file.path), ["dist/index.js", "dist/tui.js", "package.json"]) + for (const file of input.fileChanges.additions) assert.equal(Buffer.from(file.contents, "base64").toString("utf8"), readFileSync(join(files, file.path), "utf8"), file.path) + assert.equal(input.fileChanges.deletions, undefined) + assert.match(mutation.input.query, /createCommitOnBranch/) + assert.deepEqual(outputs, { commit: result.commit, tree: result.tree }) + assert.equal(github.state.refs.get("heads/release/v0.0.1").sha, result.commit) + }) + + test("the tree it expects is the tree of a real commit of the same files", () => { + const { repo, files, run } = setup() + const { tree } = run() + repo.git("checkout", "-q", "--detach", repo.head) + for (const path of ["dist/index.js", "dist/tui.js", "package.json"]) writeTree(repo.dir, { [path]: readFileSync(join(files, path)) }) + repo.git("add", "-f", "dist", "package.json") + assert.equal(repo.git("write-tree"), tree) + }) + + test("refuses files outside the allowed set, not regular, a package.json under dist/, unlisted, changed, too big", () => { + const cases = { + "outside the allowed set": [(dir) => writeTree(dir, { "src/x.js": "x\n" }), /paths outside the allowed set: src\/x\.js/], + "a package.json under dist/": [(dir) => writeTree(dir, { "dist/a/package.json": "{}\n" }), /paths outside the allowed set: dist\/a\/package\.json/], + "a symlink": [(dir) => symlinkSync("index.js", join(dir, "dist", "link.js")), /dist\/link\.js is not a regular file/], + "a changed file": [(dir) => writeFileSync(join(dir, "dist", "index.js"), "changed\n"), /dist\/index\.js does not match its sha256 in paths\.txt/, false], + "a missing file": [(dir) => rmSync(join(dir, "dist", "tui.js")), /missing dist\/tui\.js/, false], + "another package.json": [(dir) => writeFileSync(join(dir, "package.json"), serialise({ name: "@owner/kit-test", version: "0.0.1", scripts: { build: "x" } })), /package\.json is not exactly the parent's without its lifecycle scripts/], + } + for (const [label, [change, expected, relist = true]] of Object.entries(cases)) { + const { files, github, run } = setup() + change(files) + if (relist) relistPaths(files) + assert.throws(() => run(), expected, label) + assert.deepEqual(github.writes(), [], label) + } + const big = setup() + assert.throws(() => big.run({ limits: { file: 10, payload: MAX_PAYLOAD } }), /dist\/index\.js is 0\.0 MB, over the 0\.0 MB a file can be/) + assert.throws(() => big.run({ limits: { file: MAX_FILE, payload: 40 } }), /the release commit needs a 0\.0 MB request, over the 0\.0 MB limit/) + assert.deepEqual(big.github.writes(), []) + assert.equal(MAX_FILE, 5 * 1024 * 1024) + assert.equal(MAX_PAYLOAD, 40 * 1024 * 1024) + }) + + test("refuses when release/v already exists, and writes nothing", () => { + const { repo, github, run } = setup() + github.state.refs.set("heads/release/v0.0.1", { type: "commit", sha: repo.head }) + assert.throws(() => run(), /the branch release\/v0\.0\.1 already exists/) + assert.deepEqual(github.writes(), []) + }) + + test("on a tree mismatch, an unverified commit or another parent, deletes the branch and fails", () => { + const cases = { + "another tree": [(c) => (c.commit.tree.sha = "f".repeat(40)), /its tree is ffffffffffff, and the files give/], + unverified: [(c) => (c.commit.verification = { verified: false, reason: "unsigned" }), /does not verify the release commit's signature \(reason: unsigned\)/], + "another parent": [(c) => (c.parents = [{ sha: "f".repeat(40) }]), /its parent is not/], + "a person as author": [(c) => (c.commit.author.email = "private.person@example.com"), /author is another address/], + } + for (const [label, [change, expected]] of Object.entries(cases)) { + const { github, run } = setup({ onCommit: change }) + assert.throws(() => run(), (error) => expected.test(error.message) && /deleted release\/v0\.0\.1/.test(error.message), label) + assert.equal(github.state.refs.has("heads/release/v0.0.1"), false, label) + assert.doesNotMatch(String(github.calls.map((call) => call.args.join(" "))), /private\.person/) + } + }) + + test("a native release's manifest must be, byte for byte, the one the plan vetted", () => { + const repo = repository(pluginSource("0.0.1", { native: true })) + const manifestFile = join(folder(), "native.json") + const text = `${JSON.stringify(nativeManifest({ tag: "v0.0.1", commit: repo.head }), null, 2)}\n` + writeFileSync(manifestFile, text) + const files = join(folder(), "files") + assert.equal(repo.node("release-commit.mjs", ["--sha", repo.head, "--out", files, "--manifest", manifestFile]).status, 0) + const vetted = createHash("sha256").update(text).digest("hex") + const read = (manifestSha256) => releaseFiles({ dir: files, parent: repo.head, native: true, cwd: repo.dir, manifestSha256 }) + assert.deepEqual(read(vetted).map((file) => file.path), ["dist/index.js", "dist/tui.js", "manifest/native.json", "package.json"]) + assert.throws(() => read(undefined), /needs the sha256 of the manifest the plan job vetted \(--manifest-sha256\)/) + assert.throws(() => read("f".repeat(64)), /manifest\/native\.json is not, byte for byte, the manifest the plan job vetted/) + writeFileSync(join(files, "manifest", "native.json"), text.replace('"run": 77', '"run": 78')) + relistPaths(files) + assert.throws(() => read(vetted), /is not, byte for byte, the manifest the plan job vetted/) + }) + + test("leaves no temporary index behind", () => { + const leftovers = () => readdirSync(tmpdir()).filter((name) => name.startsWith("release-index-")).length + const before = leftovers() + setup().run() + assert.equal(leftovers(), before) + }) + + test("waits while GitHub has not verified the signature yet", () => { + let reads = 0 + const { github, run } = setup({ onCommit: (c) => (c.commit.verification = { verified: false, reason: "gpgverify_unavailable" }) }) + const read = github.gh + const gh = (args, options) => { + const out = read(args, options) + if (/\/commits\//.test(args[1] ?? "") && ++reads === 2) { + const body = JSON.parse(out) + body.commit.verification = { verified: true, reason: "valid" } + return JSON.stringify(body) + } + return out + } + let slept = 0 + run({ gh, sleep: () => slept++ }) + assert.equal(slept, 1) + }) +}) + +/** Rewrites paths.txt for the files now in a folder, as a build that produced them would. */ +function relistPaths(dir) { + const files = [] + const walk = (at) => { + for (const name of readdirSync(at)) { + const full = join(at, name) + const stat = lstatSync(full) + if (stat.isDirectory()) walk(full) + else if (name !== "paths.txt" || at !== dir) files.push({ path: full.slice(dir.length + 1), bytes: stat.isFile() ? readFileSync(full) : Buffer.alloc(0) }) + } + } + walk(dir) + writeFileSync(join(dir, "paths.txt"), pathsText(files)) +} + +describe("release-github.mjs tag", () => { + const setup = (options = {}) => { + const repo = repository(pluginSource("0.0.1")) + const github = fakeGitHub(repo, options) + const release = "c".repeat(40) + github.state.commits.set(release, botCommit({ sha: release, parents: [repo.head] })) + github.state.refs.set("heads/release/v0.0.1", { type: "commit", sha: release }) + const run = (extra = {}) => tagRelease({ tag: "v0.0.1", commit: release, parent: repo.head, mode: "new", env: ENV(repo), gh: github.gh, cwd: repo.dir, now: () => "2026-11-12T09:00:00Z", ...extra }) + return { repo, github, release, run } + } + + test("creates the annotated tag object, then the ref, with exact bodies, and reads it back", () => { + const { github, release, run } = setup() + const result = run() + assert.equal(result.action, "created") + const [object, ref] = github.writes() + assert.deepEqual(object.args, ["api", "-X", "POST", "repos/owner/kit-test/git/tags", "--input", "-"]) + assert.deepEqual(object.input, { tag: "v0.0.1", message: "v0.0.1", object: release, type: "commit", tagger: { name: "github-actions[bot]", email: "41898282+github-actions[bot]@users.noreply.github.com", date: "2026-11-12T09:00:00Z" } }) + assert.deepEqual(ref.args, ["api", "-X", "POST", "repos/owner/kit-test/git/refs", "--input", "-"]) + assert.deepEqual(ref.input, { ref: "refs/tags/v0.0.1", sha: result.tagObject.sha }) + assert.equal(github.state.refs.get("tags/v0.0.1").type, "tag") + }) + + test("reuses the kit's tag on the commit with no write, and stops with no write at any other tag", () => { + const { github, release, run } = setup() + github.state.tagObjects.set("a".repeat(40), kitTag({ sha: "a".repeat(40), tag: "v0.0.1", commit: release })) + github.state.refs.set("tags/v0.0.1", { type: "tag", sha: "a".repeat(40) }) + assert.equal(run().action, "reused") + assert.equal(run({ mode: "resume" }).action, "reused") + github.state.tagObjects.set("a".repeat(40), kitTag({ sha: "a".repeat(40), tag: "v0.0.1", commit: "d".repeat(40) })) + assert.throws(() => run(), /the tag v0\.0\.1 already exists and is not the kit's tag on cccccccccccc \(the tag points at dddddddddddd, not the release commit cccccccccccc\): nothing was written/) + github.state.refs.set("tags/v0.0.1", { type: "commit", sha: release }) + assert.throws(() => run(), /lightweight tag/) + assert.deepEqual(github.writes(), []) + }) + + test("refuses, before any write, a moved branch, a parent no longer on master, a published or newer Release, a commit the workflow did not make", () => { + const moved = setup() + moved.github.state.refs.set("heads/release/v0.0.1", { type: "commit", sha: "d".repeat(40) }) + assert.throws(() => moved.run(), /release\/v0\.0\.1 no longer points at the release commit cccccccccccc/) + assert.throws(() => setup({ mergeBase: "f".repeat(40) }).run(), /is no longer on master/) + const done = setup() + done.github.state.releases = [published("v0.0.1")] + assert.throws(() => done.run(), /v0\.0\.1 is already a published Release/) + const newer = setup() + newer.github.state.releases = [published("v0.0.2")] + assert.throws(() => newer.run(), /the published Release v0\.0\.2 is not older than v0\.0\.1/) + const person = setup() + person.github.state.commits.get(person.release).commit.author.email = "private.person@example.com" + assert.throws(() => person.run(), /the release commit is not right: the release commit's author is another address/) + const gone = setup() + assert.throws(() => gone.run({ mode: "resume" }), /resume: the tag v0\.0\.1 is gone/) + for (const { github } of [moved, done, newer, person, gone]) assert.deepEqual(github.writes(), []) + }) +}) + +describe("release-github.mjs cleanup", () => { + const setup = (at) => { + const repo = repository(pluginSource("0.0.1")) + const github = fakeGitHub(repo) + if (at) github.state.refs.set("heads/release/v0.0.1", { type: "commit", sha: at === "parent" ? repo.head : at }) + github.state.refs.set("heads/master", { type: "commit", sha: repo.head }) + const run = (extra = {}) => cleanup({ tag: "v0.0.1", parent: repo.head, env: ENV(repo), gh: github.gh, ...extra }) + return { repo, github, run } + } + + test("deletes release/v when it points at the parent or the run's commit", () => { + const atParent = setup("parent") + assert.equal(atParent.run({ commit: "c".repeat(40) }).action, "deleted") + assert.equal(atParent.github.state.refs.has("heads/release/v0.0.1"), false) + const atCommit = setup("c".repeat(40)) + assert.equal(atCommit.run({ commit: "c".repeat(40) }).action, "deleted") + assert.deepEqual(atCommit.github.writes().map((call) => call.args), [["api", "-X", "DELETE", "repos/owner/kit-test/git/refs/heads/release/v0.0.1"]]) + }) + + test("with the run's commit unknown, deletes a release commit the kit made on the parent, and nothing else", () => { + const kit = setup("c".repeat(40)) + kit.github.state.commits.set("c".repeat(40), botCommit({ sha: "c".repeat(40), parents: [kit.repo.head], message: "chore(release): v0.0.1 with the built dist/ and no lifecycle scripts" })) + assert.equal(kit.run().action, "deleted") + const other = setup("d".repeat(40)) + other.github.state.commits.set("d".repeat(40), botCommit({ sha: "d".repeat(40), parents: [other.repo.head], message: "a person's work" })) + assert.equal(other.run().action, "left") + assert.deepEqual(other.github.writes(), []) + }) + + test("never a branch at another sha, never master, and a missing branch is fine", () => { + const elsewhere = setup("d".repeat(40)) + const result = elsewhere.run({ commit: "c".repeat(40) }) + assert.equal(result.action, "left") + assert.match(result.message, /release\/v0\.0\.1 points at dddddddddddd, which is neither the parent nor this run's commit: left alone/) + assert.deepEqual(elsewhere.github.writes(), []) + const missing = setup() + assert.equal(missing.run().action, "absent") + assert.throws(() => missing.run({ tag: "master" }), /master is not a release tag/) + assert.throws(() => missing.run({ tag: "v0.0.1/../../master" }), /is not a release tag/) + assert.equal(missing.github.state.refs.has("heads/master"), true) + assert.deepEqual(missing.github.writes(), []) + }) +}) + +describe("release-github.mjs sums", () => { + const hash = (text) => createHash("sha256").update(text).digest("hex") + const line = (name, text) => `${hash(text)} ${name}\n` + const staged = () => { + const dir = folder() + writeTree(dir, { "kit-test-0.0.1.tgz": "tarball\n", "native/tool.gz": "gzip\n" }) + const sums = line("kit-test-0.0.1.tgz", "tarball\n") + line("tool.gz", "gzip\n") + writeFileSync(join(dir, "SHA256SUMS"), sums) + return { dir, digest: hash(sums) } + } + + test("accepts staged files when SHA256SUMS has the approved digest and every file matches it", () => { + const { dir, digest } = staged() + assert.deepEqual(sumsProblems(dir, { sumsSha256: digest }), []) + }) + + test("refuses without the approved digest, and a changed, extra or missing file", () => { + const { dir, digest } = staged() + assert.deepEqual(sumsProblems(dir), ["the sha256 of SHA256SUMS that the approver saw was not given"]) + assert.deepEqual(sumsProblems(dir, { sumsSha256: "" }), ["the sha256 of SHA256SUMS that the approver saw was not given"]) + writeFileSync(join(dir, "native", "tool.gz"), "other\n") + assert.deepEqual(sumsProblems(dir, { sumsSha256: digest }), ["tool.gz does not match SHA256SUMS"]) + writeFileSync(join(dir, "native", "tool.gz"), "gzip\n") + writeFileSync(join(dir, "extra.txt"), "x\n") + assert.deepEqual(sumsProblems(dir, { sumsSha256: digest }), ["extra.txt is not in SHA256SUMS"]) + rmSync(join(dir, "extra.txt")) + rmSync(join(dir, "kit-test-0.0.1.tgz")) + assert.deepEqual(sumsProblems(dir, { sumsSha256: digest }), ["kit-test-0.0.1.tgz is in SHA256SUMS and not staged"]) + }) + + test("a swapped tarball fails, and so does a swapped SHA256SUMS that agrees with it", () => { + const { dir, digest } = staged() + writeFileSync(join(dir, "kit-test-0.0.1.tgz"), "a tarball built by someone else\n") + assert.deepEqual(sumsProblems(dir, { sumsSha256: digest }), ["kit-test-0.0.1.tgz does not match SHA256SUMS"]) + const swapped = line("kit-test-0.0.1.tgz", "a tarball built by someone else\n") + line("tool.gz", "gzip\n") + writeFileSync(join(dir, "SHA256SUMS"), swapped) + const problems = sumsProblems(dir, { sumsSha256: digest }) + assert.equal(problems.length, 1) + assert.match(problems[0], new RegExp(`^SHA256SUMS has the sha256 ${hash(swapped)}, not ${digest}, the one the approver saw: nothing is published$`)) + writeTree(dir, { "other/SHA256SUMS": readFileSync(join(dir, "SHA256SUMS")) }) + assert.deepEqual(sumsProblems(dir, { sumsSha256: hash(swapped) }), ["there are 2 SHA256SUMS files; one is needed"]) + }) + + test("the CLI refuses swapped files with the approved digest, and passes the staged ones", () => { + const { dir, digest } = staged() + const cli = (sumsDigest) => spawnSync(process.execPath, [join(SCRIPTS, "release-github.mjs"), "sums", "--dir", dir, "--sums-sha256", sumsDigest], { encoding: "utf8" }) + assert.equal(cli(digest).status, 0) + writeFileSync(join(dir, "SHA256SUMS"), line("kit-test-0.0.1.tgz", "tarball\n")) + rmSync(join(dir, "native", "tool.gz")) + const result = cli(digest) + assert.equal(result.status, 1) + assert.match(result.stderr, /release-github: the staged Release files are not the approved ones: SHA256SUMS has the sha256 [0-9a-f]{64}, not/) + }) +}) + +describe("release-github.mjs native: the staged native files against the release commit's manifest", () => { + const hash = (bytes) => createHash("sha256").update(bytes).digest("hex") + const EXE = Buffer.from("the real gws executable\n") + const GZ = gzipSync(EXE) + const LICENCE = Buffer.from("Apache-2.0\n") + const manifestFor = (gz = GZ, exe = EXE) => ({ + schema: 1, + plugin: "kit-test", + repository: "owner/kit-test", + tag: "v0.0.1", + built: { commit: "c".repeat(40), run: 77, workflow: "native" }, + modules: { gws: { "linux-x64-gnu": { name: "gws-linux-x64-gnu.gz", bytes: gz.length, sha256: hash(gz), executable: { name: "gws", bytes: exe.length, sha256: hash(exe) } } } }, + licences: [{ name: "LICENSE-gws.txt", bytes: LICENCE.length, sha256: hash(LICENCE) }], + }) + const staged = (native = { "gws-linux-x64-gnu.gz": GZ, "LICENSE-gws.txt": LICENCE }) => { + const dir = folder() + writeTree(dir, { "kit-test-0.0.1.tgz": "tarball\n", SHA256SUMS: "sums\n", ...Object.fromEntries(Object.entries(native).map(([name, bytes]) => [`native/${name}`, bytes])) }) + return dir + } + + test("accepts the files the manifest pins, and a plugin with no manifest and no native files", () => { + assert.deepEqual(nativeAssetProblems({ dir: staged(), manifest: manifestFor() }), []) + assert.deepEqual(nativeAssetProblems({ dir: staged({}), manifest: undefined }), []) + }) + + test("refuses a swapped gzip, and a swapped executable inside a gzip that looks valid", () => { + const evil = gzipSync(Buffer.from("another executable\n")) + assert.match(nativeAssetProblems({ dir: staged({ "gws-linux-x64-gnu.gz": evil, "LICENSE-gws.txt": LICENCE }), manifest: manifestFor() }).join("; "), /gws linux-x64-gnu: gws-linux-x64-gnu\.gz is not the file the manifest pins/) + // The gzip matches its own pin, and holds another executable than the manifest pins. + const problems = nativeAssetProblems({ dir: staged({ "gws-linux-x64-gnu.gz": evil, "LICENSE-gws.txt": LICENCE }), manifest: manifestFor(evil, EXE) }) + assert.deepEqual(problems, [`gws linux-x64-gnu: gws-linux-x64-gnu.gz does not hold the executable the manifest pins (${gunzipLength(evil)} bytes; the manifest pins ${EXE.length} and its sha256)`]) + const notGzip = Buffer.from("not a gzip") + assert.match(nativeAssetProblems({ dir: staged({ "gws-linux-x64-gnu.gz": notGzip, "LICENSE-gws.txt": LICENCE }), manifest: manifestFor(notGzip) }).join("; "), /does not decompress/) + }) + + test("refuses a changed licence, a missing pinned file, an unpinned native file, native files without a manifest, and extra files", () => { + assert.match(nativeAssetProblems({ dir: staged({ "gws-linux-x64-gnu.gz": GZ, "LICENSE-gws.txt": "MIT\n" }), manifest: manifestFor() }).join("; "), /licence LICENSE-gws\.txt: LICENSE-gws\.txt is not the file the manifest pins/) + assert.deepEqual(nativeAssetProblems({ dir: staged({ "gws-linux-x64-gnu.gz": GZ }), manifest: manifestFor() }), ["licence LICENSE-gws.txt: native/LICENSE-gws.txt is not staged"]) + assert.deepEqual(nativeAssetProblems({ dir: staged({ "gws-linux-x64-gnu.gz": GZ, "LICENSE-gws.txt": LICENCE, "gws-darwin-arm64.gz": GZ }), manifest: manifestFor() }), ["native/gws-darwin-arm64.gz is staged, and the manifest does not pin it"]) + assert.match(nativeAssetProblems({ dir: staged(), manifest: undefined }).join("; "), /has no manifest\/native\.json, and native files are staged/) + const extra = staged() + writeFileSync(join(extra, "install.sh"), "x\n") + assert.match(nativeAssetProblems({ dir: extra, manifest: manifestFor() }).join("; "), /outside native\/ the staged files must be SHA256SUMS and one tarball, and they are install\.sh, kit-test-0\.0\.1\.tgz/) + }) + + test("the CLI reads the manifest from the release commit, and refuses a swapped gzip", () => { + const repo = repository({ ...pluginSource("0.0.1", { native: true }), "manifest/native.json": `${JSON.stringify(manifestFor(), null, 2)}\n` }) + const cli = (dir) => repo.node("release-github.mjs", ["native", "--dir", dir, "--commit", repo.head]) + assert.equal(cli(staged()).status, 0, cli(staged()).stderr) + const result = cli(staged({ "gws-linux-x64-gnu.gz": gzipSync(Buffer.from("x")), "LICENSE-gws.txt": LICENCE })) + assert.equal(result.status, 1) + assert.match(result.stderr, /release-github: the staged Release files are not the ones the release commit pins: gws linux-x64-gnu/) + }) +}) + +/** The size of a gzip's content. */ +const gunzipLength = (bytes) => gunzipSync(bytes).length + +describe("the release workflow's shape", () => { + const text = readFileSync(join(SCRIPTS, "..", ".github", "workflows", "release.yml"), "utf8") + const jobs = {} + { + const lines = text.slice(text.indexOf("\njobs:\n") + 7).split("\n") + let name + for (const line of lines) { + const match = /^ {2}([\w-]+):$/.exec(line) + if (match) { + name = match[1] + jobs[name] = [] + } else if (name) jobs[name].push(line) + } + for (const key of Object.keys(jobs)) jobs[key] = jobs[key].join("\n") + } + const top = (key) => new RegExp(`^${key}:[\\s\\S]*?(?=^\\S)`, "m").exec(`${text}\nEND`)?.[0] ?? "" + + test("starts only by hand, with no permission at the top", () => { + const on = top("on") + assert.match(on, /^ {2}workflow_dispatch:$/m) + assert.deepEqual([...on.matchAll(/^ {2}([\w_]+):/gm)].map((match) => match[1]), ["workflow_dispatch"]) + assert.match(text, /^permissions: \{\}$/m) + assert.match(text, /^ {2}group: release$/m) + assert.match(text, /^ {2}cancel-in-progress: false$/m) + }) + + test("has the eight jobs, and only publish uses the release environment", () => { + assert.deepEqual(Object.keys(jobs), ["plan", "build", "commit", "rebuild", "verify", "hosts", "pack", "stage", "publish", "cleanup"]) + for (const [name, body] of Object.entries(jobs)) assert.equal(/environment:/.test(body), name === "publish", name) + assert.match(jobs.publish, /^ {4}environment: release$/m) + }) + + test("contents: write only in commit, publish and cleanup, which run no npm and no build", () => { + for (const [name, body] of Object.entries(jobs)) { + const writes = /contents: write/.test(body) + assert.equal(writes, ["commit", "publish", "cleanup"].includes(name), name) + if (writes) { + assert.doesNotMatch(body, /\bnpm\b|\bbun\b|setup-bun|release-pack|check-release-plugin-add/, name) + } + assert.match(body, /^ {4}permissions:$/m, `${name} names its permissions`) + } + assert.doesNotMatch(text, /write-all|read-all|id-token|secrets\./) + }) + + test("every checkout keeps no credentials, every action is pinned by sha, and nothing approves", () => { + const checkouts = text.match(/uses: actions\/checkout@/g).length + assert.equal(text.match(/persist-credentials: false/g).length, checkouts) + for (const [, action, ref] of text.matchAll(/uses: ([\w./-]+)@(\S+)/g)) assert.match(ref, /^[0-9a-f]{40}$/, action) + assert.doesNotMatch(text, /pending_deployments|--verify git(?!hub)|allowed-signers|git push|tags: \[/) + for (const call of text.match(/check-release-shape\.mjs [^\n]*/g)) assert.match(call, /--verify github-api --identity github-actions/, call) + }) + + /** A job's steps, each its own text. */ + const steps = (job) => jobs[job].split(/\n(?= {6}- )/).filter((step) => /^ {6}- /.test(step)) + + /** Whether a step runs repository or dependency code (npm, the build, the pack stage, a host install). */ + const runsDependencies = (step) => /\bnpm (ci|run|pack|install)\b|release-pack\.mjs|release-commit\.mjs|check-release-plugin-add\.sh/.test(step) + + test("a job either runs repository or dependency code or holds a token, never both", () => { + const dependencyJobs = Object.keys(jobs).filter((job) => steps(job).some(runsDependencies)) + assert.deepEqual(dependencyJobs, ["build", "rebuild", "hosts", "pack"]) + for (const job of dependencyJobs) assert.doesNotMatch(jobs[job], /GH_TOKEN|github\.token|secrets\./, job) + for (const job of Object.keys(jobs).filter((name) => !dependencyJobs.includes(name))) { + assert.match(jobs[job], /persist-credentials: false/, job) + for (const step of steps(job).filter((text) => /\brun: /.test(text))) { + for (const [, script] of step.matchAll(/\bnode (\S+)/g)) assert.match(script, /^scripts\/(release-github|check-release-shape|attach-native|publish-release)\.mjs$/, `${job} runs ${script}`) + } + } + }) + + test("jobs that run repository code give setup-node and setup-bun no token", () => { + for (const job of ["build", "rebuild", "hosts", "pack"]) { + for (const step of steps(job).filter((text) => /uses: (actions\/setup-node|oven-sh\/setup-bun)@/.test(text))) assert.match(step, /^ {10}token: ''/m, `${job}: ${step.split("\n")[0]}`) + } + }) + + test("every job on the release commit refuses changed scripts/ or .github/ before it runs a kit script", () => { + for (const job of ["verify", "hosts", "pack", "stage", "publish"]) { + const list = steps(job) + const guard = list.findIndex((step) => /git diff --quiet "\$PARENT" "\$COMMIT" -- scripts \.github \|\| \{/.test(step)) + const firstScript = list.findIndex((step) => /\b(node|sh) scripts\//.test(step) || /\bnpm (ci|pack|run)\b/.test(step)) + assert.ok(guard >= 0 && firstScript > guard, `${job}: the guard is step ${guard}, the first script step ${firstScript}`) + assert.ok(list.slice(0, guard).every((step) => /uses: actions\/(checkout|setup-node)@/.test(step)), job) + assert.match(jobs[job], /^ {6}COMMIT: \$\{\{ needs\.commit\.outputs\.commit \|\| needs\.plan\.outputs\.commit \}\}$/m, job) + } + }) + + test("jobs pass files only as artifacts downloaded by the id the uploading job gave, after refusing an empty id", () => { + for (const job of Object.keys(jobs)) { + const list = steps(job) + list.forEach((step, index) => { + if (!/uses: actions\/download-artifact@/.test(step)) return + assert.match(step, /artifact-ids: \$\{\{ needs\.\w+\.outputs\.\w+ \}\}/, job) + assert.doesNotMatch(step, /\bname:/, job) + const id = /artifact-ids: (\$\{\{ [^}]+\}\})/.exec(step)[1] + assert.ok(list[index - 1].includes("Refuse an empty artifact id") && list[index - 1].includes(id), `${job}: the download of ${id} is guarded`) + }) + } + }) + + test("the pack job keeps its tarball before installing anything; stage and publish run no dependency code", () => { + const pack = steps("pack").map((step) => step.split("\n")[0]) + const kept = pack.findIndex((name) => /id: tarball/.test(name)) + const installed = steps("pack").findIndex((step) => /npm ci/.test(step)) + assert.ok(kept >= 0 && installed > kept, pack.join(" | ")) + assert.ok(steps("pack").findIndex((step) => /npm pack --ignore-scripts/.test(step)) < kept) + for (const job of ["stage", "publish"]) assert.ok(!steps(job).some(runsDependencies), job) + }) + + test("stage shows the digest publish requires, and publish checks the native files against the release commit before tagging", () => { + const names = steps("stage").map((step) => step.split("\n")[0]) + assert.match(jobs.stage, /sums_sha256: \$\{\{ steps\.sums\.outputs\.sha256 \}\}/) + assert.match(jobs.stage, /artifact_id: \$\{\{ steps\.upload\.outputs\.artifact-id \}\}/) + const summary = steps("stage").find((step) => /GITHUB_STEP_SUMMARY/.test(step)) + assert.match(summary, /SUMS_SHA256: \$\{\{ steps\.sums\.outputs\.sha256 \}\}/) + assert.match(summary, /sha256 of SHA256SUMS/) + assert.ok(names.findIndex((name) => /id: upload/.test(name)) < names.findIndex((name) => /Summarise/.test(name)), "the summary names the uploaded artifact") + assert.match(jobs.publish, /artifact-ids: \$\{\{ needs\.stage\.outputs\.artifact_id \}\}/) + assert.match(jobs.publish, /release-github\.mjs sums --dir "\$RUNNER_TEMP\/assets\/release" --sums-sha256 "\$SUMS_SHA256"/) + assert.match(jobs.publish, /SUMS_SHA256: \$\{\{ needs\.stage\.outputs\.sums_sha256 \}\}/) + const publish = steps("publish") + const nativeCheck = publish.findIndex((step) => /release-github\.mjs native --dir "\$RUNNER_TEMP\/assets\/release" --commit "\$COMMIT"/.test(step)) + const tag = publish.findIndex((step) => /release-github\.mjs tag /.test(step)) + assert.ok(nativeCheck >= 0 && tag > nativeCheck, "publish checks the native files before it tags") + assert.match(jobs.commit, /--manifest-sha256 "\$MANIFEST_SHA256"/) + }) +}) + +describe("the pack stage's tarball", () => { + test("is named only as a whole word", () => { + for (const text of ["process.env.PACK_TARBALL", 'env["PACK_TARBALL"]', '"$PACK_TARBALL"', "${PACKED_DIR}/x.tgz", "const { PACKED_DIR } = process.env"]) { + assert.equal(namesTarball(text), true, text) + } + for (const text of ["process.env.SLACK_PACK_TARBALL", "PACK_TARBALLS", "MY_PACKED_DIR", "PACKED_DIR_2", "pack_tarball"]) { + assert.equal(namesTarball(text), false, text) + } + }) + + test("is read by a pack test, not by a longer name or the kit's own files", () => { + const root = folder() + mkdirSync(join(root, "scripts")) + mkdirSync(join(root, "test", "pack"), { recursive: true }) + writeFileSync(join(root, "scripts", "release-pack.mjs"), "env: { PACK_TARBALL: tarball, PACKED_DIR: dir }\n") + writeFileSync(join(root, "scripts", "release-kit.test.mjs"), "namesTarball('process.env.PACK_TARBALL')\n") + writeFileSync(join(root, "test", "pack", "pack.test.ts"), "const tarball = process.env.SLACK_PACK_TARBALL\n") + assert.equal(readsTarball(root), false) + writeFileSync(join(root, "test", "pack", "install.test.ts"), "const tarball = process.env.PACK_TARBALL\n") + assert.equal(readsTarball(root), true) + }) +}) + +describe("publish-release.mjs", () => { + test("never deletes or replaces an asset: a kit file with other contents stops the upload", () => { + const dir = folder() + writeFileSync(join(dir, "SHA256SUMS"), "sums\n") + writeFileSync(join(dir, "kit-test-0.0.1.tgz"), "tarball\n") + const calls = [] + const gh = (args) => { + calls.push(args[1]) + if (args[1] === "view") return JSON.stringify({ isDraft: false, assets: [{ name: "SHA256SUMS" }, { name: "speech.bin" }] }) + if (args[1] === "download") writeFileSync(join(args[args.indexOf("--dir") + 1], "SHA256SUMS"), "other sums\n") + return "" + } + assert.throws(() => publish({ tag: "v0.0.1", dir, notes: join(dir, "SHA256SUMS"), gh }), /SHA256SUMS is on the Release with other contents: nothing was uploaded/) + assert.deepEqual(calls, ["view", "download"]) + }) + + test("adds only the missing kit files, and leaves the assets it does not know alone", () => { + const dir = folder() + writeFileSync(join(dir, "SHA256SUMS"), "sums\n") + writeFileSync(join(dir, "kit-test-0.0.1.tgz"), "tarball\n") + const calls = [] + const gh = (args) => { + calls.push(args) + if (args[1] === "view") return JSON.stringify({ isDraft: false, assets: [{ name: "SHA256SUMS" }, { name: "speech.bin" }] }) + if (args[1] === "download") writeFileSync(join(args[args.indexOf("--dir") + 1], "SHA256SUMS"), "sums\n") + return "" + } + const result = publish({ tag: "v0.0.1", dir, notes: join(dir, "SHA256SUMS"), gh }) + assert.deepEqual(result, { action: "uploaded", uploaded: ["kit-test-0.0.1.tgz"], unknown: ["speech.bin"] }) + assert.deepEqual(calls.map((args) => args[1]), ["view", "download", "upload"]) + assert.ok(calls.every((args) => !args.includes("delete") && !args.includes("--clobber"))) + }) +}) + +// CI sets GITHUB_REPOSITORY to the plugin's own repository, and a shell may set it to anything. The kit tests pass the +// repository they mean explicitly, so they must pass whatever the ambient value is: this runs them again, at once, with it +// unset, set to the o/r the rule tests name, and set to another repository. +describe("the kit tests, whatever the ambient GITHUB_REPOSITORY", { skip: process.env.KIT_TESTS_NESTED === "1" }, () => { + test("pass with GITHUB_REPOSITORY unset, set to the tests' own o/r, and set to another repository", async () => { + // Unset; the value the rule tests name, where an ambient value can stand in for a missing argument; and another. + const runs = [undefined, "o/r", "someone-else/another-plugin"].map( + (value) => + new Promise((resolve) => { + // Each run has its own temporary folder: two tests count what is left in theirs. + const env = { ...process.env, KIT_TESTS_NESTED: "1", TMPDIR: folder() } + // A run inside node --test reports to its parent through NODE_TEST_CONTEXT; this one is a run of its own. + delete env.NODE_TEST_CONTEXT + if (value === undefined) delete env.GITHUB_REPOSITORY + else env.GITHUB_REPOSITORY = value + const child = spawn(process.execPath, ["--test", "--test-reporter=spec", fileURLToPath(import.meta.url)], { env }) + let out = "" + child.stdout.on("data", (chunk) => { + out += chunk + }) + child.on("close", (status) => resolve({ value: value ?? "(unset)", status, out })) + }), + ) + for (const { value, status, out } of await Promise.all(runs)) { + const counts = /ℹ pass (\d+)[\s\S]*?ℹ fail (\d+)/.exec(out) + const failed = out.split("\n").filter((line) => /^\s*✖|AssertionError/.test(line)).slice(0, 20).join("\n") + assert.equal(status, 0, `GITHUB_REPOSITORY=${value}: ${counts?.[0] ?? "no counts"}\n${failed}`) + assert.equal(counts?.[2], "0", `GITHUB_REPOSITORY=${value}`) + assert.ok(Number(counts?.[1]) > 90, `GITHUB_REPOSITORY=${value}: ${counts?.[1]} tests ran`) + } + }) +}) diff --git a/scripts/release-pack.mjs b/scripts/release-pack.mjs index 2073849..8ef0004 100644 --- a/scripts/release-pack.mjs +++ b/scripts/release-pack.mjs @@ -4,7 +4,9 @@ // The stage is the first of these that exists: a `test:pack`, `pack-smoke` or `check:pack` script in package.json, else // `node scripts/check.mjs pack` when scripts/check.mjs has a pack stage. A repository with none is refused, with the reason. // The stage gets the tarball as PACK_TARBALL (the file) and PACKED_DIR (its folder; utilities' pack test reads that). When -// no file under scripts/, test/ or tests/ reads either, the stage may test its own tarball: that is printed as a warning. +// no file under scripts/, test/ or tests/ names either as a whole word, the stage may test its own tarball: that is +// printed as a warning. A longer name that contains one, such as SLACK_PACK_TARBALL, is not this tarball and does not +// count, and neither do the kit's own files, which name both. // // node scripts/release-pack.mjs --tarball import { spawnSync } from "node:child_process" @@ -13,6 +15,8 @@ import { dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" export const STAGE_SCRIPTS = ["test:pack", "pack-smoke", "check:pack"] +/** The kit's files that name PACK_TARBALL and PACKED_DIR themselves: they are not a pack stage reading the tarball. */ +const KIT_FILES = ["scripts/release-pack.mjs", "scripts/release-kit.test.mjs"] /** * The pack stage for a package.json. `read(path)` returns a file's text, or undefined when there is no such file. @@ -34,15 +38,19 @@ export function packStage(pkg, read) { ) } -/** Whether a file under scripts/, test/ or tests/ of the repository at `root` names PACK_TARBALL or PACKED_DIR. */ +/** Whether a text names PACK_TARBALL or PACKED_DIR as a whole word, not as part of a longer name. */ +export const namesTarball = (text) => /(? join(root, path))) const walk = (dir) => { if (!existsSync(dir)) return false for (const name of readdirSync(dir)) { const full = join(dir, name) if (statSync(full).isDirectory()) { if (name !== "node_modules" && walk(full)) return true - } else if (/\.(m?[jt]sx?|sh)$/.test(name) && /PACK_TARBALL|PACKED_DIR/.test(readFileSync(full, "utf8"))) { + } else if (/\.(m?[jt]sx?|sh)$/.test(name) && !kit.has(full) && namesTarball(readFileSync(full, "utf8"))) { return true } } @@ -71,10 +79,9 @@ function main(argv) { if (!readsTarball(root)) { console.log("WARN no file under scripts/, test/ or tests/ reads PACK_TARBALL or PACKED_DIR: the stage may test its own tarball, not this one") } - const result = spawnSync(stage.command, stage.args, { - stdio: "inherit", - env: { ...process.env, PACK_TARBALL: tarball, PACKED_DIR: dirname(tarball) }, - }) + const options = { stdio: "inherit", env: { ...process.env, PACK_TARBALL: tarball, PACKED_DIR: dirname(tarball) } } + // The program is one of the two the stage names, spelled out, so no kit script runs a program it does not name. + const result = stage.command === "npm" ? spawnSync("npm", stage.args, options) : spawnSync(process.execPath, stage.args, options) if (result.error) { console.error(`release-pack: ${result.error.message}`) process.exit(1) diff --git a/scripts/release-rules.mjs b/scripts/release-rules.mjs new file mode 100644 index 0000000..5b7fd85 --- /dev/null +++ b/scripts/release-rules.mjs @@ -0,0 +1,375 @@ +// The release kit's shared rules: what a release commit may change, the identities GitHub gives the commit and the tag +// the release workflow makes, the native run a release may use, the order releases move in, and the one `gh` every kit +// script calls. Pure functions and small helpers only; no side effects at import, so every kit script and the tests can +// import it. +import { spawnSync } from "node:child_process" +import { basename } from "node:path" +import { MANIFEST_PATH } from "./native-manifest.mjs" +import { compareVersions, isReleaseTag, VERSION_SOURCE } from "./release-version.mjs" + +/** Lifecycle scripts a git install runs, or that npm runs around install and publish. The release commit drops them. */ +export const LIFECYCLE_SCRIPTS = [ + "build", + "preinstall", + "install", + "postinstall", + "preprepare", + "prepare", + "postprepare", + "prepublish", + "prepublishOnly", + "prepack", + "postpack", + "publish", + "postpublish", +] + +/** The author of a commit made with the release workflow's token, and the tagger the kit writes on the tag. Public, fixed. */ +export const ACTIONS_BOT = { name: "github-actions[bot]", email: "41898282+github-actions[bot]@users.noreply.github.com", login: "github-actions[bot]" } +/** The committer GitHub records, and signs as, for a commit made through its API ("web-flow"). Public, fixed. */ +export const WEB_FLOW = { name: "GitHub", email: "noreply@github.com" } + +/** The release commit's headline. */ +export const releaseHeadline = (tag) => `chore(release): ${tag} with the built dist/ and no lifecycle scripts` +/** The temporary branch the release commit is made on, before the tag exists. */ +export const releaseBranch = (tag) => `release/${tag}` + +/** + * The paths the release commit may add or change. The manifest is allowed only for a native plugin. No package.json + * under dist/: one there would change how the host resolves the bundles. + */ +export function allowedPath(path, { native = false } = {}) { + if (path.startsWith("dist/")) return !/(^|\/)package\.json$/.test(path.slice("dist/".length)) + return path === "package.json" || (native && path === MANIFEST_PATH) +} + +/** The one git mode a release path may have: a regular, non-executable file. No symlink, executable or submodule. */ +export const RELEASE_MODE = "100644" + +/** The paths, of { path, mode } entries, whose git mode is not RELEASE_MODE, as sentences. */ +export function modeProblems(entries) { + const kinds = { "100755": "an executable", "120000": "a symlink", "160000": "a submodule", "000000": "deleted" } + return entries.filter(({ mode }) => mode !== RELEASE_MODE).map(({ path, mode }) => `${path} is ${kinds[mode] ?? `mode ${mode}`}, not a regular file`) +} + +/** package.json with its lifecycle scripts removed. Keys keep their order, and the JSON form stays the repository's. */ +export function withoutLifecycle(pkg) { + const scripts = pkg.scripts ?? {} + const removed = Object.keys(scripts).filter((name) => LIFECYCLE_SCRIPTS.includes(name)) + const kept = Object.fromEntries(Object.entries(scripts).filter(([name]) => !LIFECYCLE_SCRIPTS.includes(name))) + const next = { ...pkg } + if (Object.keys(kept).length) next.scripts = kept + else delete next.scripts + return { next, removed } +} + +export const serialise = (pkg) => `${JSON.stringify(pkg, null, 2)}\n` + +/** The release commit's package.json, exactly: the parent's, without its lifecycle scripts, in the canonical form. */ +export const releasePackageText = (pkg) => serialise(withoutLifecycle(pkg).next) + +/** + * Why a release commit, as the REST API returns it (GET repos/{r}/commits/{sha}), is not one the release workflow made: + * GitHub verifies its signature, its author is github-actions[bot], its committer is GitHub's web-flow, and it has one + * parent. Sentences; an address that is not one of the two fixed public ones is never printed. + */ +export function commitIdentityProblems(apiCommit) { + const problems = [] + const verification = apiCommit?.commit?.verification + if (verification?.verified !== true || verification.reason !== "valid") { + problems.push(`GitHub does not verify the release commit's signature (reason: ${verification?.reason ?? "none"})`) + } + const author = apiCommit?.commit?.author ?? {} + if (author.email !== ACTIONS_BOT.email) problems.push(`the release commit's author is another address, not ${ACTIONS_BOT.name}`) + if (apiCommit?.author?.login !== ACTIONS_BOT.login || apiCommit?.author?.type !== "Bot") { + problems.push(`the release commit's author account is not the ${ACTIONS_BOT.login} app`) + } + const committer = apiCommit?.commit?.committer ?? {} + if (committer.email !== WEB_FLOW.email || committer.name !== WEB_FLOW.name) problems.push("the release commit's committer is not GitHub's web-flow") + const parents = apiCommit?.parents ?? [] + if (parents.length !== 1) problems.push(`the release commit has ${parents.length} parents; it needs exactly one`) + return problems +} + +/** Why a tag ref, as the REST API returns it (GET repos/{r}/git/ref/tags/{tag}), is not an annotated tag. */ +export function tagRefProblems(apiRef, tag) { + if (!apiRef?.object) return [`there is no tag ${tag}`] + return apiRef.object.type === "tag" ? [] : [`${tag} is a lightweight tag (it points at a ${apiRef.object.type}), not an annotated one`] +} + +/** + * Why a tag object, as the REST API returns it (GET repos/{r}/git/tags/{sha}), is not the one the release workflow makes + * for `tag` on `commit`: named and titled exactly the tag, on that commit, tagged by github-actions[bot], and unsigned. + * A signature of any kind, valid or not, is refused: the kit never signs a tag. + */ +export function tagObjectProblems(apiTag, { tag, commit }) { + const problems = [] + if (!apiTag || typeof apiTag !== "object") return [`the tag object of ${tag} could not be read`] + if (apiTag.tag !== tag) problems.push(`the tag object is named ${apiTag.tag}, not ${tag}`) + if (typeof apiTag.message !== "string" || apiTag.message.replace(/\n$/, "") !== tag) problems.push(`the tag message is not exactly ${tag}`) + if (apiTag.object?.type !== "commit") problems.push(`the tag points at a ${apiTag.object?.type ?? "missing object"}, not a commit`) + else if (commit !== undefined && apiTag.object.sha !== commit) problems.push(`the tag points at ${String(apiTag.object.sha).slice(0, 12)}, not the release commit ${commit.slice(0, 12)}`) + if (apiTag.tagger?.email !== ACTIONS_BOT.email || apiTag.tagger?.name !== ACTIONS_BOT.name) problems.push(`the tagger is not ${ACTIONS_BOT.name}`) + const verification = apiTag.verification + if (verification?.verified !== false || verification.reason !== "unsigned") { + problems.push(`the tag must be unsigned, and GitHub says ${verification?.verified ? "verified" : "not verified"} (${verification?.reason ?? "no verification"})`) + } + return problems +} + +/** + * Why a native run, as the REST API returns it (GET repos/{r}/actions/runs/{id}), cannot supply the release's native + * files: it must have built `sha`, succeeded, be the native workflow, on master, started by hand (workflow_dispatch). + */ +export function nativeRunProblems(run, { sha }) { + const problems = [] + if (run?.head_sha !== sha) problems.push(`the run built ${String(run?.head_sha).slice(0, 12)}, not ${sha.slice(0, 12)}`) + if (run?.conclusion !== "success") problems.push(`the run concluded ${run?.conclusion ?? "nothing yet"}`) + if (basename(String(run?.path ?? "")) !== "native.yml") problems.push("the run is not the native workflow") + if (run?.head_branch !== "master") problems.push("the run is not on master") + if (run?.event !== "workflow_dispatch") problems.push(`the run was started by ${run?.event}, not workflow_dispatch`) + return problems +} + +/** The tags of the published GitHub Releases, from the Releases API: not drafts, published, and named v. */ +export function releasedTags(releases) { + return releases.filter((release) => release?.draft === false && Boolean(release.published_at) && isReleaseTag(release.tag_name)).map((release) => release.tag_name) +} + +/** Every Release of a repository, page by page, through the GitHub CLI. `gh(args)` returns its output or throws. */ +export function fetchReleases(repository, gh) { + const all = [] + for (let page = 1; ; page++) { + const batch = JSON.parse(gh(["api", `repos/${repository}/releases?per_page=100&page=${page}`])) + if (!Array.isArray(batch)) throw new Error("the Releases API did not answer with a list") + all.push(...batch) + if (batch.length < 100) return all + } +} + +/** The one GraphQL request the kit sends: the release commit (scripts/release-github.mjs commit). */ +export const COMMIT_MUTATION = "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }" + +const REPO = "[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+" +const SHA = "[0-9a-f]{40}" +const TAG = `v${VERSION_SOURCE}` +const exact = (source) => new RegExp(`^${source}$`) +const TAG_EXACT = exact(TAG) +const REPO_EXACT = exact(REPO) +const SHA_EXACT = exact(SHA) + +/** The REST reads the kit makes: `gh api `, nothing else. */ +const READS = [ + `repos/${REPO}/actions/workflows/ci\\.yml/runs\\?head_sha=${SHA}&event=push&branch=master&per_page=100`, + `repos/${REPO}/actions/runs/\\d+`, + `repos/${REPO}/git/ref/heads/release/${TAG}`, + `repos/${REPO}/git/ref/tags/${TAG}`, + `repos/${REPO}/git/tags/${SHA}`, + `repos/${REPO}/commits/${SHA}`, + `repos/${REPO}/releases\\?per_page=100&page=\\d+`, + `repos/${REPO}/compare/${SHA}\\.\\.\\.master`, +].map(exact) + +/** + * JSON.parse that also refuses a key repeated in one object. `gh` sends the raw text, and GitHub may read a repeated key + * differently from JSON.parse (which keeps the last), so a body the kit checks must have one meaning only. + */ +export function strictJson(text) { + if (typeof text !== "string") throw new Error("the body is not text") + const value = JSON.parse(text) + const stack = [] + for (let i = 0; i < text.length; i++) { + const c = text[i] + if (c === '"') { + let j = i + 1 + for (;;) { + const quote = text.indexOf('"', j) + let slashes = 0 + while (text[quote - 1 - slashes] === "\\") slashes++ + if (slashes % 2 === 0) { + j = quote + break + } + j = quote + 1 + } + const top = stack.at(-1) + if (top?.object && top.expectKey) { + const key = JSON.parse(text.slice(i, j + 1)) + if (top.keys.has(key)) throw new Error(`the key ${JSON.stringify(key)} appears twice in one object`) + top.keys.add(key) + top.expectKey = false + } + i = j + } else if (c === "{") stack.push({ object: true, keys: new Set(), expectKey: true }) + else if (c === "[") stack.push({ object: false }) + else if (c === "}" || c === "]") stack.pop() + else if (c === "," && stack.at(-1)?.object) stack.at(-1).expectKey = true + } + return value +} + +/** + * Whether a repository path is plain: relative, no empty, "." or ".." part, no backslash, NUL or other control character. + * REST paths must also have no `%`, `#` or whitespace; file paths may. + */ +export function plainRepositoryPath(path, { rest = false } = {}) { + if (typeof path !== "string" || path === "") return false + const control = [...path].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 || char === "\\") + if (control || path.startsWith("/") || path.includes("//")) return false + if (rest && /[%#\s]/.test(path)) return false + return !path.split(rest ? /[/?]/ : "/").some((part) => part === "." || part === "..") +} + +/** Whether a value is a plain object with exactly these keys. */ +const hasKeys = (value, keys) => + Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)) + +/** A JSON object with exactly these keys and no repeated key, or undefined. */ +function bodyWith(input, keys) { + let body + try { + body = strictJson(input) + } catch { + return undefined + } + return hasKeys(body, keys) ? body : undefined +} + +/** + * Why a GraphQL body is not the release commit's createCommitOnBranch, or undefined: the kit's mutation, on + * `repository`'s release/v branch, at an expected head, with the release headline, adding only paths a release + * commit may change, and deleting nothing. + */ +function commitRequestProblem(input, repository) { + const body = bodyWith(input, ["query", "variables"]) + if (body?.query !== COMMIT_MUTATION) return "a GraphQL request other than the release commit's createCommitOnBranch" + const commit = hasKeys(body.variables, ["input"]) ? body.variables.input : undefined + if (!hasKeys(commit, ["branch", "message", "fileChanges", "expectedHeadOid"])) return "a createCommitOnBranch whose input is not the release commit's shape" + if (!repository || !hasKeys(commit.branch, ["repositoryNameWithOwner", "branchName"]) || commit.branch.repositoryNameWithOwner !== repository) return "a createCommitOnBranch on another repository than GITHUB_REPOSITORY" + const match = exact(`release/(${TAG})`).exec(String(commit.branch.branchName)) + if (!match) return "a createCommitOnBranch on a branch other than release/v" + if (!SHA_EXACT.test(String(commit.expectedHeadOid))) return "a createCommitOnBranch without an expected head" + if (!hasKeys(commit.message, ["headline"]) || commit.message.headline !== releaseHeadline(match[1])) return "a createCommitOnBranch without the release headline" + if (!hasKeys(commit.fileChanges, ["additions"]) || !Array.isArray(commit.fileChanges.additions) || !commit.fileChanges.additions.length) return "a createCommitOnBranch that deletes, or adds nothing" + const paths = commit.fileChanges.additions.map((file) => (hasKeys(file, ["path", "contents"]) && typeof file.contents === "string" ? file.path : undefined)) + if (paths.some((path) => !plainRepositoryPath(path))) return "a createCommitOnBranch that writes a path with an empty, relative, absolute or control part" + if (paths.some((path) => !allowedPath(path, { native: true })) || new Set(paths).size !== paths.length) return "a createCommitOnBranch that writes a path outside the release's allowed set" + return undefined +} + +/** A plain local path argument: not an option, not empty. */ +const plainPath = (arg) => typeof arg === "string" && arg !== "" && !arg.startsWith("-") + +/** + * Why `gh` with these arguments, and this stdin, is not one of the calls the release kit makes, or undefined when it is. + * An allowlist: the exact REST reads and writes, the one GraphQL mutation (compared whole), and the `gh run`, `gh + * attestation` and `gh release` forms the kit uses. Everything else is refused, approving or rejecting a deployment, + * environments, rulesets, their bypasses and branch protection included, in any spelling: the release approval is the + * owner's alone (CONTRIBUTING.md, "Releasing"). + */ +export function forbiddenCall(args, input, { repository = process.env.GITHUB_REPOSITORY } = {}) { + if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) return "arguments that are not strings" + const [command, sub] = args + // A call on a repository names exactly GITHUB_REPOSITORY; without a well-formed one, no such call is made. + const knownRepository = typeof repository === "string" && REPO_EXACT.test(repository) + const otherRepository = (name) => !knownRepository || name !== repository + if (command === "api") { + if (args.length === 4 && sub === "graphql" && args[2] === "--input" && args[3] === "-") return commitRequestProblem(input, repository) + const method = sub === "-X" ? args[2] : "GET" + const path = sub === "-X" ? args[3] : sub + if (!plainRepositoryPath(path, { rest: true })) return "a path with encoded, empty or relative parts" + if (!knownRepository) return "a call on a repository while GITHUB_REPOSITORY is unset or not owner/name" + if (otherRepository(path.split("/").slice(1, 3).join("/"))) return `a call on another repository than ${repository}` + const rest = args.slice(sub === "-X" ? 4 : 2) + if (method === "GET") return rest.length === 0 && READS.some((pattern) => pattern.test(path)) ? undefined : `GET ${path}, which the kit never reads` + if (method === "DELETE") return rest.length === 0 && exact(`repos/${REPO}/git/refs/heads/release/${TAG}`).test(path) ? undefined : `DELETE ${path}` + if (method !== "POST") return `${method} ${path}` + if (exact(`repos/${REPO}/git/refs`).test(path)) { + if (rest.length === 4 && rest[0] === "-f" && rest[2] === "-f" && exact(`ref=refs/heads/release/${TAG}`).test(rest[1]) && exact(`sha=${SHA}`).test(rest[3])) return undefined + const body = rest.length === 2 && rest[0] === "--input" && rest[1] === "-" ? bodyWith(input, ["ref", "sha"]) : undefined + return body && exact(`refs/tags/${TAG}`).test(String(body.ref)) && SHA_EXACT.test(String(body.sha)) ? undefined : "a ref other than release/ at a commit or the release tag" + } + if (exact(`repos/${REPO}/git/tags`).test(path)) { + const body = rest.length === 2 && rest[0] === "--input" && rest[1] === "-" ? bodyWith(input, ["message", "object", "tag", "tagger", "type"]) : undefined + return body && TAG_EXACT.test(String(body.tag)) && body.message === body.tag && body.type === "commit" && SHA_EXACT.test(String(body.object)) ? undefined : "a tag object other than the release tag's" + } + return `POST ${path}` + } + if ((command === "run" || command === "attestation") && !knownRepository) return "a call on a repository while GITHUB_REPOSITORY is unset or not owner/name" + if ((command === "run" || command === "attestation") && args.includes("--repo") && otherRepository(args[args.indexOf("--repo") + 1])) return `a call on another repository than ${repository}` + if (command === "run" && sub === "download" && /^\d+$/.test(args[2] ?? "") && args[3] === "--repo" && REPO_EXACT.test(args[4] ?? "")) { + const rest = args.slice(5) + if (rest.length === 4 && rest[0] === "--name" && rest[1] === "native-manifest" && rest[2] === "--dir" && plainPath(rest[3])) return undefined + if (rest.length === 2 && rest[0] === "--dir" && plainPath(rest[1])) return undefined + return "a run download the kit does not make" + } + if (command === "attestation" && sub === "verify" && args.length === 5 && plainPath(args[2]) && args[3] === "--repo" && REPO_EXACT.test(args[4])) return undefined + if (command === "release" && TAG_EXACT.test(args[2] ?? "")) { + const [, , tag, ...rest] = args + if (sub === "view" && rest.length === 2 && rest[0] === "--json" && rest[1] === "assets,isDraft") return undefined + if (sub === "download" && rest.length === 4 && rest[0] === "--pattern" && plainPath(rest[1]) && rest[2] === "--dir" && plainPath(rest[3])) return undefined + if (sub === "upload" && rest.length > 0 && rest.every(plainPath)) return undefined + if (sub === "create" && rest[0] === "--verify-tag" && rest[1] === "--title" && rest[2] === tag && rest[3] === "--notes-file" && rest.length > 5 && rest.slice(4).every(plainPath)) return undefined + return `gh release ${sub} in a form the kit does not use` + } + return `gh ${[command, sub].filter(Boolean).join(" ")}, which the kit never runs` +} + +/** + * Runs `gh`, only for a call the kit makes (forbiddenCall), and returns its output, or throws with its message. `input` + * is sent on stdin, re-serialised from the checked body, so gh sends exactly what was checked. With `inherit`, gh's + * output goes to this process's, and nothing is returned. `repository` (GITHUB_REPOSITORY by default) is the one + * repository every call may name. + */ +export function ghOutput(args, { input, inherit = false, repository = process.env.GITHUB_REPOSITORY } = {}) { + const forbidden = forbiddenCall(args, input, { repository }) + if (forbidden) throw new Error(`refused: the release kit makes only its own GitHub calls, and not ${forbidden}; only the owner approves a release`) + const sent = input === undefined ? undefined : JSON.stringify(strictJson(input)) + const result = spawnSync("gh", args, { encoding: "utf8", input: sent, maxBuffer: 256 * 1024 * 1024, stdio: inherit ? ["pipe", "inherit", "inherit"] : "pipe" }) + if (result.error) throw new Error(`gh could not run: ${result.error.message}`) + if (result.status !== 0) throw new Error(inherit ? `gh ${args.slice(0, 2).join(" ")} failed (exit ${result.status})` : ghError(result.stderr, result.status)) + return inherit ? "" : result.stdout +} + +/** gh's error text, one line: the line naming the HTTP status when there is one, else the first non-empty line. */ +function ghError(stderr, status) { + const lines = String(stderr || "").split("\n").map((line) => line.trim()).filter(Boolean) + return lines.find((line) => /HTTP \d{3}/.test(line)) ?? lines.find((line) => !/^Tip: |boxd/.test(line)) ?? `gh exited ${status}` +} + +/** A REST read through `gh`, parsed; undefined when GitHub answers 404 and `allow404` is set. */ +export function api(gh, path, { allow404 = false } = {}) { + try { + return JSON.parse(gh(["api", path])) + } catch (error) { + if (allow404 && /HTTP 404|Not Found/.test(String(error.message))) return undefined + throw error + } +} + +/** owner/name of a checkout's origin, when it is a GitHub remote. */ +export function originRepository(cwd) { + const result = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf8", cwd }) + return /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(result.status === 0 ? result.stdout.trim() : "")?.[1] +} + +/** + * Whether a release of `tag` on `parent` moves forward from the published Releases (`released`, their tag names): every + * other one is an older version, and the newest of them has a parent that is an ancestor of `parent`, or is it. A tag + * that never became a Release does not count. Returns { previous, problem }: the last Release, and why the release is + * refused, or undefined. The previous Release's tag must be in the checkout at `cwd`, to read its parent. + */ +export function releaseOrder({ tag, parent, released, cwd }) { + const version = tag.slice(1) + const others = [...new Set(released)].filter((name) => name !== tag) + const newer = others.filter((name) => compareVersions(name.slice(1), version) >= 0) + if (newer.length) return { problem: `the published Release ${newer.join(", ")} is not older than ${tag}` } + const previous = others.sort((a, b) => compareVersions(b.slice(1), a.slice(1)))[0] + if (!previous) return { previous } + const read = spawnSync("git", ["rev-parse", "-q", "--verify", `${previous}^{commit}^`], { encoding: "utf8", cwd }) + if (read.status !== 0) return { previous, problem: `the tag ${previous} is not in this checkout, so its parent cannot be read: fetch the tags` } + if (spawnSync("git", ["merge-base", "--is-ancestor", read.stdout.trim(), parent], { cwd }).status !== 0) { + return { previous, problem: `the parent does not descend from the parent of ${previous}, the last Release: the release would ship older source` } + } + return { previous } +} diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs new file mode 100644 index 0000000..c2ae9f5 --- /dev/null +++ b/scripts/release-version.mjs @@ -0,0 +1,33 @@ +// The release version and its tag, the one rule every kit script uses: a version is x.y.z, numbers without leading zeros, +// with no prerelease or build part, and its tag is exactly v. It has no dependencies. + +const CORE = "(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)" +/** The version part of a pattern, without anchors, for patterns that embed it (a CHANGELOG heading). */ +export const VERSION_SOURCE = CORE +/** A whole release version. */ +export const RELEASE_VERSION = new RegExp(`^${CORE}$`) + +/** The release tag of a version: exactly v. */ +export const tagFor = (version) => `v${version}` + +/** Whether a tag name is a release tag, v. */ +export const isReleaseTag = (tag) => typeof tag === "string" && tag.startsWith("v") && RELEASE_VERSION.test(tag.slice(1)) + +/** -1, 0 or 1, comparing two release versions by number. */ +export function compareVersions(a, b) { + const [x, y] = [a, b].map((version) => version.split(".").map(Number)) + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1 + return 0 +} + +/** Why package.json's version cannot be released, or undefined. */ +export function versionProblem(version) { + return typeof version === "string" && RELEASE_VERSION.test(version) ? undefined : `package.json version ${JSON.stringify(version)} is not x.y.z` +} + +/** Why a tag cannot release package.json's version, or undefined: the tag must be exactly v. */ +export function tagProblem(tag, version) { + const problem = versionProblem(version) + if (problem) return problem + return tag === tagFor(version) ? undefined : `the tag is ${tag}, and package.json's version ${version} releases only as ${tagFor(version)}` +}