diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml new file mode 100644 index 0000000..8e5f3df --- /dev/null +++ b/.github/workflows/release-npm.yml @@ -0,0 +1,360 @@ +name: release npm + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + release_ref: + description: Git ref to build and publish from. + required: true + default: refs/heads/main + type: string + release_version: + description: SemVer version for dry-run metadata (without v prefix). + required: true + default: 0.0.0-dryrun + type: string + npm_dist_tag: + description: npm dist-tag for dry-run publishes (for example next). + required: true + default: next + type: string + publish_to_npm: + description: Publish to npm with provenance (requires npm trusted publisher). + required: true + default: true + type: boolean + +permissions: + contents: write + id-token: write + +jobs: + test: + name: "IntentProof Release: Test npm Package" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_ref || github.ref }} + + - name: Checkout intentproof-tools (SPEC_REF source) + uses: actions/checkout@v6 + with: + repository: IntentProof/intentproof-tools + ref: main + path: intentproof-tools + + - name: Read pinned spec ref + id: spec_ref + run: | + ref="$(tr -d '[:space:]' < intentproof-tools/SPEC_REF)" + if ! echo "$ref" | grep -qE '^[0-9a-f]{40}$'; then + echo "Invalid SPEC_REF in intentproof-tools: '$ref'" >&2 + exit 1 + fi + echo "ref=$ref" >> "$GITHUB_OUTPUT" + + - name: Checkout intentproof-spec at pinned ref + uses: actions/checkout@v6 + with: + repository: IntentProof/intentproof-spec + ref: ${{ steps.spec_ref.outputs.ref }} + path: intentproof-spec + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Verify sdk-signing fixtures match pinned spec + env: + INTENTPROOF_SPEC_DIR: intentproof-spec + run: bash scripts/check-sdk-signing-fixtures-sync.sh + + - name: Run tests + env: + INTENTPROOF_SPEC_DIR: intentproof-spec + run: npm test + + pack: + name: "IntentProof Release: Pack npm Package" + needs: test + runs-on: ubuntu-latest + outputs: + artifact_paths: ${{ steps.pack.outputs.artifact_paths }} + release_ref: ${{ steps.release.outputs.release_ref }} + release_version: ${{ steps.release.outputs.release_version }} + npm_dist_tag: ${{ steps.release.outputs.npm_dist_tag }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_ref || github.ref }} + + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Resolve release metadata + id: release + env: + INPUT_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} + INPUT_RELEASE_REF: ${{ inputs.release_ref }} + INPUT_RELEASE_VERSION: ${{ inputs.release_version }} + run: | + set -euo pipefail + semver_tag='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + release_ref="$INPUT_RELEASE_REF" + release_version="$INPUT_RELEASE_VERSION" + npm_dist_tag="$INPUT_NPM_DIST_TAG" + else + release_ref="${GITHUB_REF}" + release_version="${GITHUB_REF_NAME#v}" + if [[ "$release_ref" =~ $semver_tag ]]; then + if [[ "$release_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + npm_dist_tag="latest" + else + npm_dist_tag="${release_version#*-}" + npm_dist_tag="${npm_dist_tag%%.*}" + fi + else + echo "tag ref must match vMAJOR.MINOR.PATCH: ${release_ref}" >&2 + exit 1 + fi + fi + + { + echo "release_ref=${release_ref}" + echo "release_version=${release_version}" + echo "npm_dist_tag=${npm_dist_tag}" + } >> "$GITHUB_OUTPUT" + + - name: Install dependencies + run: npm ci + + - name: Sync package version + run: npm version "${{ steps.release.outputs.release_version }}" --no-git-tag-version --allow-same-version + + - name: Build package + run: npm run build + + - name: Pack npm tarball + id: pack + run: | + set -euo pipefail + npm pack + mapfile -t tarballs < <(find . -maxdepth 1 -type f -name '*.tgz' | sort) + test "${#tarballs[@]}" -eq 1 + { + echo "artifact_paths<> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v7 + with: + name: npm-release-package + path: ./*.tgz + + sign: + name: "IntentProof Release: Sign npm Package Tarball" + needs: pack + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.pack.outputs.release_ref }} + + - uses: actions/download-artifact@v8 + with: + name: npm-release-package + path: . + + - uses: sigstore/cosign-installer@v3 + + - uses: anchore/sbom-action/download-syft@v0 + + - name: Sign tarball and generate release metadata + env: + ARTIFACT_PATHS: ${{ needs.pack.outputs.artifact_paths }} + RELEASE_VERSION: ${{ needs.pack.outputs.release_version }} + ATTEST_TO_REKOR: ${{ github.event_name != 'workflow_dispatch' }} + run: | + set -euo pipefail + tlog_args=() + if [[ "$ATTEST_TO_REKOR" != "true" ]]; then + tlog_args+=(--tlog-upload=false) + fi + + mkdir -p release-attestations + : > release-attestations/SHA256SUMS + + while IFS= read -r artifact; do + [[ -z "$artifact" ]] && continue + test -f "$artifact" || { echo "artifact not found: $artifact" >&2; exit 1; } + + ( + cd "$(dirname "$artifact")" + sha256sum "$(basename "$artifact")" + ) >> release-attestations/SHA256SUMS + cosign sign-blob --yes \ + "${tlog_args[@]}" \ + --bundle "${artifact}.sigstore.json" \ + --output-signature "${artifact}.sig" \ + "$artifact" + + syft packages "file:${artifact}" \ + -o "spdx-json=${artifact}.spdx.json" + + syft packages . \ + -o "spdx-json=${artifact}.source.spdx.json" + + cosign attest-blob --yes \ + "${tlog_args[@]}" \ + --type spdxjson \ + --predicate "${artifact}.spdx.json" \ + --bundle "${artifact}.sbom.sigstore.json" \ + "$artifact" + + artifact_sha="$(sha256sum "$artifact" | awk '{print $1}')" + ARTIFACT_PATH="$artifact" ARTIFACT_SHA="$artifact_sha" python3 - <<'PY' + import json + import os + from pathlib import Path + + artifact_path = os.environ["ARTIFACT_PATH"] + run_id = os.environ["GITHUB_RUN_ID"] + predicate = { + "builder": { + "id": ( + f"{os.environ['GITHUB_SERVER_URL']}/" + f"{os.environ['GITHUB_REPOSITORY']}/actions/runs/{run_id}" + ) + }, + "buildType": "https://github.com/IntentProof/release-npm", + "invocation": { + "parameters": { + "releaseVersion": os.environ["RELEASE_VERSION"], + "artifactPath": artifact_path, + "subjectName": "@intentproof/sdk", + } + }, + "metadata": { + "buildInvocationId": ( + f"{run_id}-{os.environ['GITHUB_RUN_ATTEMPT']}" + ) + }, + "materials": [ + { + "uri": f"file:{artifact_path}", + "digest": {"sha256": os.environ["ARTIFACT_SHA"]}, + } + ], + } + Path(f"{artifact_path}.intoto.jsonl").write_text( + json.dumps(predicate, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + PY + cosign attest-blob --yes \ + "${tlog_args[@]}" \ + --type slsaprovenance \ + --predicate "${artifact}.intoto.jsonl" \ + --bundle "${artifact}.intoto.sigstore.json" \ + "$artifact" + done <<< "$ARTIFACT_PATHS" + + cosign sign-blob --yes \ + "${tlog_args[@]}" \ + --bundle release-attestations/SHA256SUMS.sigstore.json \ + --output-signature release-attestations/SHA256SUMS.sig \ + release-attestations/SHA256SUMS + + - uses: actions/upload-artifact@v7 + with: + name: release-signing-metadata + path: | + **/*.sig + **/*.sigstore.json + **/*.spdx.json + **/*.intoto.jsonl + release-attestations/SHA256SUMS + + publish: + name: "IntentProof Release: Publish npm Package" + needs: pack + if: github.event_name != 'workflow_dispatch' || inputs.publish_to_npm + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-node@v6 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - uses: actions/download-artifact@v8 + with: + name: npm-release-package + path: . + + - name: Publish with npm provenance + run: | + set -euo pipefail + if [[ -n "${NPM_CONFIG_USERCONFIG:-}" && -f "$NPM_CONFIG_USERCONFIG" ]]; then + sed -i '/:_authToken=/d' "$NPM_CONFIG_USERCONFIG" + fi + npm --version + node --version + mapfile -t tarballs < <(find . -maxdepth 1 -type f -name '*.tgz' | sort) + test "${#tarballs[@]}" -eq 1 + npm publish "${tarballs[0]}" --provenance --access public \ + --tag "${{ needs.pack.outputs.npm_dist_tag }}" + + upload-release: + name: "IntentProof Release: Publish npm Release Artifacts" + needs: + - pack + - sign + - publish + if: github.event_name != 'workflow_dispatch' && needs.pack.result == 'success' && needs.sign.result == 'success' && needs.publish.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + name: npm-release-package + path: release-package + + - uses: actions/download-artifact@v8 + with: + name: release-signing-metadata + path: release-signing-metadata + + - name: Upload tarball and signing metadata to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + mapfile -t files < <(find release-package release-signing-metadata -type f | sort) + test "${#files[@]}" -gt 0 + + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" --generate-notes + fi + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber