diff --git a/.github/workflows/npm-stage.yml b/.github/workflows/npm-stage.yml index 8bf7c83..b5df92d 100644 --- a/.github/workflows/npm-stage.yml +++ b/.github/workflows/npm-stage.yml @@ -1,97 +1,117 @@ -name: Stage npm package +name: Publish npm package on: push: - branches: [main] - paths: - - "package.json" - workflow_dispatch: + tags: + - "v*" permissions: contents: read concurrency: - group: npm-stage + group: npm-publish cancel-in-progress: false jobs: + authorize: + name: Authorize owner release tag + permissions: {} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Verify immutable owner and public repository identity + env: + EXPECTED_ACTOR_ID: "894119" + EXPECTED_REPOSITORY: "hraness/kb" + EXPECTED_REPOSITORY_ID: "1308971873" + REF_PROTECTED: ${{ github.ref_protected }} + run: | + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" != push || \ + "$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID" || \ + "$GITHUB_REPOSITORY" != "$EXPECTED_REPOSITORY" || \ + "$GITHUB_REPOSITORY_ID" != "$EXPECTED_REPOSITORY_ID" || \ + "$REF_PROTECTED" != true ]]; then + echo "::error::Publication requires an owner-created protected release tag in the exact public repository" + exit 1 + fi + EXPECTED_ACTOR_ID="$EXPECTED_ACTOR_ID" \ + EXPECTED_REPOSITORY="$EXPECTED_REPOSITORY" \ + EXPECTED_REPOSITORY_ID="$EXPECTED_REPOSITORY_ID" node <<'NODE' + const { readFileSync } = require("node:fs"); + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + if ( + event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID) + || event.sender?.type !== "User" + || event.repository?.id !== Number(process.env.EXPECTED_REPOSITORY_ID) + || event.repository?.full_name !== process.env.EXPECTED_REPOSITORY + || event.repository?.visibility !== "public" + || event.repository?.private !== false + || event.repository?.default_branch !== "main" + ) throw new Error("Tag-push sender or public repository identity is not the immutable release authority"); + NODE + select: - name: Select stable package version + name: Select publishable package version + needs: authorize permissions: contents: read runs-on: ubuntu-latest timeout-minutes: 5 outputs: current_version: ${{ steps.selection.outputs.current_version }} + publish_tag: ${{ steps.selection.outputs.publish_tag }} reason: ${{ steps.selection.outputs.reason }} - should_stage: ${{ steps.selection.outputs.should_stage }} + should_publish: ${{ steps.selection.outputs.should_publish }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: "1.3.14" - - name: Select stage request + - name: Validate owner-tagged publication request id: selection env: - BEFORE_SHA: ${{ github.event.before }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REF_PROTECTED: ${{ github.ref_protected }} run: | set -euo pipefail - expected_ref="refs/heads/$DEFAULT_BRANCH" - git check-ref-format "$expected_ref" - if [[ "$GITHUB_REF" != "$expected_ref" ]]; then - echo "::error::Run npm staging from $expected_ref, received $GITHUB_REF" + if [[ "$GITHUB_EVENT_NAME" != push || "$REF_PROTECTED" != true ]]; then + echo "::error::Publication requires a protected owner-created release tag" exit 1 fi git fetch --no-tags origin \ - "$expected_ref:refs/remotes/origin/$DEFAULT_BRANCH" + "refs/heads/$DEFAULT_BRANCH:refs/remotes/origin/$DEFAULT_BRANCH" default_head="$(git rev-parse "origin/$DEFAULT_BRANCH")" checked_out_head="$(git rev-parse HEAD)" if [[ "$GITHUB_SHA" != "$default_head" || "$checked_out_head" != "$default_head" ]]; then - echo "::error::Npm staging commit $GITHUB_SHA is not current $DEFAULT_BRANCH head $default_head" + echo "::error::Tagged workflow commit $GITHUB_SHA is not current $DEFAULT_BRANCH head $default_head" exit 1 fi - - selection_args=( - --current-manifest package.json - --event "$GITHUB_EVENT_NAME" - --github-output "$GITHUB_OUTPUT" - ) - case "$GITHUB_EVENT_NAME" in - workflow_dispatch) - ;; - push) - if [[ ! "$BEFORE_SHA" =~ ^[a-f0-9]{40}$ || \ - "$BEFORE_SHA" == 0000000000000000000000000000000000000000 ]]; then - echo "::error::Push event does not identify a previous commit" - exit 1 - fi - if ! git cat-file -e "$BEFORE_SHA^{commit}"; then - echo "::error::Previous push commit $BEFORE_SHA is unavailable" - exit 1 - fi - if ! git merge-base --is-ancestor "$BEFORE_SHA" "$default_head"; then - echo "::error::Previous push commit $BEFORE_SHA is not an ancestor of current $DEFAULT_BRANCH" - exit 1 - fi - previous_manifest="$RUNNER_TEMP/kb-previous-package.json" - git show "$BEFORE_SHA:package.json" > "$previous_manifest" - selection_args+=(--previous-manifest "$previous_manifest") - ;; - *) - echo "::error::Unsupported npm staging event $GITHUB_EVENT_NAME" - exit 1 - ;; - esac - bun run ./scripts/npm-stage-selection.ts "${selection_args[@]}" + package_name="$(node -p 'require("./package.json").name')" + package_version="$(node -p 'require("./package.json").version')" + if [[ "$package_name" != "@hraness/kb" || \ + ! "$package_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.(0|[1-9][0-9]*))?$ || \ + "$GITHUB_REF" != "refs/tags/v$package_version" || \ + "$GITHUB_REF_NAME" != "v$package_version" ]]; then + echo "::error::Publication ref does not exactly match a publishable @hraness/kb version" + exit 1 + fi + release_ref="refs/npm-publish-tags/$GITHUB_REF_NAME" + git fetch --no-tags origin "refs/tags/$GITHUB_REF_NAME:$release_ref" + if [[ "$(git cat-file -t "$release_ref")" != tag || \ + "$(git rev-parse "$release_ref^{commit}")" != "$GITHUB_SHA" ]]; then + echo "::error::Publication requires the exact annotated release tag" + exit 1 + fi + publish_tag=latest + if [[ "$package_version" == *-beta.* ]]; then publish_tag=beta; fi + printf 'current_version=%s\npublish_tag=%s\nreason=owner-tag\nshould_publish=true\n' \ + "$package_version" "$publish_tag" >> "$GITHUB_OUTPUT" verify: name: Verify exact package needs: select - if: needs.select.outputs.should_stage == 'true' + if: needs.select.outputs.should_publish == 'true' permissions: contents: read runs-on: ubuntu-latest @@ -99,6 +119,7 @@ jobs: outputs: artifact_name: ${{ steps.artifact.outputs.artifact_name }} package_version: ${{ steps.artifact.outputs.package_version }} + publish_tag: ${{ needs.select.outputs.publish_tag }} source_sha: ${{ steps.identity.outputs.source_sha }} tarball_name: ${{ steps.artifact.outputs.tarball_name }} steps: @@ -128,9 +149,8 @@ jobs: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail - expected_ref="refs/heads/$DEFAULT_BRANCH" - if [[ "$GITHUB_REF" != "$expected_ref" ]]; then - echo "::error::Run this workflow from $expected_ref, received $GITHUB_REF" + if [[ ! "$GITHUB_REF" =~ ^refs/tags/v ]]; then + echo "::error::Run this workflow from its owner-created release tag, received $GITHUB_REF" exit 1 fi git fetch --no-tags origin \ @@ -142,7 +162,7 @@ jobs: exit 1 fi printf 'source_sha=%s\n' "$default_head" >> "$GITHUB_OUTPUT" - - name: Verify package can be staged + - name: Verify package can be published run: | set -euo pipefail package_name="$(node -p 'require("./package.json").name')" @@ -151,28 +171,18 @@ jobs: echo "::error::Unexpected package name $package_name" exit 1 fi - if [[ ! "$package_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "::error::Package version $package_version is not stable semantic version" + if [[ ! "$package_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.(0|[1-9][0-9]*))?$ ]]; then + echo "::error::Package version $package_version is not stable SemVer or a beta. prerelease" exit 1 fi - if git rev-parse --verify --quiet "refs/tags/v$package_version" >/dev/null; then - echo "::error::Create v$package_version only after exact npm delivery is verified" + if [[ "$GITHUB_REF" != "refs/tags/v$package_version" || \ + "$GITHUB_REF_NAME" != "v$package_version" ]]; then + echo "::error::Owner-created tag does not match $package_name@$package_version" exit 1 fi if ! npm view "$package_name" name --json \ --registry=https://registry.npmjs.org >/dev/null; then - echo "::error::$package_name must be bootstrapped interactively before staged publishing can run" - exit 1 - fi - version_error="$RUNNER_TEMP/npm-version-error.txt" - if npm view "$package_name@$package_version" version --json \ - --registry=https://registry.npmjs.org >/dev/null 2>"$version_error"; then - echo "::error::$package_name@$package_version is already public" - exit 1 - fi - if ! grep -q 'E404' "$version_error"; then - cat "$version_error" - echo "::error::Could not prove that $package_name@$package_version is unpublished" + echo "::error::$package_name must exist before trusted publishing can run" exit 1 fi - run: bun install --frozen-lockfile --ignore-scripts @@ -233,8 +243,8 @@ jobs: compression-level: 0 retention-days: 30 - stage: - name: Stage exact package + publish: + name: Publish exact package needs: verify environment: npm-stage permissions: @@ -263,7 +273,7 @@ jobs: run: | set -euo pipefail if [[ ! "$EXPECTED_SOURCE_SHA" =~ ^[a-f0-9]{40}$ || \ - ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.(0|[1-9][0-9]*))?$ ]]; then echo "::error::Verified package identity is invalid" exit 1 fi @@ -276,7 +286,7 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ needs.verify.outputs.artifact_name }} - path: ${{ runner.temp }}/kb-npm-stage + path: ${{ runner.temp }}/kb-npm-publish - name: Rebind downloaded package id: artifact env: @@ -285,15 +295,15 @@ jobs: EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }} run: | set -euo pipefail - artifact_directory="$RUNNER_TEMP/kb-npm-stage" + artifact_directory="$RUNNER_TEMP/kb-npm-publish" metadata="$artifact_directory/npm-pack.json" digest="$artifact_directory/npm-package.sha256" if [[ ! "$EXPECTED_SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]; then echo "::error::Verified source commit is invalid" exit 1 fi - if [[ ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "::error::Verified package version is not stable semantic version" + if [[ ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.(0|[1-9][0-9]*))?$ ]]; then + echo "::error::Verified package version is not stable SemVer or a beta. prerelease" exit 1 fi expected_tarball_name="hraness-kb-$EXPECTED_VERSION.tgz" @@ -395,7 +405,7 @@ jobs: || packageRecord.id !== `${expectedName}@${expectedVersion}` || packageRecord.version !== expectedVersion || packageRecord.filename !== expectedFilename - || !/^hraness-kb-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.tgz$/u.test(packageRecord.filename) + || !/^hraness-kb-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta\.(0|[1-9][0-9]*))?\.tgz$/u.test(packageRecord.filename) ) { throw new Error("npm-pack.json has the wrong package identity or filename"); } @@ -490,7 +500,7 @@ jobs: >> "$GITHUB_OUTPUT" printf 'tarball=%s\nmetadata=%s\ndigest=%s\n' \ "$tarball" "$metadata" "$digest" >> "$GITHUB_OUTPUT" - - name: Revalidate current main and stage exact package + - name: Revalidate current main, publish, and verify registry readback env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} DIGEST: ${{ steps.artifact.outputs.digest }} @@ -500,40 +510,46 @@ jobs: EXPECTED_SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }} METADATA: ${{ steps.artifact.outputs.metadata }} + PUBLISH_TAG: ${{ needs.verify.outputs.publish_tag }} TARBALL: ${{ steps.artifact.outputs.tarball }} run: | set -euo pipefail git check-ref-format "refs/heads/$DEFAULT_BRANCH" - if [[ ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "::error::Verified package version is not stable semantic version" + if [[ ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.(0|[1-9][0-9]*))?$ ]]; then + echo "::error::Verified package version is not stable SemVer or a beta. prerelease" + exit 1 + fi + expected_publish_tag=latest + if [[ "$EXPECTED_VERSION" == *-beta.* ]]; then + expected_publish_tag=beta + fi + if [[ "$PUBLISH_TAG" != "$expected_publish_tag" ]]; then + echo "::error::Verified package channel $PUBLISH_TAG does not match $EXPECTED_VERSION" exit 1 fi release_tag="v$EXPECTED_VERSION" - git check-ref-format "refs/tags/$release_tag" + release_ref="refs/tags/$release_tag" + local_release_ref="refs/owner-release-tags/$release_tag" + git check-ref-format "$release_ref" current_main="$RUNNER_TEMP/kb-current-main.git" git init --quiet --bare "$current_main" git --git-dir="$current_main" fetch --quiet --no-tags --depth=1 \ "https://github.com/$GITHUB_REPOSITORY.git" \ "refs/heads/$DEFAULT_BRANCH" current_default_sha="$(git --git-dir="$current_main" rev-parse FETCH_HEAD)" - if [[ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" || \ + if [[ "$GITHUB_REF" != "$release_ref" || \ "$GITHUB_SHA" != "$EXPECTED_SOURCE_SHA" || \ "$GITHUB_SHA" != "$current_default_sha" ]]; then echo "::error::$DEFAULT_BRANCH advanced to $current_default_sha after artifact verification" exit 1 fi - tag_lookup_output="$RUNNER_TEMP/kb-stage-tag-lookup.txt" - if git ls-remote --exit-code --refs \ + git --git-dir="$current_main" fetch --quiet --no-tags \ "https://github.com/$GITHUB_REPOSITORY.git" \ - "refs/tags/$release_tag" > "$tag_lookup_output"; then - echo "::error::Tag $release_tag was created after package verification" + "$release_ref:$local_release_ref" + release_commit="$(git --git-dir="$current_main" rev-parse "$local_release_ref^{commit}")" + if [[ "$release_commit" != "$EXPECTED_SOURCE_SHA" ]]; then + echo "::error::Owner-created release tag identifies $release_commit instead of $EXPECTED_SOURCE_SHA" exit 1 - else - tag_lookup_status=$? - if [[ "$tag_lookup_status" -ne 2 || -s "$tag_lookup_output" ]]; then - echo "::error::Could not prove that tag $release_tag is still absent from origin" - exit 1 - fi fi current_archive_sha256="$(sha256sum "$TARBALL" | cut -d ' ' -f 1)" current_metadata_sha256="$(sha256sum "$METADATA" | cut -d ' ' -f 1)" @@ -544,8 +560,80 @@ jobs: echo "::error::Downloaded package changed after current-main verification" exit 1 fi - npm stage publish "$TARBALL" \ - --access public \ - --ignore-scripts \ - --provenance \ - --registry=https://registry.npmjs.org + package_spec="@hraness/kb@$EXPECTED_VERSION" + registry_dist="$RUNNER_TEMP/kb-published-dist.json" + registry_tag="$RUNNER_TEMP/kb-published-tag.json" + version_error="$RUNNER_TEMP/kb-published-version-error.txt" + if npm view "$package_spec" version --json \ + --registry=https://registry.npmjs.org >/dev/null 2>"$version_error"; then + echo "::notice::$package_spec is already public; verifying exact idempotent readback" + else + version_status=$? + if [[ "$version_status" -ne 1 ]] || ! grep -q E404 "$version_error"; then + cat "$version_error" + echo "::error::Could not determine whether $package_spec is already public" + exit 1 + fi + npm publish "$TARBALL" \ + --access public \ + --ignore-scripts \ + --provenance \ + --tag "$PUBLISH_TAG" \ + --registry=https://registry.npmjs.org + fi + readback_verified=false + for attempt in {1..12}; do + if npm view "$package_spec" dist --json \ + --registry=https://registry.npmjs.org > "$registry_dist" \ + && npm view "@hraness/kb" "dist-tags.$PUBLISH_TAG" --json \ + --registry=https://registry.npmjs.org > "$registry_tag" \ + && EXPECTED_VERSION="$EXPECTED_VERSION" \ + METADATA="$METADATA" \ + PUBLISH_TAG="$PUBLISH_TAG" \ + REGISTRY_DIST="$registry_dist" \ + REGISTRY_TAG="$registry_tag" node <<'NODE' + const { readFileSync } = require("node:fs"); + const expectedVersion = process.env.EXPECTED_VERSION; + const metadataPath = process.env.METADATA; + const publishTag = process.env.PUBLISH_TAG; + const registryDistPath = process.env.REGISTRY_DIST; + const registryTagPath = process.env.REGISTRY_TAG; + if (!expectedVersion || !metadataPath || !publishTag || !registryDistPath || !registryTagPath) { + throw new Error("Registry readback environment is incomplete"); + } + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + const expected = Array.isArray(metadata) ? metadata[0] : undefined; + const dist = JSON.parse(readFileSync(registryDistPath, "utf8")); + const observedTag = JSON.parse(readFileSync(registryTagPath, "utf8")); + if (!expected || typeof expected !== "object" || !dist || typeof dist !== "object") { + throw new Error("Registry readback metadata is invalid"); + } + if ( + dist.integrity !== expected.integrity + || dist.shasum !== expected.shasum + || dist.fileCount !== expected.entryCount + || dist.unpackedSize !== expected.unpackedSize + ) throw new Error("Registry package differs from the reviewed artifact"); + const expectedTarball = `https://registry.npmjs.org/@hraness/kb/-/kb-${expectedVersion}.tgz`; + if (dist.tarball !== expectedTarball || observedTag !== expectedVersion) { + throw new Error(`Registry ${publishTag} channel does not identify the reviewed package`); + } + if ( + !dist.attestations + || typeof dist.attestations.url !== "string" + || !dist.attestations.url.startsWith("https://registry.npmjs.org/-/npm/v1/attestations/") + || dist.attestations.provenance?.predicateType !== "https://slsa.dev/provenance/v1" + || !Array.isArray(dist.signatures) + || dist.signatures.length < 1 + ) throw new Error("Registry readback is missing signatures or SLSA provenance"); + NODE + then + readback_verified=true + break + fi + if [[ "$attempt" -lt 12 ]]; then sleep 5; fi + done + if [[ "$readback_verified" != true ]]; then + echo "::error::Could not verify exact npm registry publication, channel, signatures, and provenance" + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c5ca82..38f02a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,12 +4,7 @@ on: push: tags: - "v*" - workflow_dispatch: - inputs: - tag: - description: Existing stable tag to recover after npm delivery succeeded - required: true - type: string + - "!v*-beta.*" permissions: contents: read @@ -19,15 +14,53 @@ concurrency: cancel-in-progress: false jobs: + authorize: + name: Authorize owner release tag + permissions: {} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Verify immutable owner and public repository identity + env: + EXPECTED_ACTOR_ID: "894119" + EXPECTED_REPOSITORY: "hraness/kb" + EXPECTED_REPOSITORY_ID: "1308971873" + REF_PROTECTED: ${{ github.ref_protected }} + run: | + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" != push || \ + "$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID" || \ + "$GITHUB_REPOSITORY" != "$EXPECTED_REPOSITORY" || \ + "$GITHUB_REPOSITORY_ID" != "$EXPECTED_REPOSITORY_ID" || \ + "$REF_PROTECTED" != true ]]; then + echo "::error::Release requires an owner-created protected tag in the exact public repository" + exit 1 + fi + EXPECTED_ACTOR_ID="$EXPECTED_ACTOR_ID" \ + EXPECTED_REPOSITORY="$EXPECTED_REPOSITORY" \ + EXPECTED_REPOSITORY_ID="$EXPECTED_REPOSITORY_ID" node <<'NODE' + const { readFileSync } = require("node:fs"); + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + if ( + event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID) + || event.sender?.type !== "User" + || event.repository?.id !== Number(process.env.EXPECTED_REPOSITORY_ID) + || event.repository?.full_name !== process.env.EXPECTED_REPOSITORY + || event.repository?.visibility !== "public" + || event.repository?.private !== false + || event.repository?.default_branch !== "main" + ) throw new Error("Tag-push sender or public repository identity is not the immutable release authority"); + NODE + verify: name: Verify + needs: authorize permissions: contents: read runs-on: ubuntu-latest timeout-minutes: 45 outputs: default_branch: ${{ steps.identity.outputs.default_branch }} - event_mode: ${{ steps.identity.outputs.event_mode }} source_sha: ${{ steps.identity.outputs.source_sha }} verified_tag: ${{ steps.identity.outputs.tag }} workflow_sha: ${{ steps.identity.outputs.workflow_sha }} @@ -56,7 +89,7 @@ jobs: id: identity env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - RECOVERY_TAG: ${{ inputs.tag }} + REF_PROTECTED: ${{ github.ref_protected }} run: | set -euo pipefail git check-ref-format "refs/heads/$DEFAULT_BRANCH" @@ -65,30 +98,13 @@ jobs: default_head="$(git rev-parse "origin/$DEFAULT_BRANCH")" checked_out_head="$(git rev-parse HEAD)" - case "$GITHUB_EVENT_NAME" in - push) - event_mode="push-tag" - release_tag="$GITHUB_REF_NAME" - if [[ "$GITHUB_REF" != "refs/tags/$release_tag" ]]; then - echo "::error::Release push is not an exact tag ref" - exit 1 - fi - ;; - workflow_dispatch) - event_mode="recovery" - release_tag="$RECOVERY_TAG" - if [[ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" || \ - "$GITHUB_SHA" != "$default_head" || \ - "$checked_out_head" != "$default_head" ]]; then - echo "::error::Recovery must run from current $DEFAULT_BRANCH head $default_head" - exit 1 - fi - ;; - *) - echo "::error::Unsupported release event $GITHUB_EVENT_NAME" - exit 1 - ;; - esac + release_tag="$GITHUB_REF_NAME" + if [[ "$GITHUB_EVENT_NAME" != push || \ + "$GITHUB_REF" != "refs/tags/$release_tag" || \ + "$REF_PROTECTED" != true ]]; then + echo "::error::Release requires a protected owner-created stable tag" + exit 1 + fi if [[ ! "$release_tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "::error::Release tag $release_tag is not a stable semantic version" @@ -97,14 +113,18 @@ jobs: git check-ref-format "refs/tags/$release_tag" release_ref="refs/kb-release-tags/$release_tag" git fetch --no-tags origin "refs/tags/$release_tag:$release_ref" + if [[ "$(git cat-file -t "$release_ref")" != tag ]]; then + echo "::error::Release tag must be annotated" + exit 1 + fi tag_commit="$(git rev-parse "$release_ref^{commit}")" if [[ ! "$tag_commit" =~ ^[a-f0-9]{40}$ ]]; then echo "::error::Release tag did not resolve to one commit" exit 1 fi - if [[ "$event_mode" == push-tag && \ - ( "$GITHUB_SHA" != "$tag_commit" || "$checked_out_head" != "$tag_commit" ) ]]; then - echo "::error::Tag event does not match the checked release commit $tag_commit" + if [[ "$GITHUB_SHA" != "$tag_commit" || \ + "$checked_out_head" != "$tag_commit" ]]; then + echo "::error::Tag does not match the checked release commit $tag_commit" exit 1 fi if ! git merge-base --is-ancestor "$tag_commit" "$default_head"; then @@ -148,8 +168,8 @@ jobs: exit 1 fi - printf 'default_branch=%s\nevent_mode=%s\nsource_sha=%s\ntag=%s\nworkflow_sha=%s\n' \ - "$DEFAULT_BRANCH" "$event_mode" "$tag_commit" "$release_tag" "$checked_out_head" \ + printf 'default_branch=%s\nsource_sha=%s\ntag=%s\nworkflow_sha=%s\n' \ + "$DEFAULT_BRANCH" "$tag_commit" "$release_tag" "$checked_out_head" \ >> "$GITHUB_OUTPUT" - name: Materialize exact tagged source id: source @@ -213,6 +233,21 @@ jobs: registry_pack_json="$registry_directory/npm-pack.json" registry_view_json="$registry_directory/npm-view.json" + registry_ready=false + for registry_poll in {1..60}; do + published_version="$(npm view "$package_spec" version --json \ + --registry=https://registry.npmjs.org 2>/dev/null || true)" + if [[ "$published_version" == "\"$package_version\"" ]]; then + registry_ready=true + break + fi + sleep 10 + done + if [[ "$registry_ready" != true ]]; then + echo "::error::$package_spec did not become readable from npm in time" + exit 1 + fi + if [[ "$(git -C "$GITHUB_WORKSPACE" rev-parse HEAD)" != "$WORKFLOW_SHA" ]]; then echo "::error::Reviewed workflow checkout changed before package verification" exit 1 @@ -280,7 +315,6 @@ jobs: timeout-minutes: 5 env: DEFAULT_BRANCH: ${{ needs.verify.outputs.default_branch }} - EVENT_MODE: ${{ needs.verify.outputs.event_mode }} GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} VERIFIED_SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} @@ -296,28 +330,13 @@ jobs: echo "::error::Verified release identity is incomplete" exit 1 fi - case "$EVENT_MODE" in - push-tag) - if [[ "$GITHUB_EVENT_NAME" != push || \ - "$GITHUB_REF" != "refs/tags/$VERIFIED_TAG" || \ - "$GITHUB_SHA" != "$VERIFIED_SOURCE_SHA" ]]; then - echo "::error::Tag release event changed after verification" - exit 1 - fi - ;; - recovery) - if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch || \ - "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" || \ - "$GITHUB_SHA" != "$WORKFLOW_SHA" ]]; then - echo "::error::Recovery event changed after verification" - exit 1 - fi - ;; - *) - echo "::error::Verified release event mode is invalid" - exit 1 - ;; - esac + if [[ "$GITHUB_EVENT_NAME" != push || \ + "$GITHUB_REF" != "refs/tags/$VERIFIED_TAG" || \ + "$GITHUB_SHA" != "$VERIFIED_SOURCE_SHA" || \ + "$GITHUB_SHA" != "$WORKFLOW_SHA" ]]; then + echo "::error::Protected tag release event changed after verification" + exit 1 + fi current_tag_sha="$(gh api "/repos/$GITHUB_REPOSITORY/commits/$VERIFIED_TAG" --jq '.sha')" current_default_sha="$(gh api "/repos/$GITHUB_REPOSITORY/commits/$DEFAULT_BRANCH" --jq '.sha')" @@ -332,11 +351,6 @@ jobs: echo "::error::Tag $VERIFIED_TAG is no longer reachable from $DEFAULT_BRANCH" exit 1 fi - if [[ "$EVENT_MODE" == recovery && "$current_default_sha" != "$WORKFLOW_SHA" ]]; then - echo "::error::$DEFAULT_BRANCH advanced to $current_default_sha during recovery verification" - exit 1 - fi - repository_tags="$(gh api --paginate \ "/repos/$GITHUB_REPOSITORY/tags?per_page=100" \ --jq '.[].name')" diff --git a/AGENTS.md b/AGENTS.md index 2554541..5cdb64d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ - `kb/` – this source repository's authored rationale, maintained synthesis, and implementation plans; it is separate from the package's graph implementation and fixtures. - `WRITING.md` and `STYLE.md` – internal and public prose contracts. - `docs/` – design, capture, and agent-workflow documentation. -- `.github/workflows/` – read-only branch validation, stable-version-triggered and recovery-dispatched stage-only npm publication, and checks-gated immutable GitHub Release automation. +- `.github/workflows/` – read-only branch validation, stable-or-beta direct OIDC npm publication, and checks-gated immutable GitHub Release automation. - `portfolio-inventory.json`, `scripts/check-portfolio-inventory.ts`, and `scripts/check-installed-command-docs.ts` – canonical public package inventory and standalone public-command consistency gates. - `README.md`, `CONTRIBUTING.md`, `SECURITY.md`, and `LICENSE` – public usage, project policy, threat model, and terms. - `package.json`, `tsconfig.json`, and `bun.lock` – standalone package and frozen verification configuration. @@ -51,10 +51,13 @@ - Keep `portfolio-inventory.json` byte-canonical and consistent with the public package identity, version, repository, direct `@hraness/*` dependency edges, and Hraness-owned dependencies pinned by exact immutable GitHub specifiers. - Pair concrete behavior tests with property tests for parsing, resolution, ordering, path confinement, and round-trip laws. - Run `bun test src/benchmark.test.ts src/evaluation.test.ts src/evaluation-kb.test.ts src/search.test.ts src/sdk.test.ts` when changing rank fusion, retrieval defaults, frozen-corpus execution, or built-in evaluation adapters. The six-case synthetic rank-fusion fixture is a deterministic regression, not a retrieval-quality or performance benchmark. Keep real-corpus manifests versioned, judgments independent of rankings, raw lane evidence intact, and performance claims tied to named hardware and measured runs. Run `bun run check` before handing off a change; it must leave committed `dist/` and `bun.lock` unchanged. -- Follow `docs/publishing.md` for the interactive npm bootstrap and later stage-only trusted publishing. A `package.json` push to `main` may continue only when the exact prior and current manifests prove a strictly increasing stable package version; an unchanged version must stop successfully before verification or OIDC, and `workflow_dispatch` remains the current-`main` recovery path. Keep source checkout, install, build, test, pack, and artifact upload in a read-only job; only its minimal dependent staging job may request OIDC, and that job must use the exact `npm-stage` environment, restrict deployments to `main` without required deployment reviewers, and rebind the exact three-file artifact and current `main` before the canonical-registry stage-only mutation. Bind npm's trusted publisher to that exact environment. Disallow traditional publishing tokens, inspect the staged tarball, and approve its promotion with human 2FA. Preserve `contentPolicy.class=dual-use` and the root `DISCLOSURE` in every published version. -- Treat a `v*` tag as a release request, not a completed release. Publish the exact npm version first. Before tagging, confirm repository-level immutable releases are enabled; use a strictly increasing stable package version, keep the tag equal to `v` on `main`, and let the read-only verification job compare the source and registry packages by exact extracted path, type, mode, size, and regular-file hashes before its write-scoped publisher creates the Release. Verify each transport's npm and registry integrity independently because compressed tarball bytes may vary across operating systems. Recover a failed post-tag Release only through the explicit current-`main` workflow dispatch; bind the current workflow helpers to reviewed Git blobs and invoke them by absolute path against the tagged working tree after its explicit check/build, with tag-owned Bun config and environment loading disabled. Keep `npm pack --ignore-scripts` so recovery never depends on or reruns a historical `prepack`. Never move the tag or republish npm. Do not create the next tag until that workflow and Release are verified because GitHub concurrency is not a durable queue. After tagging, verify the matching non-draft immutable Release is Latest. +- Follow `docs/publishing.md` for the historical interactive bootstrap and direct OIDC trusted publishing. After the exact active CI workflow's `push` run and `Required` job succeed on protected current `main`, an agent acting under standing release authorization runs `bun run ./scripts/push-npm-release-tag.ts ` with already-available owner `gh` and Git credentials. The script must keep authentication opaque; bind immutable owner `User` ID `894119`, public repository ID `1308971873`, the exact live `npm-stage` environment with administrator bypass disabled, sole `branch_policy` protection, and sole `v*` tag deployment policy, plus the exact package/current-main/active-CI run and attempt/Required job; enforce bounded monotonic stable or numeric `beta.N` remote-tag inventory; refuse conflicts and inherited local refs; and push only one exact annotated `v` ref. An exact same remote annotated tag and commit is an idempotent no-op. Missing authentication or ambiguous evidence fails closed. +- Use two exact active rulesets matching `refs/tags/v*`: **Immutable version tags** restricts update and deletion with an empty bypass list, while **Release tag creation** restricts creation and has owner `User` ID `894119` as its sole always-bypass actor. The local tag command must read back both before mutation. Never grant generic GitHub Actions integration ID `15368`, an administrator, a repository role, a team, or another integration this bypass; never combine creation with update/delete or create probe tags. Protected tag-push workflows must bind the actor and event sender to that owner and the exact public repository before checkout, require the annotated tag/package/source identity, keep source verification read-only, publish each unique version directly through the minimal OIDC job with its final initial tag (`latest` or `beta`), verify exact registry content/integrity/channel/signatures/provenance, and never use staged publishing or dist-tag promotion. Stable tags independently wait for npm before the immutable Latest Release; beta tags stop after npm readback. Trust a dedicated Release App only after its isolated credential and exact immutable App ID are configured explicitly. Never move a tag or republish npm, and finish one stable release before requesting the next because workflow concurrency is not a durable queue. +- Treat the user's request to change this repository as standing authorization for routine task-owned commits, pushes, pull requests, merges, releases, deployments, and production verification after the repository's required validation, review, identity, and rollout gates pass. Do not ask for another confirmation at each delivery step. +- Use the repository's documented delivery workflow and preserve every runtime-enforced approval, branch protection, environment rule, safety policy, and final gate. Ask for user input only when delivery needs a material product decision, missing credentials or authority, an irreversibly destructive action outside task scope, or resolution of a release failure that cannot be handled safely and autonomously. +- Prefer short-lived repository workload identities such as OIDC trusted publishing, GitHub Apps, and narrowly scoped machine identities. Do not add long-lived personal tokens, weaken two-factor authentication, or bypass provider controls to eliminate an interactive prompt. Batch unavoidable human-gated production promotions into intentional stable releases while agents publish validated prerelease or beta channels through workload identities when the repository supports them. - Preserve useful reasoning fan-out, but avoid unnecessary checkout fan-out. Prefer subagents in the current task for bounded research, review, diagnosis, and focused checks when they can safely share one working tree; create a separate task or worktree only for independently deliverable divergent edits, an isolated verification tree, or a different execution environment. - Give each expensive focused validation command and external wait one owner. The integration owner reviews that evidence and runs the repository-required aggregate or final gate once after convergence. Reuse evidence only for the exact Git tree, command, lockfiles, toolchain, relevant environment, and validity period, and never to skip a required final integration, merge, release, deployment, or production-verification gate. - On Hraness development machines, use `$hra-local-efficiency` and the installed host scheduler for heavyweight top-level commands when available. Keep ordinary work in the compute lane; give authenticated browser/dev-server/Chromium work one `browser-auth` owner and Mac-only validation one `mac-native` owner. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/publishing.md b/docs/publishing.md index d8d4506..03f411d 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,14 +1,15 @@ # Publish KB -KB uses an interactive first publication and stage-only trusted publishing for -later versions. npm staged publishing cannot create a package name, so the -initial registry write follows a separate path. +KB used an interactive first publication and now uses direct OIDC trusted +publishing for every later version. The interactive bootstrap remains here only +as history; routine beta and stable releases need no maintainer npm session, +one-time password, staging approval, or long-lived publishing token. ## Bootstrap the npm package This section records the one-time `0.17.1` bootstrap. Do not reuse the interactive path for a later release; follow -[Stage a later version](#stage-a-later-version) instead. The bootstrap started +[Publish a later version](#publish-a-later-version) instead. The bootstrap started from the checked `main` commit with Node 24, npm 11.19.0, and Bun 1.3.14. The matching Git tag was created only after the public package was verified. @@ -69,80 +70,161 @@ systems. ## Configure trusted publishing After the first version exists, configure one GitHub Actions trusted publisher -in the npm package settings: +in the npm package settings. If a staged-publishing connection already exists, +delete and recreate it once because npm does not let a trusted publisher's +workflow, environment, or allowed action be edited in place: - organization or owner: `hraness` - repository: `kb` - workflow filename: `npm-stage.yml` -- allowed action: `npm stage publish` only -- environment: `npm-stage` - -Create the `npm-stage` GitHub environment before enabling later publishing. -Restrict deployments to `main` and configure no required deployment reviewers, -so a verified version bump reaches npm staging without a second GitHub +- allowed action: direct `npm publish` (`--allow-publish`) +- environment: `npm-stage` (`--environment npm-stage`) + +Do not grant `--allow-stage` or any staged-publishing permission. Create the +`npm-stage` GitHub environment before enabling later publishing. Disable +administrator bypass and use custom deployment policies rather than +protected-branch admission. Its sole protection rule must be `branch_policy`, +with no required deployment reviewers; its sole deployment policy must be tag +pattern `v*`. A verified release can then reach npm without a second GitHub approval. The npm trusted publisher must name that exact environment. Then require publishing two-factor authentication and disallow traditional tokens. Do not add an npm publishing token to GitHub. Preserve `contentPolicy.class=dual-use` and the root `DISCLOSURE` in every package. -## Stage a later version +GitHub Actions may retain read-and-write workflow permissions for the stable +Release job's narrowly declared `contents: write` permission. No workflow gets +`contents: write` authority to create a tag or `actions: write` authority to +dispatch another workflow. Never give the generic GitHub Actions integration a +release-tag ruleset bypass: repository branch workflows can request a +write-capable `GITHUB_TOKEN`. + +Enable immutable releases in the repository settings before the first stable +OIDC release. The Release workflow requires GitHub's immutable readback; it does +not emulate immutability in workflow code. + +## Publish a later version + +1. Merge one unique, strictly increasing version to `main`. Stable versions use + `M.m.p`; beta versions use `M.m.p-beta.N`, with an increasing numeric `N`. + An agent operating under the repository's standing release authorization + then runs the checked local tag command from a clean current `main` checkout: + + ```sh + bun run ./scripts/push-npm-release-tag.ts + ``` -1. Merge a strictly increasing stable version to `main`. A `package.json` push - that changes the version automatically starts **Stage npm package**. When - `package.json` changes but its version is unchanged, the selector exits - successfully before package verification or OIDC use. + The command uses only already-available `gh` and Git credentials; it never + reads or prints a token. Before its first mutation it requires authenticated + immutable owner `User` ID `894119`, public repository ID `1308971873`, the + exact active **Release tag creation** and **Immutable version tags** rulesets, + and a + live `npm-stage` environment with administrator bypass disabled and only + the `v*` tag deployment policy, a clean exact protected current `main`, + matching package identity, the exact + active `.github/workflows/ci.yml`, its sole successful `push` run for that + commit and exact attempt, and that attempt's successful **Required** job. It + reads a bounded remote-tag inventory twice, enforces monotonic stable or beta + SemVer, refuses conflicting or inherited local refs, creates one annotated + `v` tag, and pushes only that exact ref. If the same annotated remote + tag already identifies the same commit, the command reports idempotent proof + and does nothing. Missing authentication or ambiguous evidence fails closed. 2. Wait for the read-only verification job and inspect the uploaded artifact. It contains exactly the tarball, `npm-pack.json`, and `npm-package.sha256`, bound to the source commit, version, complete inventory, size, integrity, dual-use declaration, and disclosure. -3. The minimal OIDC job starts automatically after verification. Its exact - `npm-stage` environment allows only `main`, and the job revalidates the - artifact and current branch head before it stages the package. -4. Inspect and approve the staged package through npm with two-factor - authentication. -5. Verify the public registry package in a clean consumer. -6. Create and push the matching `v` tag on the same `main` commit. The - tag workflow verifies npm delivery before it creates the immutable GitHub - Release. - -If the automatic run is missing or fails before npm staging completes, -dispatch **Stage npm package** from current `main`. Manual recovery runs the -same verification and main-branch-restricted staging jobs. The workflow rejects -a tag, another branch, or a commit behind the current default-branch head. +3. The protected tag push starts the workflow. Its first job binds the push + actor and event sender to owner `User` ID `894119`, the immutable public + repository ID, and a protected tag before checkout. The minimal OIDC job + starts automatically after verification. Its exact + `npm-stage` environment allows only `v*` tags. The job + revalidates the artifact and current branch head, publishes the reviewed + tarball directly without a maintainer OTP, and polls the registry until + exact integrity, inventory, channel, signature, and SLSA provenance + readback succeeds. + Stable versions publish with `--tag latest`; beta versions publish with + `--tag beta`. Never use `npm dist-tag` promotion: a beta promoted to stable + is a new stable version and a new exact publication. +4. A stable protected tag also starts the Release workflow. It waits for exact + npm readback, independently rechecks the owner-created protected annotated + tag, npm delivery, and source, then creates the immutable GitHub Release. + Beta tags do not start the Release workflow. + +If the local command fails before creating the remote tag, update to current +`main` and rerun it. If a tag-bound workflow later fails transiently, rerun that +exact workflow run; never dispatch it against another ref or move the tag. +Never reuse a published version. If npm accepted the package but registry +readback timed out, verify that exact version and channel instead of rerunning +publication; recover only the later GitHub Release when necessary. The verification job checks out source, installs dependencies without lifecycle scripts, runs the complete gate, creates the three-file artifact, -and smokes the exact tarball. Its dependent staging job is the only job with +and smokes the exact tarball. Its dependent publishing job is the only job with OIDC authority. The exact `npm-stage` environment restricts deployments to -`main` and has no required reviewers, so the job starts automatically after -verification. It checks out no source and runs no repository code. It rebinds +`v*` tags, disables administrator bypass, and has no required reviewers, so the job starts +automatically after verification. It checks out no source and runs no +repository code. It rebinds identity, filename, inventory, count, modes, sizes, SHA-1, SHA-512, and the independent SHA-256 manifest before mutation. Immediately -before staging, +before publication, it fetches current `main` into a new bare Git directory, then rehashes all -three files and invokes only `npm stage publish` against -`https://registry.npmjs.org`. +three files and invokes direct `npm publish` against +`https://registry.npmjs.org`. The registry readback must match the reviewed +artifact and intended `latest` or `beta` channel and must expose npm signatures +and SLSA provenance. + +Release selection is deliberately confined to the checked local tag script and +tag-bound workflow guards. The obsolete staged push/manual selection helper and +its tests were removed rather than retained as a second executable policy that +could drift into an alternate publication path. + +## Protect npm release tags without a sudo prompt + +Create two repository rulesets matching `refs/tags/v*`. Name **Immutable +version tags** restricts updates and deletions with an empty bypass list. Name +**Release tag creation** restricts creation and gives only immutable owner `User` ID +`894119` an always bypass. It grants no update or delete bypass and includes no +administrator, repository-role, team, deploy-key, or integration actor. Never +give GitHub Actions integration ID `15368` this bypass: any same-repository +branch workflow could otherwise mint a release tag. Do not combine the two +rules in one bypassable ruleset, and do not create throwaway or probe tags. +After this one-time ruleset setup, the owner's existing local Git credential can +push the script's exact annotated ref without routine GitHub sudo approval; +neither the credential nor an approval is stored in the repository. Publication +and Release require `github.ref_protected`, the exact owner/event sender and +public repository identity, annotated-tag identity, package version, and source +commit before any provider mutation. The local tag command reads back both exact +active rulesets and refuses any namespace, rule, enforcement, or bypass drift. + +For a larger maintainer group, replace the owner-local boundary with a dedicated +Release GitHub App only after its isolated credential and immutable installed +App ID are configured explicitly in the script/workflow and creation ruleset. +Do not use the generic Actions integration or trust an unconfigured/name-only +App. ## Recover an already-published release -If npm delivery succeeded but the tag-triggered GitHub Release job failed, -keep the tag and npm version immutable. After the recovery workflow is on -current `main`, dispatch it with the existing tag: +If npm delivery succeeded but the GitHub Release job failed, keep the tag and +npm version immutable. Re-run the failed exact Release workflow run. Running +the local command with the same version is also a read-only proof: ```sh -gh workflow run release.yml --ref main -f tag=v0.19.0 +bun run ./scripts/push-npm-release-tag.ts 0.19.0 ``` -The recovery path accepts only the newest stable repository tag. It freshly -resolves that tag from GitHub, requires its commit to remain reachable from -current `main`, reads the name and version from the tagged `package.json`, and +The command idempotently accepts only the same exact owner-created annotated tag +and commit and performs no push. A publication rerun verifies the existing +package and registry channel without republishing it. The Release workflow +freshly resolves the stable repository tag, +requires its commit to remain reachable from current `main`, reads the name and +version from the tagged `package.json`, and checks and builds the tagged source in a detached worktree. That explicit tagged `bun run check` is the only historical build boundary. Afterward, the -workflow rebinds the release helpers to their reviewed Git blobs in the current -workflow checkout and invokes those files by absolute path while retaining the -tagged tree as the package working directory. Bun loads no tag-owned config or -environment file. The package step uses `npm pack --ignore-scripts`, so it does +workflow rebinds the release helpers to their reviewed Git blobs in the +tag-bound workflow checkout and invokes those files by absolute path while +retaining the detached tree as the package working directory. Bun loads no +working-tree config or environment file. The package step uses +`npm pack --ignore-scripts`, so it does not run the tag's `prepack` or another historical lifecycle script. The current helpers import their current core-only archive inspector. They do not import a script from the tagged tree. They compare the rebuilt package with the public @@ -151,6 +233,6 @@ job creates the missing immutable Release. Recovery never moves the tag or republishes npm. See npm's documentation for [trusted -publishing](https://docs.npmjs.com/trusted-publishers/), [staged -publishing](https://docs.npmjs.com/staged-publishing/), and [dual-use +publishing](https://docs.npmjs.com/trusted-publishers/), [package +provenance](https://docs.npmjs.com/viewing-package-provenance/), and [dual-use content](https://docs.npmjs.com/policies/dual-use/). diff --git a/scripts/check-workflow-yaml.test.ts b/scripts/check-workflow-yaml.test.ts index 3a447d6..c7a4be9 100644 --- a/scripts/check-workflow-yaml.test.ts +++ b/scripts/check-workflow-yaml.test.ts @@ -4,9 +4,22 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { - validateNpmStageWorkflow, + validateNpmPublishWorkflow, + validateOwnerTagWorkflow, validateWorkflowYaml, } from "./check-workflow-yaml.ts"; +import { + admitActiveCiWorkflow, + admitCiRequiredJob, + admitCiRun, + admitOwner, + admitReleaseEnvironment, + admitReleaseRulesets, + admitRemoteReleaseTags, + admitRemoteRoutes, + admitRepository, + parseReleaseVersion, +} from "./push-npm-release-tag.ts"; describe("GitHub workflow YAML", () => { test("accepts commands with YAML-significant text inside block scalars", () => { @@ -39,36 +52,36 @@ jobs: test("requires a fresh default-branch HEAD guard at the final publication boundary", async () => { const path = resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"); const source = await readFile(path, "utf8"); - const finalGuard = 'git --git-dir="$current_main" fetch'; + const finalGuard = 'git --git-dir="$current_main" fetch --quiet --no-tags --depth=1'; const finalGuardIndex = source.lastIndexOf(finalGuard); expect(finalGuardIndex).toBeGreaterThan(-1); const missingFinalGuard = source.slice(0, finalGuardIndex) + "git status --short" + source.slice(finalGuardIndex + finalGuard.length); - expect(() => validateNpmStageWorkflow(source, "npm-stage.yml")).not.toThrow(); - expect(() => validateNpmStageWorkflow( + expect(() => validateNpmPublishWorkflow(source, "npm-stage.yml")).not.toThrow(); + expect(() => validateNpmPublishWorkflow( missingFinalGuard, "npm-stage.yml", )).toThrow("must recheck current default-branch HEAD"); }); - test("keeps npm staging version-selected, environment-bound, tokenless, artifact-bound, and stage-only", async () => { + test("keeps npm publishing version-selected, environment-bound, tokenless, and artifact-bound", async () => { const path = resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"); const source = await readFile(path, "utf8"); for (const required of [ - "push:", - "branches: [main]", - 'paths:\n - "package.json"', - "workflow_dispatch:", - "name: Select stable package version", - "github.event.before", - "git merge-base --is-ancestor", - 'git show "$BEFORE_SHA:package.json"', - "scripts/npm-stage-selection.ts", + 'tags:\n - "v*"', + "name: Authorize owner release tag", + 'EXPECTED_ACTOR_ID: "894119"', + 'EXPECTED_REPOSITORY_ID: "1308971873"', + "GITHUB_ACTOR_ID", + 'event.sender?.type !== "User"', + 'event.repository?.visibility !== "public"', + "name: Select publishable package version", + "github.ref_protected", "needs: select", - "if: needs.select.outputs.should_stage == 'true'", + "if: needs.select.outputs.should_publish == 'true'", "contents: read", "environment: npm-stage", "id-token: write", @@ -85,7 +98,9 @@ jobs: "npm-package.sha256", "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", "git init --quiet --bare \"$current_main\"", - "npm stage publish \"$TARBALL\"", + "npm publish \"$TARBALL\"", + "--tag \"$PUBLISH_TAG\"", + "dist.attestations.provenance?.predicateType", "--registry=https://registry.npmjs.org", ] as const) { expect(source).toContain(required); @@ -93,28 +108,184 @@ jobs: expect(source).not.toContain("secrets.NPM_TOKEN"); expect(source).not.toContain("NODE_AUTH_TOKEN"); - expect(source).not.toMatch(/\bnpm publish\b/u); + expect(source).not.toMatch(/\bnpm\s+(?:stage|dist-tag)\b/u); + expect(source).not.toContain("workflow_dispatch:"); + expect(source).not.toContain("actions: write"); + expect(source).not.toContain("authorization_run_id"); expect(source.match(/id-token: write/gu) ?? []).toHaveLength(1); - const stage = source.slice(source.indexOf("\n stage:\n")); - expect(stage).not.toContain("actions/checkout@"); - expect(stage).not.toContain("setup-bun@"); - expect(stage).not.toContain("./scripts/"); + const publish = source.slice(source.indexOf("\n publish:\n")); + expect(publish).not.toContain("actions/checkout@"); + expect(publish).not.toContain("setup-bun@"); + expect(publish).not.toContain("./scripts/"); }); - test("requires the exact environment and fail-closed version selector", async () => { + test("requires the exact environment and fail-closed owner identity", async () => { const path = resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"); const source = await readFile(path, "utf8"); - expect(() => validateNpmStageWorkflow( + expect(() => validateNpmPublishWorkflow( source.replace("environment: npm-stage", "environment: unprotected"), "npm-stage.yml", )).toThrow("exact npm-stage environment"); - expect(() => validateNpmStageWorkflow( + expect(() => validateNpmPublishWorkflow( + source.replace( + '"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"', + '"$GITHUB_ACTOR_ID" == "$EXPECTED_ACTOR_ID"', + ), + "npm-stage.yml", + )).toThrow("owner authorization is missing"); + expect(() => validateNpmPublishWorkflow( source.replace( - 'git show "$BEFORE_SHA:package.json"', - 'cp package.json "$previous_manifest"', + "event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID)", + "event.sender?.id !== 894120", ), "npm-stage.yml", - )).toThrow("package-version selection is missing"); + )).toThrow("owner authorization is missing"); + expect(() => validateNpmPublishWorkflow( + source.replace( + 'event.sender?.type !== "User"', + 'event.sender?.type !== "Bot"', + ), + "npm-stage.yml", + )).toThrow("owner authorization is missing"); + expect(() => validateNpmPublishWorkflow( + source.replace('event.repository?.visibility !== "public"', 'event.repository?.visibility !== "private"'), + "npm-stage.yml", + )).toThrow("owner authorization is missing"); + expect(() => validateNpmPublishWorkflow( + source.replace("needs: authorize", "needs: untrusted"), + "npm-stage.yml", + )).toThrow("select must follow owner authorization"); + }); + + test("requires exact owner actor and sender guards before release checkout", async () => { + const source = await readFile(resolve(import.meta.dir, "../.github/workflows/release.yml"), "utf8"); + expect(() => validateOwnerTagWorkflow(source, "release.yml")).not.toThrow(); + expect(() => validateOwnerTagWorkflow( + source.replace( + '"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"', + '"$GITHUB_ACTOR_ID" == "$EXPECTED_ACTOR_ID"', + ), + "release.yml", + )).toThrow("owner authorization is missing"); + expect(() => validateOwnerTagWorkflow( + source.replace( + "event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID)", + "event.sender?.id !== 894120", + ), + "release.yml", + )).toThrow("owner authorization is missing"); + }); + + test("keeps local release-tag creation owner-scoped, CI-gated, monotonic, and exact-ref only", async () => { + const source = await readFile(resolve(import.meta.dir, "push-npm-release-tag.ts"), "utf8"); + for (const required of [ + "PROCESS_TIMEOUT_MS = 30_000", + "MAXIMUM_OUTPUT_BYTES = 1024 * 1024", + 'GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0"', + '["git", "remote", "get-url", "--push", "--all", "origin"]', + 'admitOwner(await jsonCommand(["gh", "api", "user"]', + "admitProtectedBranch(", + "admitActiveCiWorkflow(", + "admitCiRun(runInventory", + "admitCiRequiredJob(jobs", + "admitReleaseEnvironment(", + "admitReleaseRulesets(rulesetList, rulesetDetails)", + "repos/${EXPECTED_REPOSITORY}/rulesets", + "/deployment-branch-policies", + "const secondAdmission = admitRemoteReleaseTags", + "refusing an inherited tag object", + '["git", "tag", "--annotate"', + '`refs/tags/${release.tag}:refs/tags/${release.tag}`', + '["git", "update-ref", "-d", `refs/tags/${release.tag}`, createdTagObject]', + ]) expect(source).toContain(required); + const ciIndex = source.indexOf("const jobId = admitCiRequiredJob"); + const environmentIndex = source.indexOf(" admitReleaseEnvironment(\n await jsonCommand("); + const rulesetIndex = source.indexOf(" admitReleaseRulesets(rulesetList, rulesetDetails)"); + const monotonicIndex = source.indexOf("const secondAdmission = admitRemoteReleaseTags"); + const mutationIndex = source.indexOf('["git", "tag", "--annotate"'); + const pushIndex = source.indexOf('`refs/tags/${release.tag}:refs/tags/${release.tag}`'); + const compareDeleteIndex = source.indexOf('["git", "update-ref", "-d", `refs/tags/${release.tag}`, createdTagObject]'); + expect(ciIndex).toBeGreaterThan(-1); + expect(environmentIndex).toBeGreaterThan(-1); + expect(rulesetIndex).toBeGreaterThan(-1); + expect(monotonicIndex).toBeGreaterThan(ciIndex); + expect(mutationIndex).toBeGreaterThan(monotonicIndex); + expect(mutationIndex).toBeGreaterThan(environmentIndex); + expect(mutationIndex).toBeGreaterThan(rulesetIndex); + expect(pushIndex).toBeGreaterThan(mutationIndex); + expect(compareDeleteIndex).toBeGreaterThan(pushIndex); + + const sha = "a".repeat(40); + const otherSha = "b".repeat(40); + admitOwner({ id: 894119, type: "User" }); + admitRepository({ archived: false, default_branch: "main", disabled: false, full_name: "hraness/kb", id: 1308971873, private: false, visibility: "public" }); + const releaseEnvironment = { + can_admins_bypass: false, + deployment_branch_policy: { custom_branch_policies: true, protected_branches: false }, + name: "npm-stage", + protection_rules: [{ type: "branch_policy" }], + }; + const releasePolicies = { branch_policies: [{ name: "v*", type: "tag" }], total_count: 1 }; + expect(() => admitReleaseEnvironment(releaseEnvironment, releasePolicies)).not.toThrow(); + expect(() => admitReleaseEnvironment({ ...releaseEnvironment, can_admins_bypass: true }, releasePolicies)).toThrow("administrator bypass"); + expect(() => admitReleaseEnvironment({ + ...releaseEnvironment, + deployment_branch_policy: { custom_branch_policies: false, protected_branches: true }, + }, releasePolicies)).toThrow("branch_policy"); + expect(() => admitReleaseEnvironment({ + ...releaseEnvironment, + protection_rules: [{ type: "branch_policy" }, { type: "required_reviewers" }], + }, releasePolicies)).toThrow("only branch_policy"); + expect(() => admitReleaseEnvironment(releaseEnvironment, { + branch_policies: [{ name: "main", type: "branch" }], + total_count: 1, + })).toThrow("v* tag policy"); + const ruleset = (id: number, name: string, rules: readonly string[], bypassOwner = false) => ({ + bypass_actors: bypassOwner + ? [{ actor_id: 894119, actor_type: "User", bypass_mode: "always" }] + : [], + conditions: { ref_name: { exclude: [], include: ["refs/tags/v*"] } }, + enforcement: "active", + id, + name, + rules: rules.map((type) => ({ type })), + target: "tag", + }); + const rulesetList = [ + { id: 1, name: "Release tag creation" }, + { id: 2, name: "Immutable version tags" }, + ]; + const rulesetDetails = new Map([ + ["Release tag creation", ruleset(1, "Release tag creation", ["creation"], true)], + ["Immutable version tags", ruleset(2, "Immutable version tags", ["update", "deletion"])], + ]); + expect(() => admitReleaseRulesets(rulesetList, rulesetDetails)).not.toThrow(); + rulesetDetails.set("Release tag creation", { + ...ruleset(1, "Release tag creation", ["creation"], true), + bypass_actors: [{ actor_id: 15368, actor_type: "Integration", bypass_mode: "always" }], + }); + expect(() => admitReleaseRulesets(rulesetList, rulesetDetails)).toThrow("unexpected bypass authority"); + rulesetDetails.set("Release tag creation", ruleset(1, "Release tag creation", ["creation"], true)); + rulesetDetails.set("Immutable version tags", ruleset(2, "Immutable version tags", ["creation", "update", "deletion"])); + expect(() => admitReleaseRulesets(rulesetList, rulesetDetails)).toThrow("unexpected rules"); + expect(() => admitOwner({ id: 894119, type: "Bot" })).toThrow("owner User"); + expect(() => admitRepository({ archived: false, default_branch: "main", disabled: false, full_name: "hraness/kb", id: 1308971873, private: true, visibility: "private" })).toThrow(); + admitRemoteRoutes("https://github.com/hraness/kb.git\n", "git@github.com:hraness/kb.git\n"); + expect(() => admitRemoteRoutes("https://github.com/hraness/kb.git\n", "https://github.com/hraness/kb.git\nhttps://github.com/attacker/kb.git\n")).toThrow(); + const workflowId = admitActiveCiWorkflow({ id: 9, name: "CI", path: ".github/workflows/ci.yml", state: "active" }); + const exactRun = { conclusion: "success", event: "push", head_branch: "main", head_repository: { full_name: "hraness/kb" }, head_sha: sha, id: 10, name: "CI", path: ".github/workflows/ci.yml", repository: { full_name: "hraness/kb" }, run_attempt: 2, status: "completed", workflow_id: workflowId }; + const run = admitCiRun({ total_count: 1, workflow_runs: [exactRun] }, workflowId, sha); + expect(() => admitCiRun({ total_count: 1, workflow_runs: [{ ...exactRun, event: "workflow_dispatch" }] }, workflowId, sha)).toThrow("exactly one exact CI push run"); + expect(admitCiRequiredJob({ jobs: [{ conclusion: "success", head_sha: sha, id: 11, name: "Required", run_attempt: 2, run_id: 10, status: "completed" }], total_count: 1 }, run, sha)).toBe(11); + expect(() => admitCiRequiredJob({ jobs: [{ conclusion: "success", head_sha: sha, id: 11, name: "Required", run_attempt: 1, run_id: 10, status: "completed" }], total_count: 1 }, run, sha)).toThrow(); + const inventory = `${otherSha}\trefs/tags/v0.19.0\n`; + expect(admitRemoteReleaseTags(inventory, "0.20.0", sha)).toBe("absent"); + expect(admitRemoteReleaseTags(inventory, "0.19.1-beta.0", sha)).toBe("absent"); + expect(() => admitRemoteReleaseTags(inventory, "0.18.0", sha)).toThrow("monotonically"); + const exact = `${otherSha}\trefs/tags/v0.20.0\n${sha}\trefs/tags/v0.20.0^{}\n`; + expect(admitRemoteReleaseTags(exact, "0.20.0", sha)).toBe("same-annotated-commit"); + expect(() => admitRemoteReleaseTags(`${otherSha}\trefs/tags/v0.20.0\n`, "0.20.0", sha)).toThrow("conflicts"); + expect(() => parseReleaseVersion("0.20.0-beta.01")).toThrow("canonical"); }); test("gates the immutable GitHub release on the exact public npm artifact", async () => { @@ -122,6 +293,15 @@ jobs: const source = await readFile(path, "utf8"); expect(source).toContain("Verify canonical npm delivery"); + expect(source).toContain('tags:\n - "v*"\n - "!v*-beta.*"'); + expect(source).toContain("Authorize owner release tag"); + expect(source).toContain('EXPECTED_REPOSITORY_ID: "1308971873"'); + expect(source).toContain('event.repository?.visibility !== "public"'); + expect(source).toContain("github.ref_protected"); + expect(source).not.toContain("workflow_dispatch:"); + expect(source).not.toContain("publication_run_id"); + expect(source).toContain("Release tag must be annotated"); + expect(source).toContain("for registry_poll in {1..60}"); expect(source).toContain('package_spec="$EXPECTED_NAME@$package_version"'); expect(source).toContain("scripts/npm-package-identity.ts"); expect(source).toContain("--registry-view-json"); diff --git a/scripts/check-workflow-yaml.ts b/scripts/check-workflow-yaml.ts index 47759ad..977b8fe 100644 --- a/scripts/check-workflow-yaml.ts +++ b/scripts/check-workflow-yaml.ts @@ -11,10 +11,7 @@ function record(value: unknown, label: string): Record { } function workflowRecord(source: string, label: string): Record { - const document = parseDocument(source, { - prettyErrors: true, - uniqueKeys: true, - }); + const document = parseDocument(source, { prettyErrors: true, uniqueKeys: true }); if (document.errors.length > 0) { throw new Error(`${label} is invalid YAML: ${document.errors[0]?.message ?? "unknown parse error"}`); } @@ -24,9 +21,7 @@ function workflowRecord(source: string, label: string): Record } record(workflow.on, `${label} on`); const jobs = record(workflow.jobs, `${label} jobs`); - if (Object.keys(jobs).length === 0) { - throw new Error(`${label} jobs must not be empty`); - } + if (Object.keys(jobs).length === 0) throw new Error(`${label} jobs must not be empty`); return workflow; } @@ -34,100 +29,129 @@ export function validateWorkflowYaml(source: string, label: string): void { workflowRecord(source, label); } -export function validateNpmStageWorkflow(source: string, label: string): void { +function validateOwnerTagAuthorization( + workflow: Record, + label: string, + dependentJobName: string, +): void { + const jobs = record(workflow.jobs, `${label} jobs`); + const authorize = record(jobs.authorize, `${label} authorize job`); + if (Object.keys(record(authorize.permissions, `${label} authorize permissions`)).length !== 0) { + throw new Error(`${label} owner authorization must have no token permissions`); + } + if (!Array.isArray(authorize.steps) || authorize.steps.length !== 1) { + throw new Error(`${label} owner authorization must be one pre-checkout step`); + } + const step = record(authorize.steps[0], `${label} owner authorization step`); + const environment = record(step.env, `${label} owner authorization environment`); + const run = step.run; + if ( + environment.EXPECTED_ACTOR_ID !== "894119" + || environment.EXPECTED_REPOSITORY !== "hraness/kb" + || environment.EXPECTED_REPOSITORY_ID !== "1308971873" + || environment.REF_PROTECTED !== "${{ github.ref_protected }}" + || typeof run !== "string" + || !run.includes('"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"') + || !run.includes("event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID)") + || !run.includes('event.sender?.type !== "User"') + || !run.includes("event.repository?.id !== Number(process.env.EXPECTED_REPOSITORY_ID)") + || !run.includes('event.repository?.visibility !== "public"') + || !run.includes("event.repository?.private !== false") + ) { + throw new Error(`${label} owner authorization is missing an exact actor, sender, or public-repository guard`); + } + if (JSON.stringify(authorize).includes("actions/checkout@")) { + throw new Error(`${label} must authorize the tag sender before checkout`); + } + const dependent = record(jobs[dependentJobName], `${label} ${dependentJobName} job`); + if (dependent.needs !== "authorize") { + throw new Error(`${label} ${dependentJobName} must follow owner authorization`); + } +} + +export function validateOwnerTagWorkflow(source: string, label: string): void { + const workflow = workflowRecord(source, label); + validateOwnerTagAuthorization(workflow, label, "verify"); +} + +export function validateNpmPublishWorkflow(source: string, label: string): void { const workflow = workflowRecord(source, label); const triggers = record(workflow.on, `${label} on`); - if (!("workflow_dispatch" in triggers)) { - throw new Error(`${label} must retain manual recovery dispatch`); + if (Object.keys(triggers).length !== 1 || !("push" in triggers)) { + throw new Error(`${label} must accept only protected release-tag pushes`); } const push = record(triggers.push, `${label} push trigger`); - if ( - !Array.isArray(push.branches) - || push.branches.length !== 1 - || push.branches[0] !== "main" - || !Array.isArray(push.paths) - || push.paths.length !== 1 - || push.paths[0] !== "package.json" - ) { - throw new Error(`${label} must run only for package.json pushes to main`); + if (!Array.isArray(push.tags) || push.tags.length !== 1 || push.tags[0] !== "v*") { + throw new Error(`${label} must accept exactly v* tag pushes`); + } + + const permissions = record(workflow.permissions, `${label} permissions`); + if (permissions.contents !== "read" || Object.keys(permissions).length !== 1) { + throw new Error(`${label} top-level permissions must be contents: read only`); } const jobs = record(workflow.jobs, `${label} jobs`); + const authorize = record(jobs.authorize, `${label} authorize job`); const select = record(jobs.select, `${label} select job`); const verify = record(jobs.verify, `${label} verify job`); - const stage = record(jobs.stage, `${label} stage job`); + const publish = record(jobs.publish, `${label} publish job`); + validateOwnerTagAuthorization(workflow, label, "select"); + const selectPermissions = record(select.permissions, `${label} select permissions`); if ( - selectPermissions.contents !== "read" - || "id-token" in selectPermissions + select.needs !== "authorize" + || selectPermissions.contents !== "read" || Object.keys(selectPermissions).length !== 1 - ) { - throw new Error(`${label} selection must remain read-only without OIDC authority`); - } + ) throw new Error(`${label} selection must follow authorization and remain read-only`); const selectOutputs = record(select.outputs, `${label} select outputs`); - if (selectOutputs.should_stage !== "${{ steps.selection.outputs.should_stage }}") { - throw new Error(`${label} selection must expose the reviewed should_stage decision`); - } if ( - verify.needs !== "select" - || verify.if !== "needs.select.outputs.should_stage == 'true'" - ) { - throw new Error(`${label} verification must require an affirmative stage selection`); + selectOutputs.should_publish !== "${{ steps.selection.outputs.should_publish }}" + || selectOutputs.publish_tag !== "${{ steps.selection.outputs.publish_tag }}" + ) throw new Error(`${label} selection must expose the reviewed decision and tag`); + const selectionSource = JSON.stringify(select); + for (const required of [ + "GITHUB_EVENT_NAME", + "GITHUB_SHA", + "GITHUB_REF_NAME", + "github.ref_protected", + "refs/remotes/origin/$DEFAULT_BRANCH", + "refs/npm-publish-tags/$GITHUB_REF_NAME", + "git cat-file -t", + "exact annotated release tag", + "publish_tag=latest", + "publish_tag=beta", + ]) { + if (!selectionSource.includes(required)) throw new Error(`${label} tag selection is missing ${required}`); } + const verifyPermissions = record(verify.permissions, `${label} verify permissions`); - if (verifyPermissions.contents !== "read" || "id-token" in verifyPermissions) { - throw new Error(`${label} verification must remain read-only without OIDC authority`); - } - const stagePermissions = record(stage.permissions, `${label} stage permissions`); if ( - stagePermissions["id-token"] !== "write" - || Object.keys(stagePermissions).length !== 1 - ) { - throw new Error(`${label} staging must hold only id-token: write`); - } - if (stage.environment !== "npm-stage") { - throw new Error(`${label} staging must use the exact npm-stage environment`); - } - if (!Array.isArray(select.steps)) { - throw new Error(`${label} select steps must be a sequence`); - } - const selectionSteps = select.steps.map((step, index) => - record(step, `${label} select step ${String(index + 1)}`)); - const selectionCommands = selectionSteps.filter((step) => - typeof step.run === "string" && step.run.includes("scripts/npm-stage-selection.ts")); - if (selectionCommands.length !== 1 || typeof selectionCommands[0]?.run !== "string") { - throw new Error(`${label} must contain exactly one package-version selection step`); - } - const selectionCommand = selectionCommands[0].run; - for (const required of [ - 'git fetch --no-tags origin', - 'git merge-base --is-ancestor "$BEFORE_SHA" "$default_head"', - 'git show "$BEFORE_SHA:package.json"', - 'bun run ./scripts/npm-stage-selection.ts', - ]) { - if (!selectionCommand.includes(required)) { - throw new Error(`${label} package-version selection is missing ${required}`); - } + verify.needs !== "select" + || verify.if !== "needs.select.outputs.should_publish == 'true'" + || verifyPermissions.contents !== "read" + || Object.keys(verifyPermissions).length !== 1 + ) throw new Error(`${label} package verification must follow selection and remain read-only`); + + const publishPermissions = record(publish.permissions, `${label} publish permissions`); + if (publishPermissions["id-token"] !== "write" || Object.keys(publishPermissions).length !== 1) { + throw new Error(`${label} terminal publishing must hold only id-token: write`); } - if (!Array.isArray(stage.steps)) { - throw new Error(`${label} stage steps must be a sequence`); + if (publish.environment !== "npm-stage") { + throw new Error(`${label} publishing must use the exact npm-stage environment`); } - const steps = stage.steps.map((step, index) => - record(step, `${label} stage step ${String(index + 1)}`)); + if (!Array.isArray(publish.steps)) throw new Error(`${label} publish steps must be a sequence`); + const steps = publish.steps.map((step, index) => record(step, `${label} publish step ${String(index + 1)}`)); if (steps.some((step) => typeof step.uses === "string" && (step.uses.startsWith("actions/checkout@") || step.uses.startsWith("oven-sh/setup-bun@")))) { - throw new Error(`${label} staging must not check out source or install Bun`); + throw new Error(`${label} publishing must not check out source or install Bun`); } const publicationSteps = steps.filter((step) => - typeof step.run === "string" && step.run.includes("npm stage publish")); - if (publicationSteps.length !== 1) { - throw new Error(`${label} must contain exactly one staged-publication step`); + typeof step.run === "string" && step.run.includes('npm publish "$TARBALL"')); + if (publicationSteps.length !== 1 || typeof publicationSteps[0]?.run !== "string") { + throw new Error(`${label} must contain exactly one direct-publication step`); } const publicationStep = publicationSteps[0]; - if (publicationStep === undefined || typeof publicationStep.run !== "string") { - throw new Error(`${label} staged-publication command is missing`); - } - const environment = record(publicationStep.env, `${label} staged-publication environment`); + const environment = record(publicationStep.env, `${label} direct-publication environment`); for (const name of [ "DEFAULT_BRANCH", "DIGEST", @@ -136,47 +160,60 @@ export function validateNpmStageWorkflow(source: string, label: string): void { "EXPECTED_METADATA_SHA256", "EXPECTED_SOURCE_SHA", "METADATA", + "PUBLISH_TAG", "TARBALL", ]) { - if (typeof environment[name] !== "string") { - throw new Error(`${label} staged publication must bind ${name}`); - } + if (typeof environment[name] !== "string") throw new Error(`${label} direct publication must bind ${name}`); } const guardCommands = [ 'git init --quiet --bare "$current_main"', - 'git --git-dir="$current_main" fetch', + 'git --git-dir="$current_main" fetch --quiet --no-tags --depth=1', 'current_default_sha="$(git --git-dir="$current_main" rev-parse FETCH_HEAD)"', + 'release_commit="$(git --git-dir="$current_main" rev-parse', 'current_archive_sha256="$(sha256sum "$TARBALL"', 'current_metadata_sha256="$(sha256sum "$METADATA"', 'current_digest_sha256="$(sha256sum "$DIGEST"', - 'npm stage publish "$TARBALL"', - "--registry=https://registry.npmjs.org", + 'npm view "$package_spec" version --json', + 'npm publish "$TARBALL"', + '--tag "$PUBLISH_TAG"', + 'npm view "$package_spec" dist --json', + "dist.attestations.provenance?.predicateType", ]; let previousIndex = -1; - for (const command of guardCommands) { - const index = publicationStep.run.indexOf(command); + for (const required of guardCommands) { + const index = publicationStep.run.indexOf(required); if (index <= previousIndex) { - throw new Error(`${label} must recheck current default-branch HEAD immediately before staged publication`); + throw new Error(`${label} must recheck current default-branch HEAD and artifact before direct publication/readback`); } previousIndex = index; } - const stageSource = JSON.stringify(stage); - if (/\bbun\b/u.test(stageSource) || stageSource.includes("./scripts/")) { - throw new Error(`${label} staging must not execute repository code`); + const publishSource = JSON.stringify(publish); + if (/\bbun\b/u.test(publishSource) || publishSource.includes("./scripts/")) { + throw new Error(`${label} publishing must not execute repository code`); + } + if (/\bnpm\s+(?:dist-tag|stage)\b/u.test(source)) { + throw new Error(`${label} must publish with its initial tag and never promote or stage it`); } if ((source.match(/id-token: write/gu) ?? []).length !== 1) { throw new Error(`${label} must grant OIDC authority to exactly one job`); } + for (const forbidden of ["workflow_dispatch:", "actions: write", "authorization_run_id", "NPM_TOKEN", "NODE_AUTH_TOKEN"]) { + if (source.includes(forbidden)) throw new Error(`${label} contains forbidden release authority ${forbidden}`); + } } if (import.meta.main) { const repositoryRoot = resolve(import.meta.dir, ".."); - for (const path of [".github/workflows/ci.yml", ".github/workflows/release.yml"]) { - validateWorkflowYaml(await readFile(resolve(repositoryRoot, path), "utf8"), path); - } - const npmStagePath = ".github/workflows/npm-stage.yml"; - validateNpmStageWorkflow( - await readFile(resolve(repositoryRoot, npmStagePath), "utf8"), - npmStagePath, + const ciPath = ".github/workflows/ci.yml"; + validateWorkflowYaml(await readFile(resolve(repositoryRoot, ciPath), "utf8"), ciPath); + const releasePath = ".github/workflows/release.yml"; + validateOwnerTagWorkflow( + await readFile(resolve(repositoryRoot, releasePath), "utf8"), + releasePath, + ); + const npmPublishPath = ".github/workflows/npm-stage.yml"; + validateNpmPublishWorkflow( + await readFile(resolve(repositoryRoot, npmPublishPath), "utf8"), + npmPublishPath, ); } diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 0d924bf..26eac7b 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -15,7 +15,7 @@ import { verifyNpmPackageIdentity, } from "./npm-package-identity.js"; -const stageWorkflowUrl = new URL("../.github/workflows/npm-stage.yml", import.meta.url); +const publishWorkflowUrl = new URL("../.github/workflows/npm-stage.yml", import.meta.url); const releaseWorkflowUrl = new URL("../.github/workflows/release.yml", import.meta.url); const ciWorkflowUrl = new URL("../.github/workflows/ci.yml", import.meta.url); const manifestUrl = new URL("../package.json", import.meta.url); @@ -176,34 +176,48 @@ describe("npm release workflows", () => { ]) expect(readme).toContain(link); }); - test("keeps the exact terminal OIDC stage independent from repository code", async () => { - const workflow = await readFile(stageWorkflowUrl, "utf8"); + test("keeps the exact terminal OIDC publication independent from repository code", async () => { + const workflow = await readFile(publishWorkflowUrl, "utf8"); + const authorizeStart = workflow.indexOf("\n authorize:\n"); const selectStart = workflow.indexOf("\n select:\n"); const verifyStart = workflow.indexOf("\n verify:\n"); - const stageStart = workflow.indexOf("\n stage:\n"); - expect(selectStart).toBeGreaterThan(-1); + const publishStart = workflow.indexOf("\n publish:\n"); + expect(authorizeStart).toBeGreaterThan(-1); + expect(selectStart).toBeGreaterThan(authorizeStart); expect(verifyStart).toBeGreaterThan(selectStart); - expect(stageStart).toBeGreaterThan(verifyStart); + expect(publishStart).toBeGreaterThan(verifyStart); + const authorizeJob = workflow.slice(authorizeStart, selectStart); const selectJob = workflow.slice(selectStart, verifyStart); - const verifyJob = workflow.slice(verifyStart, stageStart); - const stageJob = workflow.slice(stageStart); + const verifyJob = workflow.slice(verifyStart, publishStart); + const publishJob = workflow.slice(publishStart); for (const required of [ - "name: Select stable package version", - "permissions:\n contents: read", - "should_stage: ${{ steps.selection.outputs.should_stage }}", - "BEFORE_SHA: ${{ github.event.before }}", - 'expected_ref="refs/heads/$DEFAULT_BRANCH"', - 'git merge-base --is-ancestor "$BEFORE_SHA" "$default_head"', - 'git show "$BEFORE_SHA:package.json"', - 'bun run ./scripts/npm-stage-selection.ts "${selection_args[@]}"', + "name: Authorize owner release tag", + 'EXPECTED_ACTOR_ID: "894119"', + 'EXPECTED_REPOSITORY_ID: "1308971873"', + "GITHUB_ACTOR_ID", + 'event.sender?.type !== "User"', + 'event.repository?.visibility !== "public"', + 'event.repository?.private !== false', + "github.ref_protected", + ] as const) expect(authorizeJob).toContain(required); + expect(authorizeJob).not.toContain("actions/checkout@"); + + for (const required of [ + "name: Select publishable package version", + "contents: read", + "publish_tag: ${{ steps.selection.outputs.publish_tag }}", + "should_publish: ${{ steps.selection.outputs.should_publish }}", + "needs: authorize", + "github.ref_protected", + "Publication requires the exact annotated release tag", ] as const) expect(selectJob).toContain(required); expect(selectJob).not.toContain("id-token: write"); for (const required of [ "name: Verify exact package", "needs: select", - "if: needs.select.outputs.should_stage == 'true'", + "if: needs.select.outputs.should_publish == 'true'", "permissions:\n contents: read", "source_sha: ${{ steps.identity.outputs.source_sha }}", "artifact_name: ${{ steps.artifact.outputs.artifact_name }}", @@ -219,11 +233,11 @@ describe("npm release workflows", () => { "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", ] as const) expect(verifyJob).toContain(required); expect(verifyJob).not.toContain("id-token: write"); - expect(verifyJob).not.toContain("npm stage publish"); + expect(verifyJob).not.toMatch(/\bnpm publish\b/u); for (const required of [ "environment: npm-stage", - "permissions:\n id-token: write", + "id-token: write", "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", "Downloaded npm artifact must contain exactly the tarball, npm-pack.json, and npm-package.sha256", 'expected_tarball_name="hraness-kb-$EXPECTED_VERSION.tgz"', @@ -239,55 +253,68 @@ describe("npm release workflows", () => { '"https://github.com/$GITHUB_REPOSITORY.git"', 'EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }}', 'release_tag="v$EXPECTED_VERSION"', - "git ls-remote --exit-code --refs", - '"refs/tags/$release_tag" > "$tag_lookup_output"', - 'tag_lookup_status=$?', - '[[ "$tag_lookup_status" -ne 2 || -s "$tag_lookup_output" ]]', - "Could not prove that tag $release_tag is still absent from origin", + 'local_release_ref="refs/owner-release-tags/$release_tag"', + 'release_commit="$(git --git-dir="$current_main" rev-parse', + "Owner-created release tag identifies", 'current_archive_sha256="$(sha256sum "$TARBALL"', 'current_metadata_sha256="$(sha256sum "$METADATA"', 'current_digest_sha256="$(sha256sum "$DIGEST"', - 'npm stage publish "$TARBALL"', + 'npm publish "$TARBALL"', + '--access public', "--ignore-scripts", "--provenance", + '--tag "$PUBLISH_TAG"', + 'npm view "$package_spec" dist --json', + '"dist-tags.$PUBLISH_TAG" --json', + 'dist.attestations.provenance?.predicateType', + 'dist.signatures.length < 1', `--registry=${npmRegistry}`, - ] as const) expect(stageJob).toContain(required); + ] as const) expect(publishJob).toContain(required); expect(workflow.match(/id-token: write/gu) ?? []).toHaveLength(1); - expect(stageJob).not.toContain("contents: read"); - expect(stageJob).not.toContain("actions/checkout@"); - expect(stageJob).not.toContain("setup-bun@"); - expect(stageJob).not.toMatch(/\bbun\b/u); - expect(stageJob).not.toContain("./scripts/"); - expect(stageJob.match(/npm stage publish/gu) ?? []).toHaveLength(1); - const fetchIndex = stageJob.lastIndexOf('git --git-dir="$current_main" fetch'); - const tagLookupIndex = stageJob.lastIndexOf("git ls-remote --exit-code --refs"); - const rehashIndex = stageJob.lastIndexOf('current_archive_sha256="$(sha256sum "$TARBALL"'); - const stageIndex = stageJob.indexOf('npm stage publish "$TARBALL"'); + expect(publishJob).not.toContain("contents: read"); + expect(publishJob).not.toContain("actions/checkout@"); + expect(publishJob).not.toContain("setup-bun@"); + expect(publishJob).not.toMatch(/\bbun\b/u); + expect(publishJob).not.toContain("./scripts/"); + expect(publishJob.match(/npm publish/gu) ?? []).toHaveLength(1); + const fetchIndex = publishJob.lastIndexOf('git --git-dir="$current_main" fetch'); + const tagLookupIndex = publishJob.lastIndexOf('release_commit="$(git --git-dir="$current_main" rev-parse'); + const rehashIndex = publishJob.lastIndexOf('current_archive_sha256="$(sha256sum "$TARBALL"'); + const publishIndex = publishJob.indexOf('npm publish "$TARBALL"'); + const readbackIndex = publishJob.indexOf('npm view "$package_spec" dist --json'); expect(fetchIndex).toBeGreaterThan(-1); expect(fetchIndex).toBeLessThan(tagLookupIndex); expect(tagLookupIndex).toBeLessThan(rehashIndex); - expect(rehashIndex).toBeLessThan(stageIndex); + expect(rehashIndex).toBeLessThan(publishIndex); + expect(publishIndex).toBeLessThan(readbackIndex); expect(workflow).not.toContain("secrets.NPM_TOKEN"); expect(workflow).not.toContain("NODE_AUTH_TOKEN"); - expect(workflow).not.toMatch(/\bnpm publish\b/u); - expect(workflow).toContain('branches: [main]\n paths:\n - "package.json"'); - expect(workflow).toContain("workflow_dispatch:"); + expect(workflow).not.toMatch(/\bnpm\s+(?:stage|dist-tag)\b/u); + expect(workflow).not.toContain("workflow_dispatch:"); + expect(workflow).not.toContain("actions: write"); + expect(workflow).not.toContain("authorization_run_id"); + expect(workflow).toContain('tags:\n - "v*"'); }); - test("gates immutable releases on canonical content and current-main recovery", async () => { + test("gates immutable releases on an owner-created protected stable tag and exact npm delivery", async () => { const [workflow, artifact, identity] = await Promise.all([ readFile(releaseWorkflowUrl, "utf8"), readFile(packageArtifactUrl, "utf8"), readFile(packageIdentityUrl, "utf8"), ]); for (const required of [ - "workflow_dispatch:", - "Existing stable tag to recover after npm delivery succeeded", - "RECOVERY_TAG: ${{ inputs.tag }}", - "Recovery must run from current $DEFAULT_BRANCH head", + 'tags:\n - "v*"\n - "!v*-beta.*"', + "Authorize owner release tag", + 'EXPECTED_ACTOR_ID: "894119"', + 'EXPECTED_REPOSITORY_ID: "1308971873"', + 'event.sender?.type !== "User"', + 'event.repository?.visibility !== "public"', + "REF_PROTECTED: ${{ github.ref_protected }}", 'release_ref="refs/kb-release-tags/$release_tag"', + "Release tag must be annotated", 'git merge-base --is-ancestor "$tag_commit" "$default_head"', "Tag $release_tag is not the newest stable tag", + "for registry_poll in {1..60}", 'git worktree add --detach "$source_tree" "$SOURCE_SHA"', 'current_prepare="$GITHUB_WORKSPACE/scripts/prepare-npm-package.ts"', 'current_identity="$GITHUB_WORKSPACE/scripts/npm-package-identity.ts"', @@ -301,8 +328,10 @@ describe("npm release workflows", () => { 'npm view "$package_spec" name version dist', 'current_tag_sha="$(gh api', 'compare/$VERIFIED_SOURCE_SHA...$current_default_sha', - '"$EVENT_MODE" == recovery && "$current_default_sha" != "$WORKFLOW_SHA"', + '"$GITHUB_EVENT_NAME" != push', ] as const) expect(workflow).toContain(required); + expect(workflow).not.toContain("workflow_dispatch:"); + expect(workflow).not.toContain("publication_run_id"); expect(workflow).not.toContain('cmp "$source_archive" "$registry_archive"'); expect(workflow).not.toContain("bun run ./scripts/prepare-npm-package.ts"); expect(workflow).not.toContain("bun run ./scripts/package-smoke.ts"); @@ -340,32 +369,34 @@ describe("npm release workflows", () => { readFile(agentGuideUrl, "utf8"), ]); for (const required of [ - "automatically starts", + "starts automatically", "one-time `0.17.1` bootstrap", "Do not reuse the\ninteractive path for a later release", - "[Stage a later version](#stage-a-later-version)", - "version is unchanged", + "[Publish a later version](#publish-a-later-version)", "exact `npm-stage` environment", - "configure no required deployment reviewers", - "allows only `main`", + "`--environment npm-stage`", + "`--allow-publish`", + "Do not grant `--allow-stage`", + "allows only `v*` tags", "rebinds the release helpers to their reviewed Git blobs", "invokes those files by absolute path", "`npm pack --ignore-scripts`", npmRegistry, ] as const) expect(guide).toContain(required); expect(guide).toMatch(/the only job with\s+OIDC authority/u); - expect(guide).toMatch(/has no required reviewers, so the job starts automatically/u); - expect(guide).toMatch(/approve the staged package through npm with two-factor\s+authentication/u); + expect(guide).toMatch(/has no required reviewers, so the job starts\s+automatically/u); + expect(guide).toMatch(/publishes the reviewed\s+tarball directly\s+without a maintainer OTP/u); expect(guide).toMatch(/checks out no source and runs no\s+repository\s+code/u); expect(guide).toMatch(/exactly the tarball,\s+`npm-pack\.json`, and `npm-package\.sha256`/u); expect(guide).toMatch(/new bare\s+Git directory/u); expect(guide).toMatch(/do not import a\s+script from the tagged tree/u); - expect(agents).toContain("only its minimal dependent staging job may request OIDC"); - expect(agents).toContain("use the exact `npm-stage` environment"); - expect(agents).toContain("restrict deployments to `main` without required deployment reviewers"); - expect(agents).toContain("approve its promotion with human 2FA"); - expect(agents).toContain("bind the current workflow helpers to reviewed Git blobs"); - expect(agents).toContain("recovery never depends on or reruns a historical `prepack`"); + expect(agents).toContain("publish each unique version directly through the minimal OIDC job"); + expect(agents).toContain("direct OIDC trusted publishing"); + expect(agents).toContain("Protected tag-push workflows"); + expect(agents).toContain("**Immutable version tags** restricts update and deletion with an empty bypass list"); + expect(agents).toContain("**Release tag creation** restricts creation and has owner `User` ID `894119` as its sole always-bypass actor"); + expect(agents).toContain("Never grant generic GitHub Actions integration ID `15368`"); + expect(agents).toContain("public repository ID `1308971873`"); }); test("pins publication to the canonical npm registry", async () => { diff --git a/scripts/npm-stage-selection.test.ts b/scripts/npm-stage-selection.test.ts deleted file mode 100644 index 3a69b5f..0000000 --- a/scripts/npm-stage-selection.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import fc from "fast-check"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { selectNpmStage } from "./npm-stage-selection.ts"; - -function manifest(version: unknown, name: unknown = "@hraness/kb"): string { - return JSON.stringify({ name, version }); -} - -describe("npm stage selection", () => { - test("selects a strictly increasing stable version from a push", () => { - expect(selectNpmStage({ - currentManifest: manifest("0.17.3"), - eventName: "push", - previousManifest: manifest("0.17.2"), - })).toEqual({ - currentVersion: "0.17.3", - previousVersion: "0.17.2", - reason: "stable-version-increase", - shouldStage: true, - }); - }); - - test("makes an unrelated package.json edit a successful no-op", () => { - expect(selectNpmStage({ - currentManifest: manifest("0.17.3"), - eventName: "push", - previousManifest: manifest("0.17.3"), - })).toEqual({ - currentVersion: "0.17.3", - previousVersion: "0.17.3", - reason: "version-unchanged", - shouldStage: false, - }); - }); - - test("retains a manual current-version recovery request", () => { - expect(selectNpmStage({ - currentManifest: manifest("0.17.3"), - eventName: "workflow_dispatch", - })).toEqual({ - currentVersion: "0.17.3", - reason: "manual-recovery", - shouldStage: true, - }); - }); - - test("rejects downgrades, prereleases, foreign packages, and incomplete pushes", () => { - expect(() => selectNpmStage({ - currentManifest: manifest("0.17.0"), - eventName: "push", - previousManifest: manifest("0.17.3"), - })).toThrow("Package version must increase"); - expect(() => selectNpmStage({ - currentManifest: manifest("0.18.0-beta.1"), - eventName: "push", - previousManifest: manifest("0.17.3"), - })).toThrow("stable semantic version"); - expect(() => selectNpmStage({ - currentManifest: manifest("0.18.0", "@hraness/not-kb"), - eventName: "push", - previousManifest: manifest("0.17.3"), - })).toThrow("must identify @hraness/kb"); - expect(() => selectNpmStage({ - currentManifest: manifest("0.18.0"), - eventName: "push", - })).toThrow("must provide the previous package.json"); - }); - - test("compares version parts without numeric precision loss", () => { - expect(selectNpmStage({ - currentManifest: manifest("9007199254740993.0.0"), - eventName: "push", - previousManifest: manifest("9007199254740992.999.999"), - }).shouldStage).toBe(true); - }); - - test("matches lexicographic numeric ordering for stable versions", () => { - const versionPart = fc.bigInt({ min: 0n, max: 999_999_999_999_999_999_999n }); - const versionParts = fc.tuple(versionPart, versionPart, versionPart); - fc.assert(fc.property(versionParts, versionParts, (current, previous) => { - const differenceIndex = current.findIndex((part, index) => part !== previous[index]); - const ordering = differenceIndex === -1 - ? 0 - : current[differenceIndex]! > previous[differenceIndex]! - ? 1 - : -1; - const input = { - currentManifest: manifest(current.join(".")), - eventName: "push", - previousManifest: manifest(previous.join(".")), - }; - if (ordering < 0) { - expect(() => selectNpmStage(input)).toThrow("Package version must increase"); - return; - } - const selection = selectNpmStage(input); - expect(selection.shouldStage).toBe(ordering > 0); - expect(selection.reason).toBe(ordering > 0 - ? "stable-version-increase" - : "version-unchanged"); - }), { numRuns: 200 }); - }); - - test("writes a bounded GitHub Actions selection output", async () => { - const work = await mkdtemp(join(tmpdir(), "kb-npm-stage-selection-")); - try { - const currentManifest = join(work, "current-package.json"); - const previousManifest = join(work, "previous-package.json"); - const githubOutput = join(work, "github-output.txt"); - await Promise.all([ - writeFile(currentManifest, manifest("0.17.3")), - writeFile(previousManifest, manifest("0.17.2")), - ]); - const child = Bun.spawn([ - process.execPath, - "run", - fileURLToPath(new URL("./npm-stage-selection.ts", import.meta.url)), - "--current-manifest", - currentManifest, - "--event", - "push", - "--github-output", - githubOutput, - "--previous-manifest", - previousManifest, - ], { stderr: "pipe", stdout: "pipe" }); - const [exitCode, output, error] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - expect(error).toBe(""); - expect(exitCode).toBe(0); - expect(output).toContain("Stage @hraness/kb@0.17.3: stable-version-increase"); - expect(await readFile(githubOutput, "utf8")).toBe( - "current_version=0.17.3\n" - + "reason=stable-version-increase\n" - + "should_stage=true\n" - + "previous_version=0.17.2\n", - ); - } finally { - await rm(work, { force: true, recursive: true }); - } - }); -}); diff --git a/scripts/npm-stage-selection.ts b/scripts/npm-stage-selection.ts deleted file mode 100644 index c7975a6..0000000 --- a/scripts/npm-stage-selection.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { appendFile, readFile } from "node:fs/promises"; - -const expectedPackageName = "@hraness/kb"; -const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; - -type PackageIdentity = Readonly<{ - name: typeof expectedPackageName; - version: string; - versionParts: readonly [bigint, bigint, bigint]; -}>; - -export type NpmStageSelection = Readonly<{ - currentVersion: string; - previousVersion?: string; - reason: "manual-recovery" | "stable-version-increase" | "version-unchanged"; - shouldStage: boolean; -}>; - -type CliOptions = Readonly<{ - currentManifestPath: string; - eventName: string; - githubOutputPath: string; - previousManifestPath?: string; -}>; - -function record(value: unknown, label: string): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new TypeError(`${label} must be a JSON object`); - } - return value as Record; -} - -function packageIdentity(source: string, label: string): PackageIdentity { - let value: unknown; - try { - value = JSON.parse(source) as unknown; - } catch (error) { - throw new TypeError(`${label} must be valid JSON`, { cause: error }); - } - const manifest = record(value, label); - if (manifest.name !== expectedPackageName) { - throw new TypeError(`${label} must identify ${expectedPackageName}`); - } - if (typeof manifest.version !== "string") { - throw new TypeError(`${label}.version must be a string`); - } - const match = stableVersionPattern.exec(manifest.version); - if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) { - throw new TypeError(`${label}.version must be a stable semantic version`); - } - return { - name: expectedPackageName, - version: manifest.version, - versionParts: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], - }; -} - -function compareVersions(left: PackageIdentity, right: PackageIdentity): number { - for (let index = 0; index < left.versionParts.length; index += 1) { - const leftPart = left.versionParts[index]; - const rightPart = right.versionParts[index]; - if (leftPart === undefined || rightPart === undefined) { - throw new TypeError("Stable semantic version comparison is incomplete"); - } - if (leftPart !== rightPart) return leftPart > rightPart ? 1 : -1; - } - return 0; -} - -export function selectNpmStage(input: Readonly<{ - currentManifest: string; - eventName: string; - previousManifest?: string; -}>): NpmStageSelection { - const current = packageIdentity(input.currentManifest, "current package.json"); - if (input.eventName === "workflow_dispatch") { - return { - currentVersion: current.version, - reason: "manual-recovery", - shouldStage: true, - }; - } - if (input.eventName !== "push") { - throw new TypeError(`Unsupported npm staging event ${input.eventName}`); - } - if (input.previousManifest === undefined) { - throw new TypeError("A push event must provide the previous package.json"); - } - const previous = packageIdentity(input.previousManifest, "previous package.json"); - const comparison = compareVersions(current, previous); - if (comparison < 0) { - throw new TypeError( - `Package version must increase from ${previous.version}, received ${current.version}`, - ); - } - if (comparison === 0) { - return { - currentVersion: current.version, - previousVersion: previous.version, - reason: "version-unchanged", - shouldStage: false, - }; - } - return { - currentVersion: current.version, - previousVersion: previous.version, - reason: "stable-version-increase", - shouldStage: true, - }; -} - -function parseCliOptions(args: readonly string[]): CliOptions { - const values = new Map(); - for (let index = 0; index < args.length; index += 2) { - const name = args[index]; - const value = args[index + 1]; - if (name === undefined || value === undefined || !name.startsWith("--") || value.length === 0) { - throw new TypeError("npm stage selection arguments must be non-empty name/value pairs"); - } - if (values.has(name)) throw new TypeError(`npm stage selection repeats ${name}`); - values.set(name, value); - } - const allowed = new Set([ - "--current-manifest", - "--event", - "--github-output", - "--previous-manifest", - ]); - for (const name of values.keys()) { - if (!allowed.has(name)) throw new TypeError(`Unknown npm stage selection argument ${name}`); - } - const currentManifestPath = values.get("--current-manifest"); - const eventName = values.get("--event"); - const githubOutputPath = values.get("--github-output"); - if (currentManifestPath === undefined || eventName === undefined || githubOutputPath === undefined) { - throw new TypeError("npm stage selection requires --current-manifest, --event, and --github-output"); - } - const previousManifestPath = values.get("--previous-manifest"); - return { - currentManifestPath, - eventName, - githubOutputPath, - ...(previousManifestPath === undefined ? {} : { previousManifestPath }), - }; -} - -async function main(): Promise { - const options = parseCliOptions(process.argv.slice(2)); - const [currentManifest, previousManifest] = await Promise.all([ - readFile(options.currentManifestPath, "utf8"), - options.previousManifestPath === undefined - ? undefined - : readFile(options.previousManifestPath, "utf8"), - ]); - const selection = selectNpmStage({ - currentManifest, - eventName: options.eventName, - ...(previousManifest === undefined ? {} : { previousManifest }), - }); - const output = [ - `current_version=${selection.currentVersion}`, - `reason=${selection.reason}`, - `should_stage=${selection.shouldStage ? "true" : "false"}`, - ...(selection.previousVersion === undefined - ? [] - : [`previous_version=${selection.previousVersion}`]), - ]; - await appendFile(options.githubOutputPath, `${output.join("\n")}\n`, "utf8"); - if (selection.shouldStage) { - console.log(`Stage ${expectedPackageName}@${selection.currentVersion}: ${selection.reason}`); - } else { - console.log(`Package version remains ${selection.currentVersion}; npm staging is not required`); - } -} - -if (import.meta.main) await main(); diff --git a/scripts/push-npm-release-tag.ts b/scripts/push-npm-release-tag.ts new file mode 100644 index 0000000..5b2d709 --- /dev/null +++ b/scripts/push-npm-release-tag.ts @@ -0,0 +1,651 @@ +import { lstatSync, readFileSync, realpathSync } from "node:fs"; +import { resolve } from "node:path"; + +const EXPECTED_ACTOR_ID = 894119; +const EXPECTED_PACKAGE = "@hraness/kb"; +const EXPECTED_REPOSITORY = "hraness/kb"; +const EXPECTED_REPOSITORY_ID = 1308971873; +const EXPECTED_REMOTE_URLS = new Set([ + "git@github.com:hraness/kb.git", + "https://github.com/hraness/kb.git", + "ssh://git@github.com/hraness/kb.git", +]); +const DEFAULT_BRANCH = "main"; +const CI_WORKFLOW_NAME = "CI"; +const CI_WORKFLOW_PATH = ".github/workflows/ci.yml"; +const CI_REQUIRED_JOB = "Required"; +const RELEASE_ENVIRONMENT = "npm-stage"; +const RELEASE_RULESET_POLICIES = [ + { bypassOwner: true, name: "Release tag creation", rules: ["creation"] }, + { bypassOwner: false, name: "Immutable version tags", rules: ["deletion", "update"] }, +] as const; +const MAXIMUM_OUTPUT_BYTES = 1024 * 1024; +const MAXIMUM_INVENTORY_ITEMS = 100; +const MAXIMUM_TAG_LINES = 2_000; +const PROCESS_TIMEOUT_MS = 30_000; +const SHA = /^[0-9a-f]{40}$/u; +const VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta\.(0|[1-9][0-9]*))?$/u; + +type JsonRecord = Record; + +export type ReleaseVersion = Readonly<{ + beta: bigint | null; + parts: readonly [bigint, bigint, bigint]; + tag: string; + version: string; +}>; + +export type CiRunIdentity = Readonly<{ runAttempt: number; runId: number }>; + +export type RemoteTagAdmission = "absent" | "same-annotated-commit"; + +function record(value: unknown, label: string): JsonRecord { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} is malformed.`); + } + return value as JsonRecord; +} + +function positiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +export function parseReleaseVersion(value: string): ReleaseVersion { + const match = VERSION.exec(value); + if (match === null) { + throw new Error(`Release version ${value} is not canonical stable or beta. SemVer.`); + } + const major = match[1]; + const minor = match[2]; + const patch = match[3]; + if (major === undefined || minor === undefined || patch === undefined) { + throw new Error("Release version parser lost a required SemVer component."); + } + return Object.freeze({ + beta: match[4] === undefined ? null : BigInt(match[4]), + parts: Object.freeze([BigInt(major), BigInt(minor), BigInt(patch)]) as readonly [bigint, bigint, bigint], + tag: `v${value}`, + version: value, + }); +} + +export function compareReleaseVersions(left: ReleaseVersion, right: ReleaseVersion): number { + for (let index = 0; index < left.parts.length; index += 1) { + const leftPart = left.parts[index]!; + const rightPart = right.parts[index]!; + if (leftPart !== rightPart) return leftPart > rightPart ? 1 : -1; + } + if (left.beta === null && right.beta === null) return 0; + if (left.beta === null) return 1; + if (right.beta === null) return -1; + return left.beta === right.beta ? 0 : left.beta > right.beta ? 1 : -1; +} + +export function admitOwner(value: unknown): void { + const actor = record(value, "GitHub authentication receipt"); + if (actor.id !== EXPECTED_ACTOR_ID || actor.type !== "User") { + throw new Error(`Release-tag authentication must be immutable owner User ${String(EXPECTED_ACTOR_ID)}.`); + } +} + +export function admitRepository(value: unknown): void { + const repository = record(value, "GitHub repository receipt"); + if ( + repository.full_name !== EXPECTED_REPOSITORY + || repository.id !== EXPECTED_REPOSITORY_ID + || repository.default_branch !== DEFAULT_BRANCH + || repository.archived !== false + || repository.disabled !== false + || repository.private !== false + || repository.visibility !== "public" + ) { + throw new Error(`Release-tag authentication must address active ${EXPECTED_REPOSITORY} with default branch ${DEFAULT_BRANCH}.`); + } +} + +export function admitReleaseEnvironment(environmentValue: unknown, policiesValue: unknown): void { + const environment = record(environmentValue, "npm release environment receipt"); + const deploymentPolicy = record(environment.deployment_branch_policy, "npm release deployment policy"); + if ( + environment.name !== RELEASE_ENVIRONMENT + || environment.can_admins_bypass !== false + || deploymentPolicy.custom_branch_policies !== true + || deploymentPolicy.protected_branches !== false + || !Array.isArray(environment.protection_rules) + || environment.protection_rules.length !== 1 + || record(environment.protection_rules[0], "npm release protection rule").type !== "branch_policy" + ) { + throw new Error(`${RELEASE_ENVIRONMENT} must have no administrator bypass or reviewers and use only branch_policy protection.`); + } + const policies = record(policiesValue, "npm release deployment-policy receipt"); + if ( + policies.total_count !== 1 + || !Array.isArray(policies.branch_policies) + || policies.branch_policies.length !== 1 + ) throw new Error(`${RELEASE_ENVIRONMENT} must admit exactly one deployment policy.`); + const policy = record(policies.branch_policies[0], "npm release deployment policy entry"); + if (policy.name !== "v*" || policy.type !== "tag") { + throw new Error(`${RELEASE_ENVIRONMENT} must admit only the v* tag policy.`); + } +} + +function releaseRulesetIdentities(value: unknown): ReadonlyMap { + if (!Array.isArray(value) || value.length > MAXIMUM_INVENTORY_ITEMS) { + throw new Error("GitHub ruleset inventory is malformed or exceeds its bound."); + } + const identities = new Map(); + for (const expected of RELEASE_RULESET_POLICIES) { + const matches = value + .map((item) => record(item, "GitHub ruleset summary")) + .filter((item) => item.name === expected.name); + if (matches.length !== 1 || !positiveInteger(matches[0]?.id)) { + throw new Error(`Expected exactly one ${expected.name} ruleset.`); + } + identities.set(expected.name, matches[0].id); + } + return identities; +} + +export function admitReleaseRulesets( + listValue: unknown, + detailValues: ReadonlyMap, +): void { + const identities = releaseRulesetIdentities(listValue); + for (const expected of RELEASE_RULESET_POLICIES) { + const expectedId = identities.get(expected.name); + const detailValue = detailValues.get(expected.name); + if (expectedId === undefined || detailValue === undefined) { + throw new Error(`Missing exact ${expected.name} ruleset readback.`); + } + const detail = record(detailValue, `${expected.name} ruleset`); + const conditions = record(detail.conditions, `${expected.name} ruleset conditions`); + const refName = record(conditions.ref_name, `${expected.name} ruleset ref condition`); + if ( + detail.id !== expectedId + || detail.name !== expected.name + || detail.target !== "tag" + || detail.enforcement !== "active" + || !Array.isArray(refName.exclude) + || refName.exclude.length !== 0 + || !Array.isArray(refName.include) + || refName.include.length !== 1 + || refName.include[0] !== "refs/tags/v*" + || !Array.isArray(detail.rules) + || detail.rules.length > 20 + ) throw new Error(`${expected.name} does not protect the exact release-tag namespace.`); + const ruleTypes = detail.rules.map((rule) => record(rule, `${expected.name} rule`).type).sort(); + if ( + ruleTypes.some((type) => typeof type !== "string") + || JSON.stringify(ruleTypes) !== JSON.stringify([...expected.rules].sort()) + ) throw new Error(`${expected.name} has unexpected rules.`); + if (!Array.isArray(detail.bypass_actors) || detail.bypass_actors.length > 10) { + throw new Error(`${expected.name} has malformed bypass authority.`); + } + const bypassActors = detail.bypass_actors.map((value) => { + const actor = record(value, `${expected.name} bypass actor`); + if (!positiveInteger(actor.actor_id) || typeof actor.actor_type !== "string" || typeof actor.bypass_mode !== "string") { + throw new Error(`${expected.name} has malformed bypass authority.`); + } + return { actor_id: actor.actor_id, actor_type: actor.actor_type, bypass_mode: actor.bypass_mode }; + }); + const expectedBypass = expected.bypassOwner + ? [{ actor_id: EXPECTED_ACTOR_ID, actor_type: "User", bypass_mode: "always" }] + : []; + if (JSON.stringify(bypassActors) !== JSON.stringify(expectedBypass)) { + throw new Error(`${expected.name} has unexpected bypass authority.`); + } + } +} + +export function admitRemoteRoutes(fetchOutput: string, pushOutput: string): void { + const lines = (value: string): string[] => value.trimEnd().split("\n"); + const fetchUrls = lines(fetchOutput); + const pushUrls = lines(pushOutput); + if ( + fetchUrls.length !== 1 + || pushUrls.length !== 1 + || !EXPECTED_REMOTE_URLS.has(fetchUrls[0] ?? "") + || !EXPECTED_REMOTE_URLS.has(pushUrls[0] ?? "") + ) throw new Error(`Origin fetch and push routing must each name only canonical ${EXPECTED_REPOSITORY}.`); +} + +export function admitProtectedBranch(value: unknown, expectedSha: string): void { + if (!SHA.test(expectedSha)) throw new Error("Protected-main verification requires one lowercase commit SHA."); + const branch = record(value, "GitHub protected-branch receipt"); + const commit = record(branch.commit, "GitHub protected-branch commit"); + if (branch.name !== DEFAULT_BRANCH || branch.protected !== true || commit.sha !== expectedSha) { + throw new Error(`Release commit must be the exact protected ${DEFAULT_BRANCH} head.`); + } +} + +export function admitActiveCiWorkflow(value: unknown): number { + const workflow = record(value, "CI workflow receipt"); + if ( + !positiveInteger(workflow.id) + || workflow.name !== CI_WORKFLOW_NAME + || workflow.path !== CI_WORKFLOW_PATH + || workflow.state !== "active" + ) { + throw new Error(`CI must be the exact active ${CI_WORKFLOW_PATH} workflow.`); + } + return workflow.id; +} + +export function admitCiRun( + value: unknown, + workflowId: number, + expectedSha: string, +): CiRunIdentity { + if (!positiveInteger(workflowId) || !SHA.test(expectedSha)) { + throw new Error("CI run verification received an invalid workflow or commit identity."); + } + const inventory = record(value, "CI run inventory"); + if ( + !Number.isSafeInteger(inventory.total_count) + || Number(inventory.total_count) < 0 + || !Array.isArray(inventory.workflow_runs) + || inventory.workflow_runs.length > MAXIMUM_INVENTORY_ITEMS + || inventory.total_count !== inventory.workflow_runs.length + ) { + throw new Error("CI run inventory is malformed or truncated."); + } + const candidates = inventory.workflow_runs.filter((item) => { + const run = record(item, "CI run"); + const repository = record(run.repository, "CI run repository"); + const headRepository = record(run.head_repository, "CI run head repository"); + return run.workflow_id === workflowId + && run.name === CI_WORKFLOW_NAME + && run.path === CI_WORKFLOW_PATH + && run.event === "push" + && run.head_branch === DEFAULT_BRANCH + && run.head_sha === expectedSha + && repository.full_name === EXPECTED_REPOSITORY + && headRepository.full_name === EXPECTED_REPOSITORY; + }); + if (candidates.length !== 1) { + throw new Error("Current main must have exactly one exact CI push run."); + } + const run = record(candidates[0], "Exact CI run"); + if ( + !positiveInteger(run.id) + || !positiveInteger(run.run_attempt) + || run.status !== "completed" + || run.conclusion !== "success" + ) { + throw new Error("The exact current-main CI push run is not successful."); + } + return Object.freeze({ runAttempt: run.run_attempt, runId: run.id }); +} + +export function admitCiRequiredJob(value: unknown, run: CiRunIdentity, expectedSha: string): number { + if (!positiveInteger(run.runId) || !positiveInteger(run.runAttempt) || !SHA.test(expectedSha)) { + throw new Error("CI job verification received an invalid run or commit identity."); + } + const inventory = record(value, "CI job inventory"); + if ( + !Number.isSafeInteger(inventory.total_count) + || Number(inventory.total_count) < 0 + || !Array.isArray(inventory.jobs) + || inventory.jobs.length > MAXIMUM_INVENTORY_ITEMS + || inventory.total_count !== inventory.jobs.length + ) { + throw new Error("CI job inventory is malformed or truncated."); + } + const candidates = inventory.jobs.filter((item) => { + const job = record(item, "CI job"); + return job.name === CI_REQUIRED_JOB + && job.run_id === run.runId + && job.run_attempt === run.runAttempt + && job.head_sha === expectedSha; + }); + if (candidates.length !== 1) { + throw new Error(`Exact CI run attempt must have one ${CI_REQUIRED_JOB} job.`); + } + const job = record(candidates[0], `Exact ${CI_REQUIRED_JOB} job`); + if (!positiveInteger(job.id) || job.status !== "completed" || job.conclusion !== "success") { + throw new Error(`The exact CI run attempt ${CI_REQUIRED_JOB} job is not successful.`); + } + return job.id; +} + +export function admitRemoteReleaseTags( + text: string, + expectedVersion: string, + expectedSha: string, +): RemoteTagAdmission { + if (!SHA.test(expectedSha)) throw new Error("Remote tag verification requires one lowercase commit SHA."); + if (new TextEncoder().encode(text).byteLength > MAXIMUM_OUTPUT_BYTES) { + throw new Error("Remote tag inventory exceeds its byte bound."); + } + const expected = parseReleaseVersion(expectedVersion); + const lines = text === "" ? [] : text.replace(/\n$/u, "").split("\n"); + if (lines.length > MAXIMUM_TAG_LINES || lines.some((line) => line.length > 512 || line.length === 0)) { + throw new Error("Remote tag inventory is malformed or exceeds its line bound."); + } + const tags = new Map(); + for (const line of lines) { + const match = /^([0-9a-f]{40})\trefs\/tags\/(v[^\s^]+)(\^\{\})?$/u.exec(line); + if (match === null) throw new Error("Remote tag inventory contains a malformed ref."); + const sha = match[1]; + const tag = match[2]; + const suffix = match[3]; + if (sha === undefined || tag === undefined) throw new Error("Remote tag inventory lost a ref component."); + parseReleaseVersion(tag.slice(1)); + const current = tags.get(tag) ?? {}; + const field = suffix === undefined ? "object" : "peeled"; + if (current[field] !== undefined) throw new Error(`Remote tag inventory repeats ${tag}${suffix ?? ""}.`); + current[field] = sha; + tags.set(tag, current); + } + for (const [tag, identity] of tags) { + if (identity.object === undefined || identity.peeled === identity.object) { + throw new Error(`Remote tag inventory has an invalid ${tag} identity.`); + } + } + const exact = tags.get(expected.tag); + if (exact !== undefined) { + if (exact.peeled === undefined || exact.peeled !== expectedSha) { + throw new Error(`${expected.tag} conflicts with the requested annotated tag and commit.`); + } + return "same-annotated-commit"; + } + const relevant = [...tags.keys()] + .map((tag) => parseReleaseVersion(tag.slice(1))) + .filter((candidate) => expected.beta !== null || candidate.beta === null); + const blocker = relevant.find((candidate) => compareReleaseVersions(expected, candidate) <= 0); + if (blocker !== undefined) { + throw new Error(`${expected.tag} must increase monotonically beyond ${blocker.tag}.`); + } + return "absent"; +} + +type CommandResult = Readonly<{ exitCode: number; stderr: string; stdout: string }>; + +async function readBounded(stream: ReadableStream, label: string): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAXIMUM_OUTPUT_BYTES) throw new Error(`${label} exceeded its output bound.`); + chunks.push(next.value); + } + const combined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder("utf-8", { fatal: true }).decode(combined); +} + +async function command( + argv: readonly string[], + options: Readonly<{ allowFailure?: boolean; cwd: string; label: string }>, +): Promise { + const child = Bun.spawn([...argv], { + cwd: options.cwd, + env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" }, + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + }); + let timeout: ReturnType | undefined; + try { + const completed = Promise.all([ + child.exited, + readBounded(child.stdout, `${options.label} stdout`), + readBounded(child.stderr, `${options.label} stderr`), + ] as const); + const timedOut = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`${options.label} timed out.`)), PROCESS_TIMEOUT_MS); + }); + const [exitCode, stdout, stderr] = await Promise.race([completed, timedOut]); + const result = Object.freeze({ exitCode, stderr, stdout }); + if (exitCode !== 0 && options.allowFailure !== true) { + const detail = stderr.trim().slice(0, 2_000); + throw new Error(`${options.label} failed${detail === "" ? "." : `: ${detail}`}`); + } + return result; + } catch (error) { + child.kill(); + await child.exited; + throw error; + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +async function jsonCommand(argv: readonly string[], root: string, label: string): Promise { + const result = await command(argv, { cwd: root, label }); + try { + return JSON.parse(result.stdout) as unknown; + } catch { + throw new Error(`${label} returned malformed JSON.`); + } +} + +async function refreshMain(root: string, expectedSha?: string): Promise { + await command( + ["git", "fetch", "--no-tags", "origin", `refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}`], + { cwd: root, label: `Fetch ${DEFAULT_BRANCH}` }, + ); + const head = (await command(["git", "rev-parse", "HEAD"], { cwd: root, label: "Read HEAD" })).stdout.trim(); + const remote = ( + await command(["git", "rev-parse", `refs/remotes/origin/${DEFAULT_BRANCH}`], { + cwd: root, + label: `Read origin/${DEFAULT_BRANCH}`, + }) + ).stdout.trim(); + if (!SHA.test(head) || head !== remote || (expectedSha !== undefined && head !== expectedSha)) { + throw new Error(`Release checkout must be exact current origin/${DEFAULT_BRANCH}.`); + } + return head; +} + +async function remoteTagInventory(root: string): Promise { + return ( + await command(["git", "ls-remote", "--tags", "origin", "refs/tags/v*"], { + cwd: root, + label: "Read remote release tags", + }) + ).stdout; +} + +async function requireLocalTag(root: string, tag: string, sha: string, message: string): Promise { + const type = (await command(["git", "cat-file", "-t", `refs/tags/${tag}`], { cwd: root, label: `Read ${tag} type` })).stdout.trim(); + const commit = ( + await command(["git", "rev-parse", `refs/tags/${tag}^{commit}`], { cwd: root, label: `Read ${tag} commit` }) + ).stdout.trim(); + const object = (await command(["git", "cat-file", "tag", `refs/tags/${tag}`], { cwd: root, label: `Read ${tag} object` })).stdout; + const separator = object.indexOf("\n\n"); + const headers = separator === -1 ? [] : object.slice(0, separator).split("\n"); + const body = separator === -1 ? "" : object.slice(separator + 2).trimEnd(); + if ( + type !== "tag" + || commit !== sha + || !headers.includes(`object ${sha}`) + || !headers.includes("type commit") + || !headers.includes(`tag ${tag}`) + || body !== message + ) { + throw new Error(`Local ${tag} conflicts with the exact annotated release tag.`); + } +} + +async function main(): Promise { + const [versionArgument, ...extraArguments] = Bun.argv.slice(2); + if (versionArgument === undefined || extraArguments.length !== 0) { + throw new Error("Usage: bun run ./scripts/push-npm-release-tag.ts "); + } + const release = parseReleaseVersion(versionArgument); + const root = realpathSync(resolve(import.meta.dir, "..")); + const reportedRoot = realpathSync( + (await command(["git", "rev-parse", "--show-toplevel"], { cwd: root, label: "Resolve repository root" })).stdout.trim(), + ); + if (reportedRoot !== root) throw new Error("Release-tag script is not running in its owning repository."); + const branch = (await command(["git", "branch", "--show-current"], { cwd: root, label: "Read current branch" })).stdout.trim(); + if (branch !== DEFAULT_BRANCH) throw new Error(`Release-tag script must run on ${DEFAULT_BRANCH}.`); + const fetchUrls = ( + await command(["git", "remote", "get-url", "--all", "origin"], { cwd: root, label: "Read origin fetch URLs" }) + ).stdout; + const pushUrls = ( + await command(["git", "remote", "get-url", "--push", "--all", "origin"], { cwd: root, label: "Read origin push URLs" }) + ).stdout; + admitRemoteRoutes(fetchUrls, pushUrls); + const status = ( + await command(["git", "status", "--porcelain", "--untracked-files=all"], { cwd: root, label: "Read worktree status" }) + ).stdout; + if (status !== "") throw new Error("Release-tag script requires a clean worktree."); + + const packagePath = resolve(root, "package.json"); + const packageStat = lstatSync(packagePath); + if (!packageStat.isFile() || packageStat.isSymbolicLink() || packageStat.size > MAXIMUM_OUTPUT_BYTES) { + throw new Error("package.json must be one bounded regular file."); + } + const packageJson = record(JSON.parse(readFileSync(packagePath, "utf8")) as unknown, "package.json"); + if (packageJson.name !== EXPECTED_PACKAGE || packageJson.version !== release.version) { + throw new Error(`Requested version must exactly match ${EXPECTED_PACKAGE} in package.json.`); + } + + admitOwner(await jsonCommand(["gh", "api", "user"], root, "Verify GitHub authentication")); + admitRepository( + await jsonCommand(["gh", "api", `repos/${EXPECTED_REPOSITORY}`], root, "Verify GitHub repository"), + ); + const rulesetList = await jsonCommand( + ["gh", "api", "--method", "GET", `repos/${EXPECTED_REPOSITORY}/rulesets`, "-f", "per_page=100"], + root, + "Read release rulesets", + ); + const rulesetDetails = new Map(); + for (const [name, id] of releaseRulesetIdentities(rulesetList)) { + rulesetDetails.set( + name, + await jsonCommand(["gh", "api", `repos/${EXPECTED_REPOSITORY}/rulesets/${String(id)}`], root, `Verify ${name}`), + ); + } + admitReleaseRulesets(rulesetList, rulesetDetails); + admitReleaseEnvironment( + await jsonCommand( + ["gh", "api", `repos/${EXPECTED_REPOSITORY}/environments/${RELEASE_ENVIRONMENT}`], + root, + "Verify npm release environment", + ), + await jsonCommand( + [ + "gh", "api", "--method", "GET", + `repos/${EXPECTED_REPOSITORY}/environments/${RELEASE_ENVIRONMENT}/deployment-branch-policies`, + "-f", "per_page=100", + ], + root, + "Verify npm release deployment policies", + ), + ); + const sha = await refreshMain(root); + admitProtectedBranch( + await jsonCommand(["gh", "api", `repos/${EXPECTED_REPOSITORY}/branches/${DEFAULT_BRANCH}`], root, "Verify protected main"), + sha, + ); + const workflowId = admitActiveCiWorkflow( + await jsonCommand( + ["gh", "api", `repos/${EXPECTED_REPOSITORY}/actions/workflows/ci.yml`], + root, + "Verify active CI workflow", + ), + ); + const runInventory = await jsonCommand( + [ + "gh", "api", "--method", "GET", `repos/${EXPECTED_REPOSITORY}/actions/workflows/ci.yml/runs`, + "-f", `branch=${DEFAULT_BRANCH}`, "-f", "event=push", "-f", `head_sha=${sha}`, "-F", "per_page=100", + ], + root, + "Read exact CI run", + ); + const run = admitCiRun(runInventory, workflowId, sha); + const jobs = await jsonCommand( + [ + "gh", "api", "--method", "GET", + `repos/${EXPECTED_REPOSITORY}/actions/runs/${String(run.runId)}/attempts/${String(run.runAttempt)}/jobs`, + "-F", "per_page=100", + ], + root, + "Read exact CI attempt jobs", + ); + const jobId = admitCiRequiredJob(jobs, run, sha); + + const firstAdmission = admitRemoteReleaseTags(await remoteTagInventory(root), release.version, sha); + if (firstAdmission === "same-annotated-commit") { + console.log(`${release.tag} already exists as the exact annotated release tag at ${sha}.`); + return; + } + + await refreshMain(root, sha); + admitProtectedBranch( + await jsonCommand(["gh", "api", `repos/${EXPECTED_REPOSITORY}/branches/${DEFAULT_BRANCH}`], root, "Reverify protected main"), + sha, + ); + const secondAdmission = admitRemoteReleaseTags(await remoteTagInventory(root), release.version, sha); + if (secondAdmission === "same-annotated-commit") { + console.log(`${release.tag} was concurrently created as the exact annotated release tag at ${sha}.`); + return; + } + + const releaseMessage = `Release ${EXPECTED_PACKAGE}@${release.version}`; + const localLookup = await command(["git", "show-ref", "--verify", "--quiet", `refs/tags/${release.tag}`], { + allowFailure: true, + cwd: root, + label: `Check local ${release.tag}`, + }); + if (localLookup.exitCode === 0) { + throw new Error(`Local ${release.tag} already exists while the remote tag is absent; refusing an inherited tag object.`); + } + if (localLookup.exitCode !== 1) { + throw new Error(`Could not determine whether local ${release.tag} exists.`); + } + + await command(["git", "tag", "--annotate", release.tag, sha, "--message", releaseMessage], { + cwd: root, + label: `Create local ${release.tag}`, + }); + const createdTagObject = ( + await command(["git", "rev-parse", `refs/tags/${release.tag}`], { + cwd: root, + label: `Read created ${release.tag} object`, + }) + ).stdout.trim(); + if (!SHA.test(createdTagObject)) throw new Error(`Created ${release.tag} has an invalid object identity.`); + + try { + await requireLocalTag(root, release.tag, sha, releaseMessage); + const push = await command( + ["git", "push", "origin", `refs/tags/${release.tag}:refs/tags/${release.tag}`], + { allowFailure: true, cwd: root, label: `Push exact ${release.tag} ref` }, + ); + const finalAdmission = admitRemoteReleaseTags(await remoteTagInventory(root), release.version, sha); + if (finalAdmission !== "same-annotated-commit") { + const detail = push.stderr.trim().slice(0, 2_000); + throw new Error(`Exact release tag push did not produce the requested remote tag${detail === "" ? "." : `: ${detail}`}`); + } + } catch (error) { + const cleanup = await command( + ["git", "update-ref", "-d", `refs/tags/${release.tag}`, createdTagObject], + { allowFailure: true, cwd: root, label: `Compare-delete unverified local ${release.tag}` }, + ); + if (cleanup.exitCode !== 0) { + throw new AggregateError( + [error, new Error(`Local ${release.tag} changed after creation and was not deleted.`)], + `Release-tag publication failed and safe local cleanup was not possible.`, + ); + } + throw error; + } + console.log( + `${release.tag} is the exact annotated release tag at ${sha}; CI run ${String(run.runId)} attempt ${String(run.runAttempt)} Required job ${String(jobId)} was successful.`, + ); +} + +if (import.meta.main) { + await main(); +}