Repository navigation
Merge pull request #198 from NDDev-OpenNetwork/docs/restore-architect… #39
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
| name: gds-release-bundle | |
| on: | |
| # One version line: every green main publishes the next patch release. | |
| # Pull-request review and required checks are the authorization gate; the | |
| # merge commit is released without a second human action. | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "Optional explicit SemVer for a minor/major release; empty resolves the next patch" | |
| required: false | |
| type: string | |
| permissions: {} | |
| concurrency: | |
| group: gds-release | |
| cancel-in-progress: false | |
| jobs: | |
| resolve: | |
| name: resolve next release identity and tag | |
| # This job holds ref-write authority only. It never checks out or executes | |
| # candidate source: every read is a provider API call, and the single write | |
| # is the exact version tag the build job must then produce byte-identically. | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: write | |
| outputs: | |
| version: ${{ steps.resolve.outputs.version }} | |
| sequence: ${{ steps.resolve.outputs.sequence }} | |
| minimum_cli_version: ${{ steps.resolve.outputs.minimum_cli_version }} | |
| tag: ${{ steps.resolve.outputs.tag }} | |
| released: ${{ steps.resolve.outputs.released }} | |
| steps: | |
| - name: Resolve next version, sequence, and tag | |
| id: resolve | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| VERSION_OVERRIDE: ${{ inputs.version }} | |
| run: | | |
| set -euo pipefail | |
| mapfile -t tags < <( | |
| gh api "repos/$GITHUB_REPOSITORY/tags?per_page=100" --paginate --jq '.[].name' \ | |
| | grep -x 'gds-v[0-9]*\.[0-9]*\.[0-9]*' \ | |
| | sort -t. -k1.6,1nr -k2,2nr -k3,3nr | |
| ) | |
| if [ "${#tags[@]}" -eq 0 ]; then | |
| echo "No gds-v* tag exists; the first automatic release requires one published baseline" >&2 | |
| exit 1 | |
| fi | |
| latest_tag="${tags[0]}" | |
| latest_commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/$latest_tag" --jq '.sha')" | |
| # A rerun of this workflow on an already-released merge commit must | |
| # not mint a second release identity for the same tree. | |
| if [ "$latest_commit" = "$GITHUB_SHA" ] && [ -z "$VERSION_OVERRIDE" ]; then | |
| echo "released=false" >> "$GITHUB_OUTPUT" | |
| echo "tag=$latest_tag" >> "$GITHUB_OUTPUT" | |
| echo "version=${latest_tag#gds-v}" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # The published release envelope is the authoritative sequence | |
| # ledger: it is written by this same pipeline and attached to the | |
| # GitHub Release, so no repository file can drift from it. Walk the | |
| # tag list newest-first until a release provides a readable envelope. | |
| previous="" | |
| work="$(mktemp -d)" | |
| for candidate in "${tags[@]}"; do | |
| if gh release download "$candidate" --repo "$GITHUB_REPOSITORY" \ | |
| -p release-envelope.json -p manifest.json --dir "$work" --clobber >/dev/null 2>&1; then | |
| previous="$candidate" | |
| break | |
| fi | |
| done | |
| if [ -z "$previous" ]; then | |
| echo "No published release carries a readable release-envelope.json" >&2 | |
| exit 1 | |
| fi | |
| previous_sequence="$(jq -r '.release_sequence' "$work/release-envelope.json")" | |
| previous_floor="$(jq -r '.minimum_cli_version' "$work/manifest.json")" | |
| [[ "$previous_sequence" =~ ^[0-9]+$ ]] || { echo "previous release sequence is not an integer" >&2; exit 1; } | |
| previous_version="${previous#gds-v}" | |
| if [ -n "$VERSION_OVERRIDE" ]; then | |
| next_version="$VERSION_OVERRIDE" | |
| else | |
| next_version="$(awk -F. -v v="$previous_version" 'BEGIN{split(v,p,"."); printf "%d.%d.%d", p[1], p[2], p[3]+1}')" | |
| fi | |
| # Monotonicity is structural, not assumed: the new identity must be | |
| # strictly greater than the release it follows. Compare components | |
| # numerically -- sort -n reads "9.10" as the float 9.1, which orders | |
| # a double-digit patch below its predecessor. | |
| if ! awk -F. -v a="$previous_version" -v b="$next_version" 'BEGIN{ | |
| split(a, x, "."); split(b, y, "."); | |
| for (i = 1; i <= 3; i++) { | |
| if (y[i] + 0 > x[i] + 0) exit 0; | |
| if (y[i] + 0 < x[i] + 0) exit 1; | |
| } | |
| exit 1 | |
| }'; then | |
| echo "next version $next_version is not greater than $previous_version" >&2 | |
| exit 1 | |
| fi | |
| next_sequence=$((previous_sequence + 1)) | |
| next_tag="gds-v$next_version" | |
| if gh api "repos/$GITHUB_REPOSITORY/git/refs/tags/$next_tag" >/dev/null 2>&1; then | |
| echo "tag $next_tag already exists" >&2 | |
| exit 1 | |
| fi | |
| gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" \ | |
| -f "ref=refs/tags/$next_tag" -f "sha=$GITHUB_SHA" --jq '.ref' >/dev/null | |
| { | |
| echo "released=true" | |
| echo "version=$next_version" | |
| echo "sequence=$next_sequence" | |
| echo "minimum_cli_version=$previous_floor" | |
| echo "tag=$next_tag" | |
| } >> "$GITHUB_OUTPUT" | |
| build: | |
| name: build immutable bundle (unprivileged) | |
| needs: resolve | |
| if: ${{ needs.resolve.outputs.released == 'true' }} | |
| # Privilege separation (RVR2-P1-001): all repository-controlled source, | |
| # scripts, dependencies, and the release builder run here with contents:read | |
| # only — no OIDC, no attestations, no release-write authority. The publish | |
| # job downloads this job's artifact and verifies SHA256SUMS before any OIDC | |
| # token is requested. | |
| # | |
| # This public repository builds only on GitHub-hosted runners. The build, | |
| # attestation and publication jobs keep the same provider, as required by | |
| # the release workflow contract. Permissions remain separate per job. | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 75 | |
| permissions: | |
| contents: read | |
| env: | |
| GOTOOLCHAIN: go1.27.1 | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| RELEASE_SEQUENCE: ${{ needs.resolve.outputs.sequence }} | |
| MINIMUM_CLI_VERSION: ${{ needs.resolve.outputs.minimum_cli_version }} | |
| RELEASE_TAG: ${{ needs.resolve.outputs.tag }} | |
| steps: | |
| - name: Check out exact source | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ needs.resolve.outputs.tag }} | |
| fetch-depth: 0 | |
| - name: Install exact Go toolchain | |
| uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 | |
| with: | |
| go-version: "1.27.1" | |
| cache: true | |
| - name: Run release gates | |
| shell: bash | |
| env: | |
| GDS_TEST_PYTHON: ${{ runner.temp }}/gds-release-python/bin/python | |
| run: | | |
| python3 -m venv "${GDS_TEST_PYTHON%/bin/python}" | |
| export PATH="${GDS_TEST_PYTHON%/python}:$PATH" | |
| "$GDS_TEST_PYTHON" -m pip install --quiet --require-hashes -r requirements/test.txt | |
| scripts/validate_release.sh | |
| - name: Build and independently verify release unit | |
| env: | |
| RELEASE_OUTPUT_ROOT: ${{ runner.temp }}/gds-release-output | |
| RELEASE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| install -d -m 0700 "$RELEASE_OUTPUT_ROOT" | |
| GO_BINARY="$(go env GOROOT)/bin/go" | |
| test -x "$GO_BINARY" | |
| go run ./core/cmd/gds-release-builder \ | |
| --root "$GITHUB_WORKSPACE" \ | |
| --output "$RELEASE_DIRECTORY" \ | |
| --version "$VERSION" \ | |
| --sequence "$RELEASE_SEQUENCE" \ | |
| --minimum-cli-version "$MINIMUM_CLI_VERSION" \ | |
| --source-ref "refs/tags/$RELEASE_TAG" \ | |
| --trigger-ref "$GITHUB_REF" \ | |
| --go-binary "$GO_BINARY" \ | |
| > "$RUNNER_TEMP/gds-release-result.json" | |
| go run ./core/cmd/gds-release-builder \ | |
| --verify-directory "$RELEASE_DIRECTORY" \ | |
| > "$RUNNER_TEMP/gds-release-verification.json" | |
| awk -v prefix="release/" \ | |
| '{ print $1 " " prefix $2 }' \ | |
| "$RELEASE_DIRECTORY/SHA256SUMS" \ | |
| > "$RELEASE_OUTPUT_ROOT/attestation-subjects.sha256" | |
| test "$(wc -l < "$RELEASE_OUTPUT_ROOT/attestation-subjects.sha256" | tr -d ' ')" = "5" | |
| - name: Stage build evidence alongside release directory | |
| env: | |
| BUILD_META_DIRECTORY: ${{ runner.temp }}/gds-release-output/build-meta | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| install -d -m 0700 "$BUILD_META_DIRECTORY" | |
| cp -- "$RUNNER_TEMP/gds-release-result.json" "$BUILD_META_DIRECTORY/build-result.json" | |
| cp -- "$RUNNER_TEMP/gds-release-verification.json" "$BUILD_META_DIRECTORY/verification-result.json" | |
| - name: Upload immutable release artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: gds-release-${{ needs.resolve.outputs.version }}-${{ github.sha }} | |
| # Upload one owned staging root. Supplying unrelated absolute and | |
| # workspace-relative paths makes upload-artifact preserve their | |
| # runner-wide common ancestor, so download-artifact reconstructs | |
| # `_temp/gds-release-output/release` instead of the contracted | |
| # `release/` directory beneath its destination. | |
| path: ${{ runner.temp }}/gds-release-output | |
| if-no-files-found: error | |
| compression-level: 0 | |
| retention-days: 30 | |
| attest: | |
| name: attest immutable bundle | |
| needs: [resolve, build] | |
| # This privileged job holds OIDC and attestation authority, but no release- | |
| # write authority. Publication is a separate job with no OIDC permission. | |
| # It never checks out candidate source and never executes candidate code. | |
| # It downloads the build artifact, verifies SHA256SUMS with runner-owned | |
| # tools, and only then requests OIDC tokens for attestation. All semantic | |
| # release validation and candidate execution belongs to the unprivileged | |
| # build job above; this job treats its output as inert data. | |
| # Keep this job on the same hosted provider as build and publication. | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 75 | |
| permissions: | |
| contents: read | |
| id-token: write | |
| attestations: write | |
| steps: | |
| - name: Download immutable release artifact | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: gds-release-${{ needs.resolve.outputs.version }}-${{ github.sha }} | |
| path: ${{ runner.temp }}/gds-release-output | |
| merge-multiple: true | |
| - name: Verify release artifact digest manifest | |
| # Fail the privileged job before requesting any OIDC token if the | |
| # artifact's contents were tampered with in transit or on the runner. | |
| # download-artifact with merge-multiple flattens the artifact contents | |
| # directly into path:, recreating release/, build-meta/, and | |
| # attestation-subjects.sha256 under $RELEASE_OUTPUT_ROOT. | |
| shell: bash | |
| env: | |
| RELEASE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release | |
| run: | | |
| set -euo pipefail | |
| cd -- "$RELEASE_DIRECTORY" | |
| test -f SHA256SUMS | |
| sha256sum -c -- SHA256SUMS | |
| - name: Attest release file provenance | |
| id: provenance | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-checksums: ${{ runner.temp }}/gds-release-output/attestation-subjects.sha256 | |
| - name: Attest executable bundle SBOM | |
| id: sbom | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-path: ${{ runner.temp }}/gds-release-output/release/gds-bundle-v${{ needs.resolve.outputs.version }}.tar.gz | |
| sbom-path: ${{ runner.temp }}/gds-release-output/release/sbom.spdx.json | |
| - name: Materialize offline verification evidence | |
| shell: bash | |
| env: | |
| RELEASE_OUTPUT_ROOT: ${{ runner.temp }}/gds-release-output | |
| EVIDENCE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release-evidence | |
| PROVENANCE_BUNDLE: ${{ steps.provenance.outputs.bundle-path }} | |
| SBOM_BUNDLE: ${{ steps.sbom.outputs.bundle-path }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -- "$EVIDENCE_DIRECTORY" | |
| test -f "$PROVENANCE_BUNDLE" | |
| test -f "$SBOM_BUNDLE" | |
| cp -- "$PROVENANCE_BUNDLE" "$EVIDENCE_DIRECTORY/provenance.sigstore.json" | |
| cp -- "$SBOM_BUNDLE" "$EVIDENCE_DIRECTORY/sbom.sigstore.json" | |
| gh attestation trusted-root > "$EVIDENCE_DIRECTORY/trusted-root.jsonl" | |
| test -s "$EVIDENCE_DIRECTORY/provenance.sigstore.json" | |
| test -s "$EVIDENCE_DIRECTORY/sbom.sigstore.json" | |
| test -s "$EVIDENCE_DIRECTORY/trusted-root.jsonl" | |
| # Parse every JSON value with a runner-owned tool. Semantic trust | |
| # policy verification remains the release consumer's responsibility; | |
| # running the candidate verifier in this privileged job would cross | |
| # the release privilege boundary. | |
| jq --exit-status 'objects' "$EVIDENCE_DIRECTORY/trusted-root.jsonl" >/dev/null | |
| cp -- "$RELEASE_OUTPUT_ROOT/build-meta/build-result.json" "$EVIDENCE_DIRECTORY/build-result.json" | |
| cp -- "$RELEASE_OUTPUT_ROOT/build-meta/verification-result.json" "$EVIDENCE_DIRECTORY/verification-result.json" | |
| - name: Upload immutable offline evidence | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: gds-release-evidence-${{ needs.resolve.outputs.version }}-${{ github.sha }} | |
| path: ${{ runner.temp }}/gds-release-output/release-evidence | |
| if-no-files-found: error | |
| compression-level: 0 | |
| retention-days: 30 | |
| publish: | |
| name: publish immutable bundle | |
| needs: [resolve, attest] | |
| # This job can create the GitHub Release but cannot request an OIDC token or | |
| # create attestations. It consumes only inert artifacts produced by the | |
| # preceding jobs and never checks out or executes candidate source. | |
| # Keep the same hosted provider as build and attestation. | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Download immutable release artifact | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: gds-release-${{ needs.resolve.outputs.version }}-${{ github.sha }} | |
| path: ${{ runner.temp }}/gds-release-output | |
| merge-multiple: true | |
| - name: Download immutable offline evidence | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: gds-release-evidence-${{ needs.resolve.outputs.version }}-${{ github.sha }} | |
| path: ${{ runner.temp }}/gds-release-output/release-evidence | |
| merge-multiple: true | |
| - name: Verify release artifact digest manifest | |
| shell: bash | |
| env: | |
| RELEASE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release | |
| EVIDENCE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release-evidence | |
| run: | | |
| set -euo pipefail | |
| cd -- "$RELEASE_DIRECTORY" | |
| test -f SHA256SUMS | |
| sha256sum -c -- SHA256SUMS | |
| test -s "$EVIDENCE_DIRECTORY/provenance.sigstore.json" | |
| test -s "$EVIDENCE_DIRECTORY/sbom.sigstore.json" | |
| test -s "$EVIDENCE_DIRECTORY/trusted-root.jsonl" | |
| # Every run that reaches publication is a real release on an exact tag. | |
| # The release attaches the governance-verified assets AND the offline | |
| # verification evidence: the workflow artifact expires, so evidence kept | |
| # only there would leave a published release unverifiable offline. | |
| - name: Publish GitHub Release | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release | |
| EVIDENCE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release-evidence | |
| RELEASE_TAG: ${{ needs.resolve.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| gh release create "$RELEASE_TAG" \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --title "GDS $RELEASE_TAG" \ | |
| --verify-tag \ | |
| --latest \ | |
| --notes "Immutable GDS bundle for ${RELEASE_TAG}. Multi-platform binaries + SPDX SBOM + SHA256SUMS, with keyless SLSA build provenance and SBOM attestations. Online: gh attestation verify <asset> -R ${GITHUB_REPOSITORY}. Offline: verify <asset> against provenance.sigstore.json and sbom.sigstore.json using trusted-root.jsonl, all attached here." \ | |
| "$RELEASE_DIRECTORY"/* \ | |
| "$EVIDENCE_DIRECTORY"/* | |
| record-failure: | |
| name: Record failed release evidence | |
| needs: [resolve, build, attest, publish] | |
| if: ${{ always() && needs.resolve.outputs.released == 'true' && (needs.build.result == 'failure' || needs.attest.result == 'failure' || needs.publish.result == 'failure') }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.resolve.outputs.tag }} | |
| RELEASE_VERSION: ${{ needs.resolve.outputs.version }} | |
| RELEASE_SEQUENCE: ${{ needs.resolve.outputs.sequence }} | |
| BUILD_RESULT: ${{ needs.build.result }} | |
| ATTEST_RESULT: ${{ needs.attest.result }} | |
| PUBLISH_RESULT: ${{ needs.publish.result }} | |
| steps: | |
| - name: Publish machine-readable failure envelope | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| envelope="$RUNNER_TEMP/release-failure-envelope.json" | |
| failed_jobs="$RUNNER_TEMP/failed-jobs.json" | |
| jq -cn \ | |
| --arg build "$BUILD_RESULT" \ | |
| --arg attest "$ATTEST_RESULT" \ | |
| --arg publish "$PUBLISH_RESULT" \ | |
| '[{name:"build",result:$build},{name:"attest",result:$attest},{name:"publish",result:$publish}] | map(select(.result == "failure") | .name)' \ | |
| > "$failed_jobs" | |
| jq -n \ | |
| --arg version "$RELEASE_VERSION" \ | |
| --argjson sequence "$RELEASE_SEQUENCE" \ | |
| --arg commit "$GITHUB_SHA" \ | |
| --arg ref "refs/tags/$RELEASE_TAG" \ | |
| --arg trigger "$GITHUB_REF" \ | |
| --argjson run_id "$GITHUB_RUN_ID" \ | |
| --slurpfile failed "$failed_jobs" \ | |
| '{schema_version:1,status:"failed",bundle_version:$version,release_sequence:$sequence,source_commit:$commit,source_ref:$ref,trigger_ref:$trigger,workflow_run_id:$run_id,failed_jobs:$failed[0],superseded_by:null}' \ | |
| > "$envelope" | |
| if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then | |
| gh release upload "$RELEASE_TAG" "$envelope" --repo "$GITHUB_REPOSITORY" | |
| else | |
| gh release create "$RELEASE_TAG" \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --title "GDS $RELEASE_TAG (failed)" \ | |
| --verify-tag \ | |
| --notes "Release production failed. See release-failure-envelope.json for machine-readable evidence; retain this tag and release until a later release records it as superseded." \ | |
| "$envelope" | |
| fi |