From 152afda896b455f45b99f9edb7f590d6ee38caa9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Oct 2026 20:12:49 -0700 Subject: [PATCH] ci: verify signed multi-channel releases before publication --- .github/release-allowed-signers | 1 + .github/workflows/homebrew-release.yml | 63 ++++ .github/workflows/npm-release.yml | 304 --------------- .github/workflows/release.yml | 495 ++++++++++--------------- RELEASING.md | 20 + scripts/build-npm-package.mjs | 10 + scripts/build-npm-package.test.mjs | 11 - scripts/release-preflight.py | 70 ++++ scripts/sign-release-macos.sh | 42 +++ scripts/test_release_preflight.py | 32 ++ 10 files changed, 430 insertions(+), 618 deletions(-) create mode 100644 .github/release-allowed-signers create mode 100644 .github/workflows/homebrew-release.yml delete mode 100644 .github/workflows/npm-release.yml create mode 100644 RELEASING.md create mode 100644 scripts/release-preflight.py create mode 100644 scripts/sign-release-macos.sh create mode 100644 scripts/test_release_preflight.py diff --git a/.github/release-allowed-signers b/.github/release-allowed-signers new file mode 100644 index 0000000..e8e38b8 --- /dev/null +++ b/.github/release-allowed-signers @@ -0,0 +1 @@ +release namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII9XsaCcr8TInPnHcuTVfvXXcsoUFrOE7menfbEIHFW9 steipete@gmail.com diff --git a/.github/workflows/homebrew-release.yml b/.github/workflows/homebrew-release.yml new file mode 100644 index 0000000..33eaf0f --- /dev/null +++ b/.github/workflows/homebrew-release.yml @@ -0,0 +1,63 @@ +name: Update Homebrew Tap + +on: + workflow_call: + inputs: + tag: + required: true + type: string + secrets: + HOMEBREW_TAP_TOKEN: + required: true + +permissions: + contents: read + +jobs: + update-homebrew-tap: + runs-on: ubuntu-latest + steps: + - name: Resolve release tag + id: release + shell: bash + run: | + set -euo pipefail + tag="${{ inputs.tag || github.ref_name }}" + if [[ -z "$tag" ]]; then + echo "Missing release tag." >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "request_id=clawscan-${tag}-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" + + - name: Dispatch tap update + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + shell: bash + run: | + set -euo pipefail + test -n "$GH_TOKEN" + gh workflow run update-formula.yml \ + --repo openclaw/homebrew-tap \ + -f formula=clawscan \ + -f tag="${{ steps.release.outputs.tag }}" \ + -f repository=openclaw/clawscan \ + -f artifact_template='{formula}_{tag}_{target}.tar.gz' \ + -f request_id="${{ steps.release.outputs.request_id }}" + + - name: Wait for tap update + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + shell: bash + run: | + set -euo pipefail + for _ in {1..20}; do + run_id="$(gh run list --repo openclaw/homebrew-tap --workflow update-formula.yml --json databaseId,displayTitle --jq '.[] | select(.displayTitle | contains("${{ steps.release.outputs.request_id }}")) | .databaseId' | head -n1)" + if [[ -n "$run_id" ]]; then + gh run watch "$run_id" --repo openclaw/homebrew-tap --exit-status + exit 0 + fi + sleep 5 + done + echo "Timed out waiting for tap workflow to appear." >&2 + exit 1 diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml deleted file mode 100644 index 8cceb04..0000000 --- a/.github/workflows/npm-release.yml +++ /dev/null @@ -1,304 +0,0 @@ -name: ClawScan Binary NPM Promotion - -on: - workflow_dispatch: - inputs: - tag: - description: Release tag to publish, for example v0.1.0 - required: true - type: string - preflight_only: - description: Run validation/build only and skip the gated publish job - required: true - default: true - type: boolean - preflight_run_id: - description: Existing successful preflight workflow run id to promote without rebuilding - required: false - type: string - -concurrency: - group: clawscan-npm-release-${{ inputs.tag }} - cancel-in-progress: false - -permissions: {} - -env: - NODE_VERSION: "24.x" - -jobs: - preflight_clawscan_npm: - if: ${{ inputs.preflight_only }} - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Forbid preflight artifact promotion on validation-only runs - if: ${{ inputs.preflight_run_id != '' }} - run: | - echo "preflight_run_id is only valid for real publish runs." - exit 1 - - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: refs/tags/${{ inputs.tag }} - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: go.mod - cache: true - - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: ${{ env.NODE_VERSION }} - registry-url: https://registry.npmjs.org - - - name: Test npm packaging helpers - run: | - node --test npm/clawscan/test/*.test.mjs - node --test scripts/build-npm-package.test.mjs - - - name: Build and smoke packed npm package - run: node scripts/build-npm-package.mjs --version "${{ inputs.tag }}" --pack --smoke - - - name: Upload prepared npm publish bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: clawscan-npm-preflight-${{ inputs.tag }} - path: | - dist/npm/*.tgz - dist/npm/release-tag.txt - dist/npm/release-sha.txt - dist/npm/package-version.txt - if-no-files-found: error - - validate_publish_request: - if: ${{ !inputs.preflight_only }} - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Require main workflow ref for publish - env: - WORKFLOW_REF: ${{ github.ref }} - run: | - set -euo pipefail - if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then - echo "Real npm publish runs must be dispatched from main. Use preflight_only=true for branch validation." - exit 1 - fi - - - name: Require preflight artifact promotion on real publish - env: - PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} - run: | - set -euo pipefail - if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then - echo "Real npm publish requires preflight_run_id from a successful npm preflight run." >&2 - exit 1 - fi - - publish_clawscan_npm: - needs: [validate_publish_request] - if: ${{ !inputs.preflight_only }} - runs-on: ubuntu-latest - environment: npm-release - permissions: - actions: read - contents: read - id-token: write - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: refs/tags/${{ inputs.tag }} - fetch-depth: 0 - - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: ${{ env.NODE_VERSION }} - registry-url: https://registry.npmjs.org - - - name: Ensure npm supports trusted publishing - run: | - npm install -g npm@^12.0.2 - node --version - npm --version - - - name: Verify preflight run metadata - env: - GH_TOKEN: ${{ github.token }} - PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} - run: | - set -euo pipefail - RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)" - # shellcheck disable=SC2016 - printf '%s' "$RUN_JSON" | node --input-type=module -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const run = JSON.parse(Buffer.concat(chunks).toString("utf8")); const checks = [["workflowName", "ClawScan Binary NPM Promotion"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? ""}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`); });' - - - name: Download prepared npm tarball - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: clawscan-npm-preflight-${{ inputs.tag }} - path: dist/npm - repository: ${{ github.repository }} - run-id: ${{ inputs.preflight_run_id }} - github-token: ${{ github.token }} - - - name: Verify prepared tarball provenance - env: - RELEASE_TAG: ${{ inputs.tag }} - run: | - set -euo pipefail - EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)" - EXPECTED_PACKAGE_VERSION="$(node --input-type=module <<'EOF' - import { normalizePackageVersion } from "./scripts/build-npm-package.mjs"; - process.stdout.write(normalizePackageVersion(process.env.RELEASE_TAG)); - EOF - )" - EXPECTED_DIST_TAG="$(node --input-type=module <<'EOF' - import { npmDistTagForVersion } from "./scripts/build-npm-package.mjs"; - process.stdout.write(npmDistTagForVersion(process.env.RELEASE_TAG)); - EOF - )" - TAG_FILE="dist/npm/release-tag.txt" - SHA_FILE="dist/npm/release-sha.txt" - VERSION_FILE="dist/npm/package-version.txt" - if [[ ! -f "$TAG_FILE" || ! -f "$SHA_FILE" || ! -f "$VERSION_FILE" ]]; then - echo "Prepared preflight metadata is missing." >&2 - ls -la dist/npm >&2 || true - exit 1 - fi - ARTIFACT_RELEASE_TAG="$(tr -d '\r\n' < "$TAG_FILE")" - ARTIFACT_RELEASE_SHA="$(tr -d '\r\n' < "$SHA_FILE")" - ARTIFACT_PACKAGE_VERSION="$(tr -d '\r\n' < "$VERSION_FILE")" - if [[ "$ARTIFACT_RELEASE_TAG" != "$RELEASE_TAG" ]]; then - echo "Prepared preflight tag mismatch: expected $RELEASE_TAG, got $ARTIFACT_RELEASE_TAG" >&2 - exit 1 - fi - if [[ "$ARTIFACT_RELEASE_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then - echo "Prepared preflight SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $ARTIFACT_RELEASE_SHA" >&2 - exit 1 - fi - if [[ "$ARTIFACT_PACKAGE_VERSION" != "$EXPECTED_PACKAGE_VERSION" ]]; then - echo "Prepared preflight package version mismatch: expected $EXPECTED_PACKAGE_VERSION, got $ARTIFACT_PACKAGE_VERSION" >&2 - exit 1 - fi - echo "PACKAGE_VERSION=$EXPECTED_PACKAGE_VERSION" >> "$GITHUB_ENV" - echo "NPM_DIST_TAG=$EXPECTED_DIST_TAG" >> "$GITHUB_ENV" - - - name: Resolve ClawScan publish tarball - id: publish_tarball - run: | - set -euo pipefail - CLAWSCAN_TARBALL="dist/npm/openclaw-clawscan-${PACKAGE_VERSION}.tgz" - if [[ ! -f "$CLAWSCAN_TARBALL" ]]; then - echo "Prepared ClawScan preflight tarball was not present." >&2 - ls -la dist/npm >&2 || true - exit 1 - fi - echo "path=$CLAWSCAN_TARBALL" >> "$GITHUB_OUTPUT" - - - name: Inspect ClawScan npm publish state - id: publish_state - run: | - set -euo pipefail - package_name="@openclaw/clawscan" - tarball_path="${{ steps.publish_tarball.outputs.path }}" - published_version="" - view_output="" - view_status=0 - set +e - view_output="$(npm view "${package_name}@${PACKAGE_VERSION}" version 2>&1)" - view_status=$? - set -e - if [[ "$view_status" -eq 0 && -n "$view_output" ]]; then - published_version="$view_output" - if [[ "$published_version" != "$PACKAGE_VERSION" ]]; then - echo "${package_name}@${PACKAGE_VERSION} reported unexpected version ${published_version}." >&2 - exit 1 - fi - remote_integrity="" - local_integrity="" - remote_integrity="$(npm view "${package_name}@${PACKAGE_VERSION}" dist.integrity)" - # shellcheck disable=SC2016 - local_integrity="$(node --input-type=module -e ' - import { createHash } from "node:crypto"; - import { readFileSync } from "node:fs"; - const digest = createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"); - process.stdout.write(`sha512-${digest}`); - ' "$tarball_path")" - if [[ "$remote_integrity" != "$local_integrity" ]]; then - echo "${package_name}@${PACKAGE_VERSION} does not match the prepared release tarball." >&2 - exit 1 - fi - echo "${package_name}@${PACKAGE_VERSION} is already published with valid release metadata; skipping publish." - echo "clawscan_needed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "$view_status" -eq 0 ]]; then - echo "${package_name}@${PACKAGE_VERSION} returned an empty publish-state response." >&2 - exit 1 - fi - if ! printf '%s\n' "$view_output" | grep -q "E404"; then - printf '%s\n' "$view_output" >&2 - exit "$view_status" - fi - echo "${package_name}@${PACKAGE_VERSION} is not published yet." - echo "clawscan_needed=true" >> "$GITHUB_OUTPUT" - - - name: Publish ClawScan binary package - if: steps.publish_state.outputs.clawscan_needed == 'true' - run: npm publish "${{ steps.publish_tarball.outputs.path }}" --access public --provenance --tag "$NPM_DIST_TAG" - - - name: Verify ClawScan npm release metadata - run: | - set -euo pipefail - NPM_DIST_JSON="" - # npm can accept a publish before asynchronous registry processing finishes. - for attempt in {1..60}; do - if NPM_DIST_JSON="$(npm view "@openclaw/clawscan@${PACKAGE_VERSION}" dist.tarball dist.integrity --json 2>/tmp/npm-view-error)" && [[ -n "$NPM_DIST_JSON" ]]; then - break - fi - if [[ "$attempt" == "60" ]]; then - cat /tmp/npm-view-error >&2 || true - exit 1 - fi - sleep 10 - done - printf '%s\n' "$NPM_DIST_JSON" - - TAGGED_VERSION="" - for attempt in {1..60}; do - if TAGGED_VERSION="$(npm view "@openclaw/clawscan@${NPM_DIST_TAG}" version 2>/tmp/npm-tag-view-error)" && - [[ "$TAGGED_VERSION" == "$PACKAGE_VERSION" ]]; then - break - fi - if [[ "$attempt" == "60" ]]; then - cat /tmp/npm-tag-view-error >&2 || true - echo "Expected dist-tag ${NPM_DIST_TAG} to resolve to ${PACKAGE_VERSION}, got ${TAGGED_VERSION:-}." >&2 - exit 1 - fi - sleep 10 - done - - if [[ "$NPM_DIST_TAG" == "next" ]]; then - LATEST_VERSION="" - LATEST_STATUS=0 - set +e - LATEST_VERSION="$(npm view "@openclaw/clawscan@latest" version 2>/tmp/npm-latest-view-error)" - LATEST_STATUS=$? - set -e - if [[ "$LATEST_STATUS" -eq 0 && "$LATEST_VERSION" == "$PACKAGE_VERSION" ]]; then - echo "Prerelease ${PACKAGE_VERSION} must not be assigned to the latest dist-tag." >&2 - exit 1 - fi - if [[ "$LATEST_STATUS" -ne 0 ]] && ! grep -q "E404" /tmp/npm-latest-view-error; then - cat /tmp/npm-latest-view-error >&2 - exit "$LATEST_STATUS" - fi - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d24b41..c81f479 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,347 +2,236 @@ name: Release on: push: - tags: - - "v*.*.*" + tags: ['v*'] workflow_dispatch: inputs: - tag: - description: Tag name to use when publishing a manual release. - required: false - type: string - publish: - description: Publish a GitHub Release instead of only building artifacts. - required: true - default: false + dry_run: + description: Validate and build without publishing type: boolean + default: true + version: + description: Dated changelog version to verify (blank uses the latest dated section) + type: string + required: false -permissions: - contents: write - +permissions: {} concurrency: - group: release-${{ github.ref }} + group: release-${{ github.event.inputs.version || github.ref_name }} cancel-in-progress: false -env: - NODE_VERSION: 24 - jobs: build: - name: Build release artifacts - runs-on: ubuntu-latest + runs-on: macos-15 + permissions: + actions: read + contents: read outputs: - checkout_ref: ${{ steps.version.outputs.checkout_ref }} - version: ${{ steps.version.outputs.value }} + tag: ${{ steps.release.outputs.tag }} + sha: ${{ steps.release.outputs.sha }} + tag_object: ${{ steps.release.outputs.tag_object }} steps: - - name: Resolve release version - id: version + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Test release guard + run: python3 -B scripts/test_release_preflight.py + - name: Validate source, changelog, signature, and exact-commit push CI + id: release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version || github.ref_name }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + CI_WORKFLOWS: ci.yml + PACKAGE_NAME: '@openclaw/clawscan' run: | - version="${GITHUB_REF_NAME}" - checkout_ref="${GITHUB_REF}" - if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - version="${{ inputs.tag }}" - if [ -n "$version" ]; then - checkout_ref="$version" - elif [ "${{ inputs.publish }}" = "true" ]; then - echo "Manual publishing requires the tag input." - exit 1 - else - version="manual-${GITHUB_RUN_NUMBER}" - fi - fi - echo "value=$version" >> "$GITHUB_OUTPUT" - echo "checkout_ref=$checkout_ref" >> "$GITHUB_OUTPUT" - - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$VERSION" = main ]; then export VERSION=; fi + python3 scripts/release-preflight.py + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - ref: ${{ steps.version.outputs.checkout_ref }} - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + node-version: '26.5.1' + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true - - - name: Build release artifacts - run: make release VERSION="${{ steps.version.outputs.value }}" - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Test and build release matrix + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + node --test npm/clawscan/test/*.test.mjs + node --test scripts/build-npm-package.test.mjs + make release VERSION="$TAG" + - name: Sign and notarize macOS payloads + env: + MACOS_SIGNING_P12: ${{ secrets.MACOS_SIGNING_P12 }} + MACOS_SIGNING_P12_PASSWORD: ${{ secrets.MACOS_SIGNING_P12_PASSWORD }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_PRIVATE_KEY_P8: ${{ secrets.ASC_PRIVATE_KEY_P8 }} + TAG: ${{ steps.release.outputs.tag }} + run: bash scripts/sign-release-macos.sh + - name: Pack the same signed release binaries into npm + env: + TAG: ${{ steps.release.outputs.tag }} + run: node scripts/build-npm-package.mjs --version "$TAG" --binary-root dist --pack --smoke + - name: Freeze verified channel payloads + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + mkdir release-assets + cp dist/*.tar.gz dist/*.zip release-assets/ + cp "dist/npm/openclaw-clawscan-${VERSION}.tgz" "release-assets/clawscan-${VERSION}.tgz" + cd release-assets + shasum -a 1 ./*.tgz > "clawscan-${VERSION}.tgz.sha1" + shasum -a 256 ./*.tgz > "clawscan-${VERSION}.tgz.sha256" + shasum -a 256 ./*.tgz ./*.tar.gz ./*.zip > checksums.txt + cp checksums.txt SHA256SUMS + shasum -a 256 -c checksums.txt + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: clawscan-${{ steps.version.outputs.value }}-release + name: release-${{ github.sha }} path: | - dist/*.tar.gz - dist/*.zip - dist/checksums.txt + release-assets/ + release-notes.md if-no-files-found: error - publish: - name: Publish GitHub Release - runs-on: ubuntu-latest - needs: [build, publish-npm] - if: github.event_name == 'push' || inputs.publish + verify: + needs: build + strategy: + matrix: + include: + - runner: macos-15 + arch: arm64 + npm_arch: arm64 + - runner: macos-15-intel + arch: amd64 + npm_arch: x64 + runs-on: ${{ matrix.runner }} + permissions: + actions: read steps: - - name: Checkout release notes - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.build.outputs.checkout_ref }} - - - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - path: dist - merge-multiple: true - - - name: Publish release + name: release-${{ github.sha }} + path: payload + - name: Verify signatures and identical npm payload env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.build.outputs.version }} + TAG: ${{ needs.build.outputs.tag }} + ARCH: ${{ matrix.arch }} + NPM_ARCH: ${{ matrix.npm_arch }} run: | - npm view "@openclaw/clawscan@${RELEASE_TAG#v}" --json > npm-metadata.json - node scripts/release-notes.mjs "$RELEASE_TAG" npm-metadata.json > release-notes.md - gh release create "$RELEASE_TAG" dist/* \ - --repo "$GITHUB_REPOSITORY" \ - --title "$RELEASE_TAG" \ - --notes-file release-notes.md - - publish-npm: - name: Publish ClawScan npm package + set -euo pipefail + cd payload/release-assets + shasum -a 256 -c checksums.txt + mkdir native npm + tar -xzf "clawscan_${TAG}_darwin_${ARCH}.tar.gz" -C native + tar -xzf "clawscan-${TAG#v}.tgz" -C npm + binary="native/clawscan_${TAG}_darwin_${ARCH}/clawscan" + cmp "$binary" "npm/package/binaries/darwin-${NPM_ARCH}/clawscan" + codesign --verify --strict --check-notarization -R=notarized "$binary" + codesign -dv --verbose=4 "$binary" 2>&1 | grep -Fx 'TeamIdentifier=FWJYW4S8P8' + "$binary" --version + "$binary" --help + + npm: + timeout-minutes: 45 + if: github.event_name == 'push' + needs: [build, verify] runs-on: ubuntu-latest - needs: build - if: github.event_name == 'push' || inputs.publish - timeout-minutes: 20 + outputs: + dist_tag: ${{ steps.publish.outputs.dist_tag }} permissions: contents: read id-token: write steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - ref: ${{ needs.build.outputs.checkout_ref }} - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: go.mod - cache: true - - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + node-version: '26.5.1' + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - node-version: ${{ env.NODE_VERSION }} - registry-url: https://registry.npmjs.org - - - name: Validate package metadata - run: | - node <<'NODE' - const { readFileSync } = require("node:fs"); - - const pkg = JSON.parse(readFileSync("npm/clawscan/package.json", "utf8")); - const expectedName = "@openclaw/clawscan"; - const expectedRepo = "https://github.com/openclaw/clawscan"; - const repository = typeof pkg.repository === "string" - ? pkg.repository - : pkg.repository?.url; - const normalize = (value) => - String(value ?? "") - .trim() - .replace(/^git\+/, "") - .replace(/\.git$/i, "") - .replace(/\/+$/, ""); - - const errors = []; - if (pkg.name !== expectedName) { - errors.push(`package name must be ${expectedName}; found ${pkg.name ?? ""}`); - } - if (normalize(repository) !== expectedRepo) { - errors.push(`repository URL must be ${expectedRepo}; found ${normalize(repository) || ""}`); - } - if (pkg.private === true) { - errors.push("package must not be private"); - } - if (errors.length > 0) { - for (const error of errors) console.error(error); - process.exit(1); - } - NODE - - - name: Validate release tag + name: release-${{ github.sha }} + path: payload + - name: Publish immutable tarball through npm OIDC + id: publish env: - RELEASE_TAG: ${{ needs.build.outputs.version }} + TAG: ${{ needs.build.outputs.tag }} + PACKAGE_NAME: '@openclaw/clawscan' + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/release.npmrc run: | set -euo pipefail - git fetch --no-tags origin main - - node --input-type=module <<'NODE' - import { execFileSync } from "node:child_process"; - const { normalizePackageVersion } = await import("./scripts/build-npm-package.mjs"); - - const releaseTag = process.env.RELEASE_TAG ?? ""; - const releaseSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); - - if (!/^v\d+\.\d+\.\d+$/.test(releaseTag)) { - throw new Error(`release tag must match vX.Y.Z; received ${releaseTag || ""}`); - } - normalizePackageVersion(releaseTag); - execFileSync("git", ["merge-base", "--is-ancestor", releaseSha, "origin/main"]); - NODE - - - name: Check npm package - run: | - node --test npm/clawscan/test/*.test.mjs - node --test scripts/build-npm-package.test.mjs - node scripts/build-npm-package.mjs --version "${{ needs.build.outputs.version }}" --pack --smoke - - - name: Inspect npm publish state - id: publish_state - env: - RELEASE_TAG: ${{ needs.build.outputs.version }} - run: | - set -euo pipefail - package_version="$(node --input-type=module -e 'import { normalizePackageVersion } from "./scripts/build-npm-package.mjs"; process.stdout.write(normalizePackageVersion(process.env.RELEASE_TAG));')" - inspect_package() { - local package_name="$1" - local tarball_path="$2" - local output_name="$3" - local published_version="" - local view_output="" - local view_status=0 - set +e - view_output="$(npm view "${package_name}@${package_version}" version 2>&1)" - view_status=$? - set -e - if [[ "$view_status" -eq 0 && -n "$view_output" ]]; then - published_version="$view_output" - local remote_integrity="" - local local_integrity="" - remote_integrity="$(npm view "${package_name}@${package_version}" dist.integrity)" - # shellcheck disable=SC2016 - local_integrity="$(node --input-type=module -e ' - import { createHash } from "node:crypto"; - import { readFileSync } from "node:fs"; - const digest = createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"); - process.stdout.write(`sha512-${digest}`); - ' "$tarball_path")" - if [[ "$published_version" != "$package_version" || "$remote_integrity" != "$local_integrity" ]]; then - echo "${package_name}@${package_version} does not match the prepared release tarball." >&2 - exit 1 - fi - echo "${package_name}@${package_version} already matches the prepared release tarball; skipping publish." - echo "${output_name}=false" >> "$GITHUB_OUTPUT" - return - fi - if [[ "$view_status" -eq 0 ]]; then - echo "${package_name}@${package_version} returned an empty publish-state response." >&2 - exit 1 - fi - if ! printf '%s\n' "$view_output" | grep -q "E404"; then - printf '%s\n' "$view_output" >&2 - return "$view_status" + npm install --global npm@11.20.0 + version=${TAG#v} + if npm view "$PACKAGE_NAME@$version" version --json >"$RUNNER_TEMP/npm-existing.json" 2>"$RUNNER_TEMP/npm-view.err"; then + echo "Version already exists; refusing to publish" >&2; exit 1; + fi + grep -q E404 "$RUNNER_TEMP/npm-view.err" + cd payload/release-assets + sha256sum -c SHA256SUMS + latest=$(npm view "$PACKAGE_NAME@latest" version) + dist_tag=$(node -e ' + const [version, latest] = process.argv.slice(1); + if (version.includes("-")) { console.log("next"); process.exit(); } + const a=version.split(".").map(Number), b=latest.split(".").map(Number); + const first=a.findIndex((n,i)=>n!==b[i]); + console.log(first>=0 && a[first]> "$GITHUB_OUTPUT" + env -u NPM_TOKEN -u NODE_AUTH_TOKEN npm publish "clawscan-$version.tgz" --access public --provenance --tag "$dist_tag" + expected="sha512-$(openssl dgst -sha512 -binary "clawscan-$version.tgz" | base64 -w0)" + actual= + for _ in {1..120}; do + if actual=$(npm view "$PACKAGE_NAME@$version" dist.integrity 2>"$RUNNER_TEMP/npm-propagation.err"); then + [[ -z "$actual" ]] || break + else + grep -q E404 "$RUNNER_TEMP/npm-propagation.err" || { cat "$RUNNER_TEMP/npm-propagation.err" >&2; exit 1; } fi - echo "${output_name}=true" >> "$GITHUB_OUTPUT" - } - inspect_package \ - "@openclaw/clawscan" \ - "dist/npm/openclaw-clawscan-${package_version}.tgz" \ - "clawscan_needed" - echo "package_version=$package_version" >> "$GITHUB_OUTPUT" - - - name: Publish ClawScan binary package - if: steps.publish_state.outputs.clawscan_needed == 'true' - run: npm publish "dist/npm/openclaw-clawscan-${{ steps.publish_state.outputs.package_version }}.tgz" --access public --provenance - - - name: Verify npm release metadata - run: | - set -euo pipefail - for package_name in "@openclaw/clawscan"; do - npm_dist_json="" - # npm can accept a publish before asynchronous registry processing finishes. - for attempt in {1..60}; do - if npm_dist_json="$(npm view "${package_name}@${{ steps.publish_state.outputs.package_version }}" dist.tarball dist.integrity --json 2>/tmp/npm-view-error)" && [[ -n "$npm_dist_json" ]]; then - break - fi - if [[ "$attempt" == "60" ]]; then - cat /tmp/npm-view-error >&2 || true - exit 1 - fi - sleep 10 - done - printf '%s\n' "$npm_dist_json" + sleep 15 done + test "$actual" = "$expected" + npm view "$PACKAGE_NAME@$version" dist.tarball dist.integrity dist.attestations --json - update-homebrew-tap: - name: Update Homebrew tap + publish: + if: github.event_name == 'push' + needs: [build, verify, npm] runs-on: ubuntu-latest - needs: - - build - - publish - - publish-npm - if: always() && needs.publish.result == 'success' && needs.publish-npm.result == 'success' && startsWith(needs.build.outputs.version, 'v') - env: - TAP_REPO: openclaw/homebrew-tap - FORMULA: clawscan - DESCRIPTION: Agent-skill security scanner harness for ClawHub + permissions: + actions: read + contents: write steps: - - name: Dispatch tap formula update + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ needs.build.outputs.sha }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-${{ github.sha }} + path: payload + - name: Reverify immutable tag and publish exact archive env: - HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.build.outputs.version }} + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.build.outputs.tag }} + TARGET_SHA: ${{ needs.build.outputs.sha }} + TAG_OBJECT: ${{ needs.build.outputs.tag_object }} + DIST_TAG: ${{ needs.npm.outputs.dist_tag }} run: | set -euo pipefail - - export GH_TOKEN="${HOMEBREW_TAP_TOKEN:-${HOMEBREW_TAP_GITHUB_TOKEN:-}}" - if [ -z "${GH_TOKEN}" ]; then - echo "::warning::Homebrew tap update skipped; set HOMEBREW_TAP_TOKEN with workflow access to ${TAP_REPO}." - exit 0 - fi - - if ! can_push="$(gh api "repos/${TAP_REPO}" --jq '.permissions.push // false')"; then - echo "::warning::Homebrew tap update skipped; token cannot access ${TAP_REPO}." - exit 0 - fi - if [ "${can_push}" != "true" ]; then - echo "::warning::Homebrew tap update skipped; token needs push access to ${TAP_REPO}." - exit 0 - fi - - request_id="${FORMULA}-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - expected_title="Update ${FORMULA} for ${RELEASE_TAG} (${request_id})" - - gh workflow run update-formula.yml \ - --repo "${TAP_REPO}" \ - --ref main \ - -f formula="${FORMULA}" \ - -f tag="${RELEASE_TAG}" \ - -f repository="${GITHUB_REPOSITORY}" \ - -f description="${DESCRIPTION}" \ - -f artifact_template="{formula}_{tag}_{target}.tar.gz" \ - -f request_id="${request_id}" - - run_id="" - for _ in {1..30}; do - run_id=$(gh run list \ - --repo "${TAP_REPO}" \ - --workflow update-formula.yml \ - --branch main \ - --event workflow_dispatch \ - --limit 20 \ - --json databaseId,displayTitle \ - --jq ".[] | select(.displayTitle == \"${expected_title}\") | .databaseId" | head -n1) - if [ -n "${run_id}" ]; then - break - fi - sleep 5 - done - - if [ -z "${run_id}" ]; then - echo "::error::Could not find tap workflow run with title: ${expected_title}" - exit 1 - fi - - gh run watch "${run_id}" \ - --repo "${TAP_REPO}" \ - --exit-status \ - --interval 10 + git fetch --no-tags origin "refs/tags/$TAG:refs/audit/tag" + test "$(git rev-parse refs/audit/tag)" = "$TAG_OBJECT" + test "$(git rev-parse refs/audit/tag^{})" = "$TARGET_SHA" + git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=.github/release-allowed-signers verify-tag refs/audit/tag + (cd payload/release-assets && sha256sum -c SHA256SUMS) + flags=() + [[ "$TAG" != *-* ]] || flags+=(--prerelease) + [[ "$DIST_TAG" = latest ]] || flags+=(--latest=false) + gh release create "$TAG" payload/release-assets/* --verify-tag --title "$TAG" --notes-file payload/release-notes.md "${flags[@]}" + + homebrew: + if: github.event_name == 'push' && needs.npm.outputs.dist_tag == 'latest' + needs: [build, publish, npm] + permissions: + contents: read + uses: ./.github/workflows/homebrew-release.yml + with: + tag: ${{ needs.build.outputs.tag }} + secrets: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..8529fa2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,20 @@ +# Releases + +`.github/workflows/release.yml` owns all shipped channels: GitHub archives for +Darwin, Linux and Windows, the `@openclaw/clawscan` npm package, and the OpenClaw +Homebrew tap. Prepare a dated changelog section on protected main and wait for +green push CI on the exact commit. With explicit release authorization, push an +annotated SSH-signed `vX.Y.Z` tag allowed by `.github/release-allowed-signers`. +The workflow builds once, signs and notarizes Darwin binaries, packs those same +binaries in npm, smokes the installed package, publishes through npm OIDC, and +creates a GitHub Release with changelog notes and checksums before the tap handoff. + +Dispatch from main with `dry_run=true` (default) to run the same build, signing, +packing and smoke without publishing or creating tags. The separate npm artifact +promotion workflow is retired. Existing npm versions and GitHub Releases fail +closed; no overwrite or fallback publication is supported. + +Secrets: `MACOS_SIGNING_P12`, `MACOS_SIGNING_P12_PASSWORD`, `ASC_KEY_ID`, +`ASC_ISSUER_ID`, `ASC_PRIVATE_KEY_P8`, and `HOMEBREW_TAP_TOKEN`. npm trusted +publishing names `openclaw/clawscan` and `release.yml`; no npm token is used. +Local `make release` and package smoke helpers remain build/verification only. diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index 261faaf..0346c6b 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -102,6 +102,10 @@ function parseArgs(argv) { options.outDir = resolve(argv[++index]); continue; } + if (arg === "--binary-root") { + options.binaryRoot = resolve(argv[++index]); + continue; + } if (arg === "--platform") { options.target = packageTargetForPlatform(argv[++index]); continue; @@ -160,6 +164,11 @@ async function stagePackage(options) { for (const target of options.target ? [options.target] : packageTargets) { const binaryDir = join(packageOut, "binaries", platformKeyForTarget(target)); await mkdir(binaryDir, { recursive: true }); + if (options.binaryRoot) { + const source = join(options.binaryRoot, `clawscan_${binaryVersion}_${target.goos}_${target.goarch}`, binaryNameForTarget(target)); + await cp(source, join(binaryDir, binaryNameForTarget(target))); + await chmod(join(binaryDir, binaryNameForTarget(target)), 0o755); + } else { run( "go", [ @@ -180,6 +189,7 @@ async function stagePackage(options) { }, }, ); + } } await writeFile(join(options.outDir, "release-tag.txt"), `${binaryVersion}\n`); diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index 58d7f8e..b6e5b4f 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -146,14 +146,3 @@ describe("GitHub release target mapping", () => { }); }); -describe("npm promotion verification", () => { - it("checks the expected dist-tag even when publication is skipped", () => { - const workflow = readFileSync( - new URL("../.github/workflows/npm-release.yml", import.meta.url), - "utf8", - ); - - assert.match(workflow, /@openclaw\/clawscan@\$\{NPM_DIST_TAG\}/u); - assert.match(workflow, /Prerelease \$\{PACKAGE_VERSION\} must not be assigned to the latest/u); - }); -}); diff --git a/scripts/release-preflight.py b/scripts/release-preflight.py new file mode 100644 index 0000000..bd4259a --- /dev/null +++ b/scripts/release-preflight.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Validate immutable release source without publishing anything.""" +import datetime +import json +import os +from pathlib import Path +import re +import subprocess + + +def git(*args): + return subprocess.check_output(["git", *args], text=True).strip() + + +def api(path): + return json.loads(subprocess.check_output(["gh", "api", path], text=True)) + + +def notes_for(text, version): + matches = list(re.finditer(r"^## \[?" + re.escape(version) + r"\]? - (\d{4}-\d{2}-\d{2})\s*$", text, re.M)) + if len(matches) != 1: + raise ValueError("expected exactly one dated changelog section for " + version) + match = matches[0] + datetime.date.fromisoformat(match[1]) + notes = re.split(r"^## ", text[match.end():], maxsplit=1, flags=re.M)[0].strip() + if not notes: + raise ValueError("release notes are empty") + return notes + "\n" + + +def main(): + dry = os.environ.get("DRY_RUN") == "true" + text = Path("CHANGELOG.md").read_text() + version = os.environ.get("VERSION", "").removeprefix("v") + if not version and dry: + version = re.search(r"^## \[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)\]? - \d{4}-\d{2}-\d{2}", text, re.M)[1] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?", version): + raise ValueError("release version must be SemVer") + tag = "v" + version + head = git("rev-parse", "HEAD") + repo = os.environ["GITHUB_REPOSITORY"] + branch = api(f"repos/{repo}/branches/main") + if not branch["protected"]: + raise ValueError("main must be protected") + git("merge-base", "--is-ancestor", head, "origin/main") + tag_object = "" + if dry: + if os.environ["GITHUB_REF"] != "refs/heads/main": + raise ValueError("dispatch dry runs from main") + else: + if os.environ["GITHUB_EVENT_NAME"] != "push" or os.environ["GITHUB_REF"] != f"refs/tags/{tag}": + raise ValueError("publish only from a signed version-tag push") + if git("cat-file", "-t", f"refs/tags/{tag}") != "tag" or git("rev-parse", f"refs/tags/{tag}^{{}}") != head: + raise ValueError("release tag must be annotated and freeze HEAD") + tag_object = git("rev-parse", f"refs/tags/{tag}") + git("-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile=.github/release-allowed-signers", "verify-tag", tag) + releases = api(f"repos/{repo}/releases?per_page=100") + if any(r["tag_name"] == tag for r in releases): + raise ValueError("release already exists; never overwrite it") + for workflow in os.environ["CI_WORKFLOWS"].split(","): + runs = api(f"repos/{repo}/actions/workflows/{workflow}/runs?head_sha={head}&event=push&per_page=100")["workflow_runs"] + if not any(r["head_sha"] == head and r["event"] == "push" and r["conclusion"] == "success" for r in runs): + raise ValueError(f"no green push {workflow} on {head}") + Path("release-notes.md").write_text(notes_for(text, version)) + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"version={version}\ntag={tag}\nsha={head}\ntag_object={tag_object}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/sign-release-macos.sh b/scripts/sign-release-macos.sh new file mode 100644 index 0000000..721a96c --- /dev/null +++ b/scripts/sign-release-macos.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${MACOS_SIGNING_P12:?}" "${MACOS_SIGNING_P12_PASSWORD:?}" +: "${ASC_KEY_ID:?}" "${ASC_ISSUER_ID:?}" "${ASC_PRIVATE_KEY_P8:?}" "${TAG:?}" +keychain="$RUNNER_TEMP/clawscan-signing.keychain-db" +p12="$RUNNER_TEMP/clawscan.p12" +p8="$RUNNER_TEMP/clawscan-notary.p8" +original="$RUNNER_TEMP/clawscan-keychains.txt" +security list-keychains -d user > "$original" +cleanup() { + restored=() + while IFS= read -r entry; do + entry=${entry#"${entry%%[![:space:]]*}"}; entry=${entry#\"}; entry=${entry%\"} + [[ -z "$entry" ]] || restored+=("$entry") + done < "$original" + security list-keychains -d user -s "${restored[@]}" + security delete-keychain "$keychain" || true + rm -f "$p12" "$p8" "$original" +} +trap cleanup EXIT +phrase=$(openssl rand -hex 24) +printf '%s' "$MACOS_SIGNING_P12" | base64 -D > "$p12" +printf '%s' "$ASC_PRIVATE_KEY_P8" > "$p8" +chmod 600 "$p12" "$p8" +security create-keychain -p "$phrase" "$keychain" +security unlock-keychain -p "$phrase" "$keychain" +security import "$p12" -k "$keychain" -P "$MACOS_SIGNING_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$phrase" "$keychain" >/dev/null +security list-keychains -d user -s "$keychain" +for architecture in amd64 arm64; do + name="clawscan_${TAG}_darwin_${architecture}" + binary="dist/$name/clawscan" + codesign --force --options runtime --timestamp --identifier ai.openclaw.clawscan \ + --sign 'Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)' "$binary" + codesign --verify --strict "$binary" + zip -j "$RUNNER_TEMP/$name.zip" "$binary" + xcrun notarytool submit "$RUNNER_TEMP/$name.zip" --key "$p8" --key-id "$ASC_KEY_ID" \ + --issuer "$ASC_ISSUER_ID" --wait --output-format json > "$RUNNER_TEMP/$name-notary.json" + python3 -c 'import json,sys; assert json.load(open(sys.argv[1]))["status"] == "Accepted"' "$RUNNER_TEMP/$name-notary.json" + codesign --verify --strict --check-notarization -R=notarized "$binary" + tar -czf "dist/$name.tar.gz" -C dist "$name" +done diff --git a/scripts/test_release_preflight.py b/scripts/test_release_preflight.py new file mode 100644 index 0000000..50a7c62 --- /dev/null +++ b/scripts/test_release_preflight.py @@ -0,0 +1,32 @@ +import importlib.util +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('release', Path(__file__).with_name('release-preflight.py')) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + +class ReleasePreflightTests(unittest.TestCase): + def test_notes_require_unique_dated_nonempty_section(self): + self.assertEqual(release.notes_for('## 1.2.3 - 2026-10-12\n\n- Fix\n\n## 1.2.2 - 2026-10-11\n- Old\n', '1.2.3'), '- Fix\n') + for text in ['## 1.2.3 - Unreleased\n- Fix', '## 1.2.3 - 2026-10-12\n', '## 1.2.3 - 2026-10-12\n- Fix\n## 1.2.3 - 2026-10-12\n- Duplicate']: + with self.assertRaises(ValueError): release.notes_for(text, '1.2.3') + + def test_pull_request_checks_cannot_satisfy_push_gate(self): + with tempfile.TemporaryDirectory() as directory: + previous = os.getcwd() + os.chdir(directory) + try: + Path('CHANGELOG.md').write_text('## 1.2.3 - 2026-10-12\n- Fix\n') + Path('package.json').write_text('{"name":"fixture","version":"1.2.3"}') + def api(path): + if '/branches/' in path: return {'protected': True} + return {'workflow_runs': [{'head_sha':'a'*40,'event':'pull_request','conclusion':'success'}]} + with patch.dict(os.environ, {'DRY_RUN':'true','VERSION':'1.2.3','GITHUB_REPOSITORY':'owner/repo','GITHUB_REF':'refs/heads/main','CI_WORKFLOWS':'ci.yml','PACKAGE_NAME':'fixture'}, clear=True), patch.object(release,'git',return_value='a'*40), patch.object(release,'api',side_effect=api): + with self.assertRaisesRegex(ValueError,'no green push'): release.main() + finally: os.chdir(previous) + +if __name__ == '__main__': unittest.main()