Release v0.0.0-beta.13 #10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # SPDX-License-Identifier: Apache-2.0 | |
| name: Release | |
| run-name: Release ${{ inputs.tag }} | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: "Immutable v* tag" | |
| required: true | |
| type: string | |
| prepare_run_id: | |
| description: "Successful Release Prepare run ID" | |
| required: true | |
| type: string | |
| distribution_ref: | |
| description: "Optional ref for distribution workflow/helper fixes" | |
| required: false | |
| type: string | |
| crates: | |
| description: "Publish crates.io packages" | |
| required: true | |
| default: true | |
| type: boolean | |
| pypi: | |
| description: "Publish prepared Python files" | |
| required: true | |
| default: true | |
| type: boolean | |
| npm: | |
| description: "Publish prepared npm tarballs" | |
| required: true | |
| default: true | |
| type: boolean | |
| github: | |
| description: "Create/update GitHub release" | |
| required: true | |
| default: true | |
| type: boolean | |
| concurrency: | |
| group: release-distribution-${{ inputs.tag }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| actions: read | |
| jobs: | |
| validate: | |
| uses: ./.github/workflows/release-validate.yml | |
| with: | |
| tag: ${{ inputs.tag }} | |
| provenance: | |
| needs: validate | |
| runs-on: ubuntu-latest | |
| outputs: | |
| artifact: ${{ steps.check.outputs.artifact }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.distribution_ref || inputs.tag }} | |
| - id: check | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RUN: ${{ inputs.prepare_run_id }} | |
| TAG: ${{ inputs.tag }} | |
| SHA: ${{ needs.validate.outputs.source_sha }} | |
| PUBLISH_CRATES: ${{ inputs.crates }} | |
| PUBLISH_PYPI: ${{ inputs.pypi }} | |
| PUBLISH_NPM: ${{ inputs.npm }} | |
| PUBLISH_GITHUB: ${{ inputs.github }} | |
| run: | | |
| set -euo pipefail | |
| [[ "$RUN" =~ ^[0-9]+$ ]] || { echo "invalid prepare run ID" >&2; exit 1; } | |
| [[ "$PUBLISH_CRATES$PUBLISH_PYPI$PUBLISH_NPM$PUBLISH_GITHUB" == *true* ]] || { echo "enable at least one distribution target" >&2; exit 1; } | |
| RUN_JSON="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$RUN")" | |
| test "$(jq -r .event <<<"$RUN_JSON")" = push | |
| test "$(jq -r .conclusion <<<"$RUN_JSON")" = success | |
| test "$(jq -r .head_sha <<<"$RUN_JSON")" = "$SHA" | |
| RUN_PATH="$(jq -r .path <<<"$RUN_JSON")" | |
| case "$RUN_PATH" in | |
| .github/workflows/release-prepare.yml|.github/workflows/release-prepare.yml@*) ;; | |
| *) echo "prepare run used unexpected workflow path: $RUN_PATH" >&2; exit 1 ;; | |
| esac | |
| test "$(jq -r .repository.full_name <<<"$RUN_JSON")" = "$GITHUB_REPOSITORY" | |
| test "$(jq -r .head_repository.full_name <<<"$RUN_JSON")" = "$GITHUB_REPOSITORY" | |
| ARTIFACT="release-bundle-${{ needs.validate.outputs.version }}" | |
| COUNT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$RUN/artifacts" --paginate --jq ".artifacts[] | select(.name == \"$ARTIFACT\" and .expired == false) | .id" | wc -l)" | |
| test "$COUNT" -eq 1 || { echo "expected exactly one live $ARTIFACT artifact, got $COUNT" >&2; exit 1; } | |
| echo "artifact=$ARTIFACT" >> "$GITHUB_OUTPUT" | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: ${{ steps.check.outputs.artifact }} | |
| run-id: ${{ inputs.prepare_run_id }} | |
| github-token: ${{ github.token }} | |
| path: bundle | |
| - name: Verify exact prepared bundle before protected environments | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/release/artifact_manifest.py verify --directory bundle --manifest bundle/release-manifest.json --repository "$GITHUB_REPOSITORY" --tag "${{ inputs.tag }}" --source-sha "${{ needs.validate.outputs.source_sha }}" --prepare-run-id "${{ inputs.prepare_run_id }}" --workflow release-prepare.yml --version "${{ needs.validate.outputs.version }}" | |
| python3 scripts/release/artifact_manifest.py verify-checksums --directory bundle --manifest bundle/release-manifest.json --checksums bundle/SHA256SUMS | |
| publish-crates: | |
| needs: [validate, provenance] | |
| if: inputs.crates | |
| runs-on: ubuntu-latest | |
| environment: crates.io | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.distribution_ref || inputs.tag }} | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.validate.outputs.source_sha }} | |
| path: tagged-source | |
| - uses: dtolnay/rust-toolchain@stable | |
| - run: python3 scripts/release/version_contract.py stamp "${{ needs.validate.outputs.version }}" --source tagged-source --destination release-source | |
| - shell: bash | |
| working-directory: release-source | |
| env: | |
| CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| VERSION="${{ needs.validate.outputs.version }}" | |
| USER_AGENT="code2graph-release/$VERSION ($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)" | |
| available() { curl -fsS -A "$USER_AGENT" "https://crates.io/api/v1/crates/$1/$VERSION" >/dev/null; } | |
| verify_remote() { | |
| local package="$1" remote | |
| remote="$(mktemp)" | |
| curl -fsSL -A "$USER_AGENT" "https://crates.io/api/v1/crates/$package/$VERSION/download" -o "$remote" | |
| cmp --silent "target/package/$package-$VERSION.crate" "$remote" || { echo "crates.io archive differs for $package@$VERSION" >&2; return 1; } | |
| } | |
| publish() { | |
| local package="$1" | |
| cargo package -p "$package" --allow-dirty --no-verify | |
| if available "$package"; then | |
| verify_remote "$package" | |
| return | |
| fi | |
| if ! cargo publish -p "$package" --allow-dirty --no-verify; then | |
| echo "cargo publish returned an error; reconciling against crates.io before failing" >&2 | |
| fi | |
| for _ in {1..30}; do | |
| if available "$package"; then verify_remote "$package" && return; fi | |
| sleep 10 | |
| done | |
| echo "$package@$VERSION did not become available after publish reconciliation" >&2 | |
| return 1 | |
| } | |
| publish code2graph | |
| publish code2graph-query | |
| publish code2graph-cli | |
| publish-pypi: | |
| needs: [validate, provenance] | |
| if: inputs.pypi | |
| runs-on: ubuntu-latest | |
| environment: pypi | |
| permissions: | |
| id-token: write | |
| actions: read | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.distribution_ref || inputs.tag }} | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: ${{ needs.provenance.outputs.artifact }} | |
| run-id: ${{ inputs.prepare_run_id }} | |
| github-token: ${{ github.token }} | |
| path: bundle | |
| - name: Reject different existing files and verify eventual registry hashes | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| project=code2graph-rs | |
| version="${{ needs.validate.outputs.python_version }}" | |
| release_json() { curl -fsSL "https://pypi.org/pypi/$project/$version/json"; } | |
| check_hashes() { | |
| local json="$1" file name local_hash remote_hash | |
| mapfile -t local_names < <(find bundle/python -maxdepth 1 -type f -printf '%f\n' | sort) | |
| mapfile -t remote_names < <(jq -r '.urls[].filename' <<<"$json" | sort) | |
| for name in "${remote_names[@]}"; do | |
| printf '%s\n' "${local_names[@]}" | grep -Fxq "$name" || { echo "unexpected PyPI artifact $name" >&2; return 1; } | |
| file="bundle/python/$name" | |
| local_hash="$(sha256sum "$file" | awk '{print $1}')" | |
| remote_hash="$(jq -r --arg name "$name" '.urls[] | select(.filename == $name) | .digests.sha256' <<<"$json")" | |
| test "$remote_hash" = "$local_hash" || { echo "PyPI artifact mismatch for $name" >&2; return 1; } | |
| done | |
| } | |
| if json="$(release_json 2>/dev/null)"; then check_hashes "$json"; fi | |
| - uses: pypa/gh-action-pypi-publish@release/v1 | |
| with: | |
| packages-dir: bundle/python | |
| skip-existing: true | |
| - name: Poll and re-verify published Python files | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| for attempt in {1..90}; do | |
| if json="$(curl -fsSL -H 'Cache-Control: no-cache' "https://pypi.org/pypi/code2graph-rs/${{ needs.validate.outputs.python_version }}/json?attempt=$attempt" 2>/dev/null)"; then | |
| mapfile -t local_names < <(find bundle/python -maxdepth 1 -type f -printf '%f\n' | sort) | |
| mapfile -t remote_names < <(jq -r '.urls[].filename' <<<"$json" | sort) | |
| complete=true | |
| test "${local_names[*]}" = "${remote_names[*]}" || complete=false | |
| for file in bundle/python/*; do | |
| name="$(basename "$file")"; hash="$(sha256sum "$file" | awk '{print $1}')" | |
| test "$(jq -r --arg name "$name" '.urls[] | select(.filename == $name) | .digests.sha256' <<<"$json")" = "$hash" || complete=false | |
| done | |
| "$complete" && exit 0 | |
| fi | |
| sleep 5 | |
| done | |
| exit 1 | |
| publish-npm: | |
| needs: [validate, provenance] | |
| if: inputs.npm | |
| runs-on: ubuntu-latest | |
| environment: npm | |
| permissions: | |
| id-token: write | |
| actions: read | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.distribution_ref || inputs.tag }} | |
| - uses: actions/setup-node@v5 | |
| with: | |
| node-version: "22" | |
| registry-url: https://registry.npmjs.org | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: ${{ needs.provenance.outputs.artifact }} | |
| run-id: ${{ inputs.prepare_run_id }} | |
| github-token: ${{ github.token }} | |
| path: bundle | |
| - name: Publish prepared platform packages before the root package | |
| shell: bash | |
| env: | |
| # A token is required to bootstrap previously unpublished platform packages; | |
| # provenance remains attached and subsequent releases stay idempotent. | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| publish() { | |
| local tarball="$1" name version remote | |
| name=$(tar -xOf "$tarball" package/package.json | jq -r .name) | |
| version=$(tar -xOf "$tarball" package/package.json | jq -r .version) | |
| if npm view "$name@$version" version >/dev/null 2>&1; then | |
| remote="$(mktemp -d)" | |
| npm pack "$name@$version" --pack-destination "$remote" | |
| python3 scripts/release/artifact_manifest.py compare-npm --local "$tarball" --remote "$(find "$remote" -name '*.tgz' -print -quit)" | |
| else | |
| npm publish "$tarball" --access public --provenance --tag "${{ needs.validate.outputs.is_prerelease == 'true' && 'next' || 'latest' }}" | |
| fi | |
| for _ in {1..30}; do | |
| if npm view "$name@$version" version >/dev/null 2>&1; then | |
| remote="$(mktemp -d)" | |
| npm pack "$name@$version" --pack-destination "$remote" | |
| python3 scripts/release/artifact_manifest.py compare-npm --local "$tarball" --remote "$(find "$remote" -name '*.tgz' -print -quit)" | |
| return | |
| fi | |
| sleep 2 | |
| done | |
| return 1 | |
| } | |
| shopt -s nullglob | |
| platform_tarballs=(bundle/npm/*linux-*.tgz bundle/npm/*darwin-*.tgz bundle/npm/*win32-*.tgz) | |
| test "${#platform_tarballs[@]}" -eq 6 | |
| for file in "${platform_tarballs[@]}"; do publish "$file"; done | |
| root_tarballs=(bundle/npm/nodedb-lab-code2graph-*.tgz) | |
| roots=() | |
| for file in "${root_tarballs[@]}"; do [[ "$file" == *linux-* || "$file" == *darwin-* || "$file" == *win32-* ]] || roots+=("$file"); done | |
| test "${#roots[@]}" -eq 1 | |
| publish "${roots[0]}" | |
| github-release: | |
| needs: [validate, provenance] | |
| if: inputs.github | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| actions: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.tag }} | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: ${{ needs.provenance.outputs.artifact }} | |
| run-id: ${{ inputs.prepare_run_id }} | |
| github-token: ${{ github.token }} | |
| path: bundle | |
| - name: Create or update exact GitHub release | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TAG: ${{ inputs.tag }} | |
| VERSION: ${{ needs.validate.outputs.version }} | |
| PRERELEASE: ${{ needs.validate.outputs.is_prerelease }} | |
| SHA: ${{ needs.validate.outputs.source_sha }} | |
| run: | | |
| set -euo pipefail | |
| mapfile -t files < <(find bundle -type f | sort) | |
| test "${#files[@]}" -eq 16 | |
| prerelease_flag=() | |
| test "$PRERELEASE" = true && prerelease_flag=(--prerelease) | |
| if gh release view "$TAG" >/dev/null 2>&1; then | |
| edit_flags=(--title "code2graph $VERSION" --target "$SHA" "--prerelease=$PRERELEASE") | |
| test "$PRERELEASE" = false && edit_flags+=(--latest) | |
| gh release edit "$TAG" "${edit_flags[@]}" | |
| else | |
| gh release create "$TAG" "${files[@]}" --verify-tag --target "$SHA" --title "code2graph $VERSION" --generate-notes "${prerelease_flag[@]}" | |
| fi | |
| mapfile -t expected < <(printf '%s\n' "${files[@]##*/}" | sort) | |
| while IFS=$'\t' read -r id name; do | |
| if ! printf '%s\n' "${expected[@]}" | grep -Fxq "$name"; then | |
| gh api --method DELETE "/repos/$GITHUB_REPOSITORY/releases/assets/$id" | |
| fi | |
| done < <(gh api "/repos/$GITHUB_REPOSITORY/releases/tags/$TAG" --jq '.assets[] | [.id,.name] | @tsv') | |
| gh release upload "$TAG" "${files[@]}" --clobber | |
| release_json="$(gh api "/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")" | |
| test "$(jq -r .tag_name <<<"$release_json")" = "$TAG" | |
| test "$(jq -r .target_commitish <<<"$release_json")" = "$SHA" | |
| test "$(jq -r .prerelease <<<"$release_json")" = "$PRERELEASE" | |
| mapfile -t actual < <(jq -r '.assets[].name' <<<"$release_json" | sort) | |
| test "${actual[*]}" = "${expected[*]}" || { echo "GitHub release attachment set mismatch" >&2; exit 1; } |